Fast texture tool improvements (#3624)

https://files.facepunch.com/louie/1b1811b1/sbox-dev_wPVinlJTtE.png

-Support Tiling in the fast texture tool.
-Shortcuts.
-Escaping out resets to orignal uv.
-Unwrap Square better.
-World Mapping.
-Apply active material when entering fast texturing.
-Clicking in the rect view with no rect file fills to the full uv.
-Snapping toggle.
-Reset uv and focus buttons.
-New cleaner UI.
This commit is contained in:
bakscratch
2025-12-18 16:30:58 +00:00
committed by GitHub
parent 9c76279626
commit d5daad537b
6 changed files with 1159 additions and 162 deletions

View File

@@ -14,11 +14,59 @@ internal class EdgeAwareFaceUnwrapper
faces = meshFaces;
}
public UnwrapResult UnwrapToSquare()
public UnwrapResult Unwrap( MappingMode mode )
{
if ( faces.Length == 0 )
return new UnwrapResult();
bool straighten = mode == MappingMode.UnwrapSquare;
InitializeVertexMap();
var unwrappedUVs = new List<Vector2>( new Vector2[vertexPositions.Count] );
var processedFaces = new HashSet<MeshFace>();
var faceQueue = new Queue<MeshFace>();
if ( faces.Length > 0 && faces[0].IsValid )
{
// Seed the unwrap with the first face
UnwrapFirstFace( faces[0], unwrappedUVs, straighten );
processedFaces.Add( faces[0] );
for ( int i = 1; i < faces.Length; i++ )
{
if ( faces[i].IsValid )
faceQueue.Enqueue( faces[i] );
}
}
int maxAttempts = faces.Length * 3;
int attempts = 0;
while ( faceQueue.Count > 0 && attempts < maxAttempts )
{
var currentFace = faceQueue.Dequeue();
attempts++;
if ( processedFaces.Contains( currentFace ) )
continue;
if ( TryUnfoldFace( currentFace, processedFaces, unwrappedUVs, straighten ) )
{
processedFaces.Add( currentFace );
attempts = 0;
}
else if ( attempts < maxAttempts )
{
faceQueue.Enqueue( currentFace );
}
}
return BuildResult( unwrappedUVs );
}
private void InitializeVertexMap()
{
foreach ( var face in faces )
{
if ( !face.IsValid )
@@ -41,45 +89,200 @@ internal class EdgeAwareFaceUnwrapper
faceToVertexIndices[face] = indices;
}
}
var unwrappedUVs = new List<Vector2>( new Vector2[vertexPositions.Count] );
var processedFaces = new HashSet<MeshFace>();
var faceQueue = new Queue<MeshFace>();
private void UnwrapFirstFace( MeshFace face, List<Vector2> unwrappedUVs, bool straighten )
{
if ( !faceToVertexIndices.TryGetValue( face, out var indices ) || indices.Count < 3 )
return;
if ( faces.Length > 0 && faces[0].IsValid )
if ( straighten && indices.Count == 4 )
{
UnwrapFirstFace( faces[0], unwrappedUVs );
processedFaces.Add( faces[0] );
var p0 = vertexPositions[indices[0]];
var p1 = vertexPositions[indices[1]];
var p3 = vertexPositions[indices[3]];
for ( int i = 1; i < faces.Length; i++ )
float width = p0.Distance( p1 );
float height = p0.Distance( p3 );
unwrappedUVs[indices[0]] = new Vector2( 0, 0 );
unwrappedUVs[indices[1]] = new Vector2( width, 0 );
unwrappedUVs[indices[2]] = new Vector2( width, height );
unwrappedUVs[indices[3]] = new Vector2( 0, height );
}
else
{
var p0 = vertexPositions[indices[0]];
var p1 = vertexPositions[indices[1]];
var p2 = vertexPositions[indices[2]];
var uDir = (p1 - p0).Normal;
var normal = uDir.Cross( (p2 - p0).Normal ).Normal;
var vDir = normal.Cross( uDir );
foreach ( var idx in indices )
{
if ( faces[i].IsValid )
faceQueue.Enqueue( faces[i] );
var relative = vertexPositions[idx] - p0;
unwrappedUVs[idx] = new Vector2( relative.Dot( uDir ), relative.Dot( vDir ) );
}
}
}
private bool TryUnfoldFace( MeshFace currentFace, HashSet<MeshFace> processedFaces, List<Vector2> unwrappedUVs, bool straighten )
{
if ( !faceToVertexIndices.TryGetValue( currentFace, out var currentIndices ) )
return false;
foreach ( var processedFace in processedFaces )
{
var sharedEdge = FindSharedVertices( currentFace, processedFace, unwrappedUVs );
if ( sharedEdge.HasValue )
{
UnfoldFaceAlongEdge( currentFace, currentIndices, sharedEdge.Value, unwrappedUVs, straighten );
return true;
}
}
int maxAttempts = faces.Length * 3;
int attempts = 0;
return false;
}
while ( faceQueue.Count > 0 && attempts < maxAttempts )
private void UnfoldFaceAlongEdge( MeshFace face, List<int> faceIndices, (int idx1, int idx2, Vector2 uv1, Vector2 uv2) edge, List<Vector2> unwrappedUVs, bool straighten )
{
unwrappedUVs[edge.idx1] = edge.uv1;
unwrappedUVs[edge.idx2] = edge.uv2;
// SQUARE MODE.
if ( straighten && faceIndices.Count == 4 )
{
var currentFace = faceQueue.Dequeue();
attempts++;
UnfoldQuadStraight( faceIndices, edge, unwrappedUVs );
}
// CONFORMING MODE.
else
{
UnfoldGeometric( faceIndices, edge, unwrappedUVs );
}
}
if ( processedFaces.Contains( currentFace ) )
/// <summary>
/// Unfolds a quad by extruding the shared edge perpendicularly by the average length of the connecting sides.
/// This forces the UV strip to remain straight (grid-like) even if the 3D mesh curves.
/// </summary>
private void UnfoldQuadStraight( List<int> faceIndices, (int idx1, int idx2, Vector2 uv1, Vector2 uv2) edge, List<Vector2> unwrappedUVs )
{
var uvVec = edge.uv2 - edge.uv1;
var uvLen = uvVec.Length;
var uvNormal = uvLen > 0.000001f ? uvVec / uvLen : new Vector2( 1, 0 );
var uvPerp = new Vector2( -uvNormal.y, uvNormal.x ); // 90 degrees Left
int ptr1 = faceIndices.IndexOf( edge.idx1 );
int ptr2 = faceIndices.IndexOf( edge.idx2 );
int connectedTo1;
int connectedTo2;
if ( (ptr1 + 1) % 4 == ptr2 )
{
connectedTo2 = faceIndices[(ptr2 + 1) % 4];
connectedTo1 = faceIndices[(ptr1 + 3) % 4];
}
else
{
connectedTo1 = faceIndices[(ptr1 + 1) % 4];
connectedTo2 = faceIndices[(ptr2 + 3) % 4];
}
float len1 = vertexPositions[edge.idx1].Distance( vertexPositions[connectedTo1] );
float len2 = vertexPositions[edge.idx2].Distance( vertexPositions[connectedTo2] );
float avgLen = (len1 + len2) * 0.5f;
unwrappedUVs[connectedTo1] = edge.uv1 + uvPerp * avgLen;
unwrappedUVs[connectedTo2] = edge.uv2 + uvPerp * avgLen;
}
/// <summary>
/// Unfolds a face by projecting its vertices onto a 2D plane defined by the shared edge.
/// This preserves the original geometric angles and shapes.
/// </summary>
private void UnfoldGeometric( List<int> faceIndices, (int idx1, int idx2, Vector2 uv1, Vector2 uv2) edge, List<Vector2> unwrappedUVs )
{
var pA = vertexPositions[edge.idx1];
var pB = vertexPositions[edge.idx2];
var edge3D = pB - pA;
var edge2D = edge.uv2 - edge.uv1;
Vector3 pThird = Vector3.Zero;
foreach ( var idx in faceIndices )
{
if ( idx != edge.idx1 && idx != edge.idx2 )
{
pThird = vertexPositions[idx];
break;
}
}
var faceNormal = edge3D.Cross( pThird - pA ).Normal;
var localU = edge3D.Normal;
var localV = faceNormal.Cross( localU );
var edge2DDir = edge2D.Normal;
var edge2DPerp = new Vector2( -edge2DDir.y, edge2DDir.x );
var scale = edge3D.Length > 0 ? edge2D.Length / edge3D.Length : 1.0f;
foreach ( var idx in faceIndices )
{
if ( idx == edge.idx1 || idx == edge.idx2 )
continue;
if ( TryUnfoldFace( currentFace, processedFaces, unwrappedUVs ) )
var relative3D = vertexPositions[idx] - pA;
float u = relative3D.Dot( localU );
float v = relative3D.Dot( localV );
unwrappedUVs[idx] = edge.uv1 + edge2DDir * u * scale + edge2DPerp * v * scale;
}
}
private (int idx1, int idx2, Vector2 uv1, Vector2 uv2)? FindSharedVertices( MeshFace face1, MeshFace face2, List<Vector2> unwrappedUVs )
{
if ( !faceToVertexIndices.TryGetValue( face1, out var indices1 ) ||
!faceToVertexIndices.TryGetValue( face2, out var indices2 ) )
return null;
for ( int i = 0; i < indices1.Count; i++ )
{
var idx1a = indices1[i];
var idx1b = indices1[(i + 1) % indices1.Count];
var pos1a = vertexPositions[idx1a];
var pos1b = vertexPositions[idx1b];
for ( int j = 0; j < indices2.Count; j++ )
{
processedFaces.Add( currentFace );
attempts = 0;
}
else if ( attempts < maxAttempts )
{
faceQueue.Enqueue( currentFace );
var idx2a = indices2[j];
var idx2b = indices2[(j + 1) % indices2.Count];
var pos2a = vertexPositions[idx2a];
var pos2b = vertexPositions[idx2b];
const float tolerance = 0.001f;
bool matchForward = pos1a.Distance( pos2a ) < tolerance && pos1b.Distance( pos2b ) < tolerance;
bool matchReverse = pos1a.Distance( pos2b ) < tolerance && pos1b.Distance( pos2a ) < tolerance;
if ( matchForward )
{
return (idx1a, idx1b, unwrappedUVs[idx2a], unwrappedUVs[idx2b]);
}
if ( matchReverse )
{
return (idx1a, idx1b, unwrappedUVs[idx2b], unwrappedUVs[idx2a]);
}
}
}
return null;
}
private UnwrapResult BuildResult( List<Vector2> unwrappedUVs )
{
var finalFaceIndices = new List<List<int>>();
foreach ( var face in faces )
{
@@ -97,126 +300,6 @@ internal class EdgeAwareFaceUnwrapper
};
}
private void UnwrapFirstFace( MeshFace face, List<Vector2> unwrappedUVs )
{
if ( !faceToVertexIndices.TryGetValue( face, out var indices ) || indices.Count < 3 )
return;
var pos0 = vertexPositions[indices[0]];
var pos1 = vertexPositions[indices[1]];
var pos2 = vertexPositions[indices[2]];
var u = (pos1 - pos0).Normal;
var normal = u.Cross( (pos2 - pos0).Normal ).Normal;
var v = normal.Cross( u );
foreach ( var vertexIndex in indices )
{
var pos = vertexPositions[vertexIndex];
var relative = pos - pos0;
unwrappedUVs[vertexIndex] = new Vector2( relative.Dot( u ), relative.Dot( v ) );
}
}
private bool TryUnfoldFace( MeshFace currentFace, HashSet<MeshFace> processedFaces, List<Vector2> unwrappedUVs )
{
if ( !faceToVertexIndices.TryGetValue( currentFace, out var currentIndices ) )
return false;
foreach ( var processedFace in processedFaces )
{
var sharedVertices = FindSharedVertices( currentFace, processedFace, unwrappedUVs );
if ( sharedVertices.HasValue )
{
UnfoldFaceAlongEdge( currentFace, currentIndices, sharedVertices.Value, unwrappedUVs );
return true;
}
}
return false;
}
private (int idx1, int idx2, Vector2 uv1, Vector2 uv2)? FindSharedVertices( MeshFace face1, MeshFace face2, List<Vector2> unwrappedUVs )
{
if ( !faceToVertexIndices.TryGetValue( face1, out var indices1 ) ||
!faceToVertexIndices.TryGetValue( face2, out var indices2 ) )
return null;
for ( int i = 0; i < indices1.Count; i++ )
{
var idx1a = indices1[i];
var idx1b = indices1[(i + 1) % indices1.Count];
for ( int j = 0; j < indices2.Count; j++ )
{
var idx2a = indices2[j];
var idx2b = indices2[(j + 1) % indices2.Count];
var pos1a = vertexPositions[idx1a];
var pos1b = vertexPositions[idx1b];
var pos2a = vertexPositions[idx2a];
var pos2b = vertexPositions[idx2b];
const float tolerance = 0.001f;
bool matchForward = pos1a.Distance( pos2a ) < tolerance && pos1b.Distance( pos2b ) < tolerance;
bool matchReverse = pos1a.Distance( pos2b ) < tolerance && pos1b.Distance( pos2a ) < tolerance;
if ( matchForward || matchReverse )
{
return matchForward
? (idx1a, idx1b, unwrappedUVs[idx2a], unwrappedUVs[idx2b])
: (idx1a, idx1b, unwrappedUVs[idx2b], unwrappedUVs[idx2a]);
}
}
}
return null;
}
private void UnfoldFaceAlongEdge( MeshFace face, List<int> faceIndices, (int idx1, int idx2, Vector2 uv1, Vector2 uv2) sharedEdge, List<Vector2> unwrappedUVs )
{
unwrappedUVs[sharedEdge.idx1] = sharedEdge.uv1;
unwrappedUVs[sharedEdge.idx2] = sharedEdge.uv2;
var edge3DA = vertexPositions[sharedEdge.idx1];
var edge3DB = vertexPositions[sharedEdge.idx2];
var edge3D = edge3DB - edge3DA;
var edge2D = sharedEdge.uv2 - sharedEdge.uv1;
Vector3 thirdVertex = Vector3.Zero;
foreach ( var idx in faceIndices )
{
if ( idx != sharedEdge.idx1 && idx != sharedEdge.idx2 )
{
thirdVertex = vertexPositions[idx];
break;
}
}
var faceNormal = edge3D.Cross( thirdVertex - edge3DA ).Normal;
var localU = edge3D.Normal;
var localV = faceNormal.Cross( localU );
foreach ( var idx in faceIndices )
{
if ( idx == sharedEdge.idx1 || idx == sharedEdge.idx2 )
continue;
var pos3D = vertexPositions[idx];
var relative3D = pos3D - edge3DA;
var localPos = new Vector2( relative3D.Dot( localU ), relative3D.Dot( localV ) );
var edgeLength2D = edge2D.Length;
var edgeLength3D = edge3D.Length;
var scale = edgeLength3D > 0 ? edgeLength2D / edgeLength3D : 1.0f;
var edge2DDir = edge2D.Normal;
var edge2DPerp = new Vector2( -edge2DDir.y, edge2DDir.x );
unwrappedUVs[idx] = sharedEdge.uv1 + edge2DDir * localPos.x * scale + edge2DPerp * localPos.y * scale;
}
}
public class UnwrapResult
{
public List<Vector2> VertexPositions { get; set; } = new();

View File

@@ -3,11 +3,19 @@
public enum MappingMode
{
/// <summary>
/// Unwrap the selected faces and attempt to fit them into the current rectangle
/// Unwrap the selected faces and attempt to fit them into the current rectangle.
/// Forces the faces into a grid-like alignment (straightens curves).
/// </summary>
[Icon( "auto_awesome_mosaic" )]
UnwrapSquare,
/// <summary>
/// Unwrap the selected faces while maintaining their shape and angles.
/// Best for organic shapes or when distortion should be minimized.
/// </summary>
[Icon( "texture" )]
UnwrapConforming,
/// <summary>
/// Planar Project the selected faces based on the current view. Can be selected again to update from a new view.
/// </summary>
@@ -30,10 +38,39 @@ public enum AlignmentMode
VAxis
}
public enum ScaleMode
{
[Title( "Fit To Rectangle" ), Icon( "aspect_ratio" )]
Fit,
[Title( "World Scale" ), Icon( "public" )]
WorldScale,
[Title( "Tile U" ), Icon( "view_column" )]
TileU,
[Title( "Tile V" ), Icon( "view_stream" )]
TileV
}
public enum TileMode
{
[Title( "Maintain Aspect" ), Icon( "crop_free" )]
MaintainAspect,
[Title( "Repeat" ), Icon( "repeat" )]
Repeat
}
public class FastTextureSettings
{
[Hide] private MappingMode _mapping = MappingMode.UnwrapSquare;
[Hide] private AlignmentMode _alignment = AlignmentMode.UAxis;
[Hide] private ScaleMode _scaleMode = ScaleMode.Fit;
[Hide] private TileMode _tileMode = TileMode.MaintainAspect;
[Hide] private float _repeat = 1.0f;
[Hide] private bool _isTileView;
[Hide] private bool _showRects;
[Hide] private bool _isFlippedHorizontal;
@@ -41,6 +78,14 @@ public class FastTextureSettings
[Hide] private float _insetX;
[Hide] private float _insetY;
[Hide] private Vector2 _savedRectMin = Vector2.Zero;
[Hide] private Vector2 _savedRectMax = Vector2.One;
public FastTextureSettings()
{
Load();
}
[Category( "UV Mapping" ), WideMode( HasLabel = false )]
public MappingMode Mapping
{
@@ -125,7 +170,50 @@ public class FastTextureSettings
}
}
[Category( "Tiling" ), WideMode( HasLabel = false )]
public ScaleMode ScaleMode
{
get => _scaleMode;
set
{
if ( _scaleMode == value ) return;
_scaleMode = value;
OnSettingsChanged?.Invoke();
}
}
[Category( "Tiling" )]
[HideIf( nameof( ScaleMode ), ScaleMode.Fit )]
[WideMode( HasLabel = false )]
public TileMode TileMode
{
get => _tileMode;
set
{
if ( _tileMode == value ) return;
_tileMode = value;
OnSettingsChanged?.Invoke();
}
}
[Category( "Tiling" )]
[HideIf( nameof( ScaleMode ), ScaleMode.Fit )]
[WideMode]
[Range( 0.125f, 8.0f ), Step( 0.125f )]
public float Repeat
{
get => _repeat;
set
{
if ( _repeat == value ) return;
_repeat = value;
OnSettingsChanged?.Invoke();
}
}
[Category( "Inset" )]
[WideMode( HasLabel = false )]
[Range( 0.0f, 32.0f ), Step( 1.0f )]
public float InsetX
{
get => _insetX;
@@ -140,6 +228,8 @@ public class FastTextureSettings
}
[Category( "Inset" )]
[WideMode( HasLabel = false )]
[Range( 0.0f, 32.0f ), Step( 1.0f )]
public float InsetY
{
get => _insetY;
@@ -153,6 +243,32 @@ public class FastTextureSettings
}
}
[Hide]
public Vector2 SavedRectMin
{
get => _savedRectMin;
set
{
if ( _savedRectMin != value )
{
_savedRectMin = value;
}
}
}
[Hide]
public Vector2 SavedRectMax
{
get => _savedRectMax;
set
{
if ( _savedRectMax != value )
{
_savedRectMax = value;
}
}
}
[Hide]
public bool IsPickingEdge { get; set; }
@@ -161,10 +277,78 @@ public class FastTextureSettings
{
IsPickingEdge = !IsPickingEdge;
}
[Hide]
public Action OnMappingChanged { get; set; }
[Hide]
public Action OnSettingsChanged { get; set; }
private struct FastTextureSettingsDto
{
public MappingMode Mapping { get; set; }
public AlignmentMode Alignment { get; set; }
public ScaleMode ScaleMode { get; set; }
public TileMode TileMode { get; set; }
public float Repeat { get; set; }
public bool IsTileView { get; set; }
public bool ShowRects { get; set; }
public bool IsFlippedHorizontal { get; set; }
public bool IsFlippedVertical { get; set; }
public float InsetX { get; set; }
public float InsetY { get; set; }
public Vector2 SavedRectMin { get; set; }
public Vector2 SavedRectMax { get; set; }
}
public void Load()
{
var json = ProjectCookie.Get( "FastTexture.Settings", string.Empty );
if ( string.IsNullOrWhiteSpace( json ) )
return;
try
{
var dto = JsonSerializer.Deserialize<FastTextureSettingsDto>( json );
_mapping = dto.Mapping;
_alignment = dto.Alignment;
_scaleMode = dto.ScaleMode;
_tileMode = dto.TileMode;
_repeat = dto.Repeat;
_isTileView = dto.IsTileView;
_showRects = dto.ShowRects;
_isFlippedHorizontal = dto.IsFlippedHorizontal;
_isFlippedVertical = dto.IsFlippedVertical;
_insetX = dto.InsetX;
_insetY = dto.InsetY;
_savedRectMin = dto.SavedRectMin;
_savedRectMax = dto.SavedRectMax;
}
catch
{
}
}
public void Save()
{
var dto = new FastTextureSettingsDto
{
Mapping = _mapping,
Alignment = _alignment,
ScaleMode = _scaleMode,
TileMode = _tileMode,
Repeat = _repeat,
IsTileView = _isTileView,
ShowRects = _showRects,
IsFlippedHorizontal = _isFlippedHorizontal,
IsFlippedVertical = _isFlippedVertical,
InsetX = _insetX,
InsetY = _insetY,
SavedRectMin = _savedRectMin,
SavedRectMax = _savedRectMax
};
var json = JsonSerializer.Serialize( dto );
ProjectCookie.Set( "FastTexture.Settings", json );
}
}

View File

@@ -1,29 +1,41 @@
using Editor.MeshEditor;
using Sandbox;
using Sandbox.UI;
namespace Editor.RectEditor;
public class FastTextureWindow : Window
{
public MeshFace[] MeshFaces { get; private set; }
private List<Vector2[]> OriginalUVs { get; set; } = new();
private List<Material> OriginalMaterials { get; set; } = new();
public RectView _RectView { get; private set; }
public FastTextureWindow() : base()
{
Size = new Vector2( 900, 700 );
Size = new Vector2( 700, 850 );
Settings.IsFastTextureTool = true;
}
protected override void BuildDock()
{
DockManager.RegisterDockType( "Rect View", "space_dashboard", null, false );
RectView = new RectView( this );
DockManager.AddDock( null, RectView, DockArea.Right, DockManager.DockProperty.HideOnClose, 0.0f );
_RectView = new RectView( this );
_RectView.Layout = Layout.Column();
_RectView.Layout.Margin = 0;
_RectView.Layout.Spacing = 0;
DockManager.RegisterDockType( "Properties", "edit", null, false );
Properties = new Properties( this );
UpdateProperties();
DockManager.AddDock( null, Properties, DockArea.Left, DockManager.DockProperty.HideOnClose, 0.25f );
var toolbar = new RectViewToolbar( this );
_RectView.Layout.Add( toolbar );
_RectView.Layout.AddStretchCell();
DockManager.RegisterDockType( "Rect View", "space_dashboard", null, false );
DockManager.AddDock( null, _RectView, DockArea.Right, DockManager.DockProperty.HideOnClose, 0.0f );
ToolBar.Visible = false;
MenuBar.Visible = false;
RestoreDefaultDockLayout();
}
public static void OpenWith( MeshFace[] faces, Material material = null )
@@ -41,10 +53,30 @@ public class FastTextureWindow : Window
private void InitializeWithFaces( MeshFace[] faces, Material material )
{
MeshFaces = faces;
OriginalUVs.Clear();
OriginalMaterials.Clear();
foreach ( var face in faces )
{
OriginalUVs.Add( face.TextureCoordinates.ToArray() );
OriginalMaterials.Add( face.Material );
}
if ( material != null )
{
foreach ( var face in faces )
{
face.Material = material;
}
}
Settings.FastTextureSettings.Load();
InitRectanglesFromMeshFaces();
Settings.ReferenceMaterial = material?.ResourcePath;
RectView.SetMaterial( material );
_RectView?.SetMaterial( material );
}
protected override void InitRectanglesFromMeshFaces()
@@ -104,7 +136,7 @@ public class FastTextureWindow : Window
{
var material = asset?.LoadResource<Material>();
Settings.ReferenceMaterial = material?.ResourcePath;
RectView.SetMaterial( material );
_RectView?.SetMaterial( material );
if ( MeshFaces != null )
{
@@ -149,10 +181,464 @@ public class FastTextureWindow : Window
[EditorEvent.Frame]
private void OnFrame()
{
if ( MeshFaces == null || SceneEditorSession.Active == null )
return;
var selectedFaces = SceneEditorSession.Active.Selection.OfType<MeshFace>().ToArray();
if ( selectedFaces.Length != MeshFaces.Length || !selectedFaces.All( x => MeshFaces.Contains( x ) ) )
{
Close();
}
}
[Shortcut( "editor.cancel", "ESC" )]
public void Cancel()
{
if ( MeshFaces != null && OriginalUVs != null && MeshFaces.Length == OriginalUVs.Count )
{
for ( int i = 0; i < MeshFaces.Length; i++ )
{
MeshFaces[i].TextureCoordinates = OriginalUVs[i];
if ( i < OriginalMaterials.Count )
{
MeshFaces[i].Material = OriginalMaterials[i];
}
}
}
MeshFaces = null;
Close();
}
[Shortcut( "editor.confirm", "ENTER" )]
public void Confirm()
{
var meshRect = Document?.Rectangles.OfType<Document.MeshRectangle>().FirstOrDefault();
if ( meshRect != null )
{
Settings.FastTextureSettings.SavedRectMin = meshRect.Min;
Settings.FastTextureSettings.SavedRectMax = meshRect.Max;
}
Settings.FastTextureSettings.Save();
Close();
}
[Shortcut( "editor.fasttexture.resetuvs", "Shift+R" )]
public void ResetUVs()
{
_RectView?.ResetUV();
Update();
}
}
public class RectViewToolbar : Widget
{
private FastTextureSettings Settings => Window?.Settings?.FastTextureSettings;
private FastTextureWindow Window;
private ScaleMode _lastScaleMode;
private TileMode _lastTiledMode;
public RectViewToolbar( FastTextureWindow window ) : base( null )
{
Window = window;
Layout = Layout.Row();
Layout.Margin = 2;
Layout.Spacing = 2;
FixedHeight = 140;
TransparentForMouseEvents = false;
SetModal( true );
OnPaintOverride = PaintMenuBackground;
if ( Settings != null )
{
_lastScaleMode = Settings.ScaleMode;
Settings.OnSettingsChanged += OnSettingsChanged;
Settings.OnMappingChanged += OnSettingsChanged;
}
BuildUI();
}
public override void OnDestroyed()
{
base.OnDestroyed();
if ( Settings != null )
{
Settings.OnSettingsChanged -= OnSettingsChanged;
Settings.OnMappingChanged -= OnSettingsChanged;
}
}
private void OnSettingsChanged()
{
if ( Settings.ScaleMode != _lastScaleMode )
{
_lastScaleMode = Settings.ScaleMode;
BuildUI();
}
if ( Settings.TileMode != _lastTiledMode )
{
_lastTiledMode = Settings.TileMode;
BuildUI();
}
Update();
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
if ( !e.Accepted ) e.Accepted = true;
}
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
if ( !e.Accepted ) e.Accepted = true;
}
private void BuildUI()
{
Layout.Clear( true );
if ( Settings == null ) return;
// --- Mapping & Alignment ---
{
var mappingCol = Layout.AddColumn();
mappingCol.Margin = 2;
AddGroup( mappingCol, "Mapping", layout =>
{
var mappingSeg = new SegmentedControl();
mappingSeg.AddOption( "Square", "auto_awesome_mosaic" );
mappingSeg.AddOption( "Conform", "texture" );
mappingSeg.AddOption( "Planar", "video_camera_back" );
mappingSeg.AddOption( "Exist", "view_in_ar" );
mappingSeg.SelectedIndex = Settings.Mapping switch
{
MappingMode.UnwrapSquare => 0,
MappingMode.UnwrapConforming => 1,
MappingMode.Planar => 2,
MappingMode.UseExisting => 3,
_ => 0
};
mappingSeg.OnSelectedChanged = ( val ) =>
{
Settings.Mapping = val switch
{
"Square" => MappingMode.UnwrapSquare,
"Conform" => MappingMode.UnwrapConforming,
"Planar" => MappingMode.Planar,
"Exist" => MappingMode.UseExisting,
_ => Settings.Mapping
};
};
layout.Add( mappingSeg );
} );
AddGroup( mappingCol, "Alignment", layout =>
{
var alignCol = layout.AddColumn();
alignCol.Spacing = 4;
// Axis Segment
var alignSeg = new SegmentedControl();
alignSeg.AddOption( "U Axis", "keyboard_tab" );
alignSeg.AddOption( "V Axis", "vertical_align_bottom" );
alignSeg.SelectedIndex = Settings.Alignment == AlignmentMode.UAxis ? 0 : 1;
alignSeg.OnSelectedChanged = ( val ) =>
{
Settings.Alignment = val == "U Axis" ? AlignmentMode.UAxis : AlignmentMode.VAxis;
};
alignCol.Add( alignSeg );
// Toggle Buttons
var toggleRow = alignCol.AddRow();
toggleRow.Spacing = 2;
CreateModeButton( toggleRow, "swap_horiz", "Flip H", () => false, () => Settings.IsFlippedHorizontal = !Settings.IsFlippedHorizontal );
CreateModeButton( toggleRow, "swap_vert", "Flip V", () => false, () => Settings.IsFlippedVertical = !Settings.IsFlippedVertical );
CreateModeButton( toggleRow, "border_vertical", "Pick", () => Settings.IsPickingEdge, () => Settings.PickEdge() );
} );
}
// --- Tiling & Scale ---
{
var tilingCol = Layout.AddColumn();
tilingCol.Margin = 2;
AddGroup( tilingCol, "Tiling / Scale", layout =>
{
var scaleSeg = new SegmentedControl();
scaleSeg.AddOption( "Fit", "aspect_ratio" );
scaleSeg.AddOption( "World Scale", "public" );
scaleSeg.AddOption( "Tile U", "view_column" );
scaleSeg.AddOption( "Tile V", "view_stream" );
scaleSeg.SelectedIndex = Settings.ScaleMode switch
{
ScaleMode.Fit => 0,
ScaleMode.WorldScale => 1,
ScaleMode.TileU => 2,
ScaleMode.TileV => 3,
_ => 0
};
scaleSeg.OnSelectedChanged = ( val ) =>
{
Settings.ScaleMode = val switch
{
"Fit" => ScaleMode.Fit,
"World Scale" => ScaleMode.WorldScale,
"Tile U" => ScaleMode.TileU,
"Tile V" => ScaleMode.TileV,
_ => ScaleMode.Fit
};
};
layout.Add( scaleSeg );
} );
AddGroup( tilingCol, "Repeat", layout =>
{
var col = layout.AddColumn();
col.Spacing = 4;
// Repeat Mode Segment
var repeatSeg = new SegmentedControl();
repeatSeg.Enabled = Settings.ScaleMode != ScaleMode.Fit || Settings.ScaleMode != ScaleMode.WorldScale;
repeatSeg.AddOption( "Aspect", "crop_free" );
repeatSeg.AddOption( "Repeat", "repeat" );
repeatSeg.SelectedIndex = Settings.TileMode == TileMode.MaintainAspect ? 0 : 1;
repeatSeg.OnSelectedChanged = ( val ) =>
{
Settings.TileMode = val == "Aspect" ? TileMode.MaintainAspect : TileMode.Repeat;
};
bool isFit = Settings.ScaleMode == ScaleMode.Fit || Settings.ScaleMode == ScaleMode.WorldScale;
repeatSeg.Enabled = !isFit;
col.Add( repeatSeg );
// Repeat Slider
var so = Settings.GetSerialized();
var repeatWidget = new FloatControlWidget( so.GetProperty( nameof( FastTextureSettings.Repeat ) ) );
repeatWidget.Label = null;
repeatWidget.FixedHeight = Theme.RowHeight;
repeatWidget.Icon = "settings_ethernet";
repeatWidget.HighlightColor = Theme.Blue;
repeatWidget.MinimumWidth = 160;
repeatWidget.Enabled = !isFit && Settings.TileMode == TileMode.Repeat;
col.Add( repeatWidget );
} );
}
// --- Inset ---
{
var insetCol = Layout.AddColumn();
insetCol.Margin = 2;
AddGroupToLayout( insetCol, "Inset", layout =>
{
var col = layout.AddColumn();
var so = Settings.GetSerialized();
var xInset = col.Add( new FloatControlWidget( so.GetProperty( nameof( FastTextureSettings.InsetX ) ) ) );
xInset.FixedHeight = Theme.RowHeight;
xInset.HighlightColor = Theme.Red;
xInset.Label = null;
xInset.Icon = "swap_horiz";
xInset.MinimumWidth = 160;
xInset.Enabled = Settings.ScaleMode != ScaleMode.WorldScale;
var yInset = col.Add( new FloatControlWidget( so.GetProperty( nameof( FastTextureSettings.InsetY ) ) ) );
yInset.FixedHeight = Theme.RowHeight;
yInset.HighlightColor = Theme.Green;
yInset.Label = null;
yInset.Icon = "import_export";
yInset.MinimumWidth = 160;
yInset.Enabled = Settings.ScaleMode != ScaleMode.WorldScale;
} );
}
// --- View / Actions ---
{
var viewCol = Layout.AddColumn();
viewCol.Margin = 2;
AddGroup( viewCol, "View Options", layout =>
{
CreateModeButton( layout, "grid_4x4", "Toggle Tile View", () => Settings.IsTileView, () => Settings.IsTileView = !Settings.IsTileView );
CreateModeButton( layout, "rectangle", "Show Atlas Rects", () => Settings.ShowRects, () => Settings.ShowRects = !Settings.ShowRects );
} );
AddGroup( viewCol, "Actions", layout =>
{
CreateModeButton( layout, "undo", "Reset UVs (Shift+R)", () => false, () => Window.ResetUVs() );
CreateModeButton( layout, "fit_screen", "Focus (F)", () => false, () => Window._RectView?.FocusOnUV() );
} );
}
Layout.AddStretchCell();
}
bool PaintMenuBackground()
{
Paint.SetBrushAndPen( Theme.TabBackground );
Paint.DrawRect( Paint.LocalRect, 0 );
return true;
}
private void AddGroup( Layout parent, string title, Action<Layout> build )
{
AddGroupToLayout( parent, title, build );
}
private void AddGroupToLayout( Layout parentLayout, string title, Action<Layout> build )
{
var groupContainer = new Widget( this );
groupContainer.Layout = Layout.Column();
groupContainer.MaximumHeight = 164;
groupContainer.MaximumWidth = 268;
var group = new Widget( groupContainer );
group.Layout = Layout.Row();
group.Layout.Spacing = 4;
group.Layout.Margin = new Margin( 12, 16, 12, 12 );
group.OnPaintOverride += () =>
{
var controlRect = Paint.LocalRect;
controlRect.Top += 6;
controlRect = controlRect.Shrink( 0, 0, 1, 1 );
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
Paint.SetBrushAndPen( Theme.Text.WithAlpha( 0.01f ), Theme.Text.WithAlpha( 0.1f ) );
Paint.DrawRect( controlRect, 4 );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.6f ) );
Paint.SetDefaultFont( 7, 500 );
Paint.DrawText( new Vector2( 12, 0 ), title );
return true;
};
build( group.Layout );
groupContainer.Layout.Add( group );
parentLayout.Add( groupContainer );
}
private Widget CreateModeButton( Layout layout, string Icon, string text, Func<bool> isActive, Action onClick )
{
var btn = new ToolbarIcon( Icon, text, isActive, onClick );
layout.Add( btn );
return btn; // Return the button
}
}
public class ToolbarIcon : Widget
{
private string _icon;
private Func<bool> _isActive;
private Action _onClick;
// Track if we are holding the mouse down
private bool _isPressed;
public ToolbarIcon( string icon, string tooltip, Func<bool> isActive, Action onClick ) : base( null )
{
_icon = icon;
_isActive = isActive;
_onClick = onClick;
ToolTip = tooltip;
FixedSize = 24;
Cursor = CursorShape.Finger;
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
if ( e.LeftMouseButton )
{
_isPressed = true;
_onClick?.Invoke();
Update();
}
}
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
if ( _isPressed )
{
_isPressed = false;
Update();
}
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
if ( _isPressed )
{
_isPressed = false;
Update();
}
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
// 1. Is it toggled on? (Like the "Tile View" button)
bool isToggled = _isActive != null && _isActive();
// 2. Is the user holding it down right now?
bool isClicking = _isPressed && IsUnderMouse;
// 3. Show Blue if EITHER is true
bool showBlue = (isToggled || isClicking) && Enabled;
if ( showBlue )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Blue );
Paint.DrawRect( LocalRect.Shrink( 2 ), 4 );
Paint.SetPen( Theme.TextButton );
}
else if ( !Enabled )
{
Paint.ClearPen();
Paint.SetBrush( Color.White.WithAlpha( 0.02f ) );
Paint.DrawRect( LocalRect.Shrink( 2 ), 4 );
Paint.SetPen( Theme.TextLight.WithAlpha( 0.4f ) );
Cursor = CursorShape.Arrow;
}
else if ( IsUnderMouse )
{
Paint.ClearPen();
Paint.SetBrush( Color.White.WithAlpha( 0.05f ) );
Paint.DrawRect( LocalRect.Shrink( 2 ), 4 );
Paint.SetPen( Theme.TextLight );
}
else
{
Paint.ClearPen();
Paint.SetPen( Theme.TextLight.WithAlpha( 0.8f ) );
}
Paint.DrawIcon( LocalRect, _icon, 16, TextFlag.Center );
}
}

View File

@@ -38,6 +38,9 @@ public partial class Document
[Hide, JsonIgnore]
public (int vertexA, int vertexB) HoveredEdge { get; set; } = (-1, -1);
[Hide, JsonIgnore]
public List<Vector2> UnwrappedVertexPositionsWorldSpace { get; set; } = new();
public MeshRectangle( Window window ) : base( window )
{
}
@@ -46,6 +49,13 @@ public partial class Document
{
MeshFaces = meshFaces;
StoreOriginalUVs();
var settings = Session?.Settings?.FastTextureSettings;
if ( settings != null && settings.SavedRectMin != settings.SavedRectMax )
{
Min = settings.SavedRectMin;
Max = settings.SavedRectMax;
}
}
public override void OnPaint( RectView view )
@@ -132,6 +142,16 @@ public partial class Document
Max = max;
}
private void SaveBoundsToSettings()
{
var settings = Session?.Settings?.FastTextureSettings;
if ( settings != null )
{
settings.SavedRectMin = Min;
settings.SavedRectMax = Max;
}
}
public void ApplyMapping( FastTextureSettings settings, bool resetBoundsFromUseExisting = false )
{
if ( MeshFaces == null || MeshFaces.Length == 0 )
@@ -150,7 +170,8 @@ public partial class Document
switch ( currentMapping )
{
case MappingMode.UnwrapSquare:
BuildUnwrappedMeshWithSquareMapping();
case MappingMode.UnwrapConforming:
BuildUnwrappedMesh( currentMapping );
break;
case MappingMode.Planar:
var cameraRot = SceneViewWidget.Current.LastSelectedViewportWidget.State.CameraRotation;
@@ -179,6 +200,8 @@ public partial class Document
Max = previousBounds.Max;
}
SaveBoundsToSettings();
PreviousMappingMode = currentMapping;
}
@@ -256,7 +279,7 @@ public partial class Document
return (min, max);
}
private void BuildUnwrappedMeshWithSquareMapping()
private void BuildUnwrappedMesh( MappingMode mode )
{
if ( MeshFaces == null || MeshFaces.Length == 0 )
return;
@@ -266,7 +289,7 @@ public partial class Document
OriginalVertexPositions.Clear();
var unwrapper = new EdgeAwareFaceUnwrapper( MeshFaces );
var result = unwrapper.UnwrapToSquare();
var result = unwrapper.Unwrap( mode );
UnwrappedVertexPositions.AddRange( result.VertexPositions );
FaceVertexIndices.AddRange( result.FaceIndices );
@@ -429,6 +452,8 @@ public partial class Document
if ( UnwrappedVertexPositions.Count == 0 )
return;
UnwrappedVertexPositionsWorldSpace = new List<Vector2>( UnwrappedVertexPositions );
var min = UnwrappedVertexPositions[0];
var max = UnwrappedVertexPositions[0];
@@ -486,6 +511,50 @@ public partial class Document
return lines;
}
/// <summary>
/// Gets the material's world space mapping dimensions.
/// </summary>
private (float width, float height) GetMaterialWorldScale()
{
var material = Material.Load( Session?.Settings.ReferenceMaterial );
if ( material == null )
return (512.0f, 512.0f);
var worldMappingWidth = material.Attributes.GetInt( "WorldMappingWidth", 0 );
var worldMappingHeight = material.Attributes.GetInt( "WorldMappingHeight", 0 );
var texture = material.FirstTexture;
if ( texture == null )
{
if ( worldMappingWidth > 0 && worldMappingHeight > 0 )
return (worldMappingWidth, worldMappingHeight);
return (512.0f, 512.0f);
}
var textureWidth = texture.Size.x;
var textureHeight = texture.Size.y;
float mappingWidth;
float mappingHeight;
if ( worldMappingWidth > 0 )
mappingWidth = worldMappingWidth;
else
mappingWidth = 0.25f * textureWidth;
if ( worldMappingHeight > 0 )
mappingHeight = worldMappingHeight;
else
mappingHeight = 0.25f * textureHeight;
if ( mappingWidth <= 0 )
mappingWidth = 512.0f;
if ( mappingHeight <= 0 )
mappingHeight = 512.0f;
return (mappingWidth, mappingHeight);
}
/// <summary>
/// Transforms unwrapped vertex positions so they are relative to the current rectangle bounds
/// </summary>
@@ -507,6 +576,66 @@ public partial class Document
settings.InsetY / imageSize.y
);
float tileU = 1.0f;
float tileV = 1.0f;
if ( settings.ScaleMode == ScaleMode.WorldScale )
{
var worldSpacePositions = UnwrappedVertexPositionsWorldSpace.Count > 0
? UnwrappedVertexPositionsWorldSpace
: UnwrappedVertexPositions;
var minUV = worldSpacePositions[0];
foreach ( var pos in worldSpacePositions )
{
minUV = Vector2.Min( minUV, pos );
}
var (mappingWidth, mappingHeight) = GetMaterialWorldScale();
foreach ( var pos in worldSpacePositions )
{
var scaledX = ((pos.x - minUV.x) / mappingWidth) + Min.x;
var scaledY = ((pos.y - minUV.y) / mappingHeight) + Min.y;
transformedPositions.Add( new Vector2( scaledX, scaledY ) );
}
return transformedPositions;
}
if ( settings.ScaleMode != ScaleMode.Fit )
{
if ( settings.TileMode == TileMode.Repeat )
{
if ( settings.ScaleMode == ScaleMode.TileU )
tileU = settings.Repeat;
else if ( settings.ScaleMode == ScaleMode.TileV )
tileV = settings.Repeat;
}
else if ( settings.TileMode == TileMode.MaintainAspect )
{
var meshWidth = MathF.Max( unwrappedSize.x, 0.001f );
var meshHeight = MathF.Max( unwrappedSize.y, 0.001f );
var meshAspect = meshWidth / meshHeight;
var rectPixelWidth = MathF.Max( rectSize.x * imageSize.x, 0.001f );
var rectPixelHeight = MathF.Max( rectSize.y * imageSize.y, 0.001f );
var rectAspect = rectPixelWidth / rectPixelHeight;
if ( settings.ScaleMode == ScaleMode.TileU )
{
tileU = meshAspect / rectAspect;
tileV = 1.0f;
}
else if ( settings.ScaleMode == ScaleMode.TileV )
{
tileU = 1.0f;
tileV = rectAspect / meshAspect;
}
}
}
foreach ( var pos in UnwrappedVertexPositions )
{
Vector2 relativePos;
@@ -515,6 +644,9 @@ public partial class Document
{
var normalized = (pos - unwrappedMin) / unwrappedSize;
normalized.x *= tileU;
normalized.y *= tileV;
var insetMin = Min + insetUV;
var insetSize = rectSize - insetUV * 2;
relativePos = insetMin + normalized * insetSize;

View File

@@ -52,6 +52,65 @@ public class RectView : Widget
ResetView();
}
protected override void OnKeyPress( KeyEvent e )
{
base.OnKeyPress( e );
if ( e.Accepted ) return;
var direction = Vector2.Zero;
if ( e.Key == KeyCode.Left ) direction = new Vector2( -1, 0 );
if ( e.Key == KeyCode.Right ) direction = new Vector2( 1, 0 );
if ( e.Key == KeyCode.Up ) direction = new Vector2( 0, -1 );
if ( e.Key == KeyCode.Down ) direction = new Vector2( 0, 1 );
if ( direction != Vector2.Zero )
{
Nudge( direction );
e.Accepted = true;
}
}
private void Nudge( Vector2 direction )
{
var gridCountX = GetGridCountX();
var gridCountY = GetGridCountY();
var step = new Vector2( 1.0f / gridCountX, 1.0f / gridCountY );
var delta = direction * step;
if ( Session.Settings.IsFastTextureTool )
{
var meshRect = Document.Rectangles.OfType<Document.MeshRectangle>().FirstOrDefault();
if ( meshRect != null )
{
Session.ExecuteUndoableAction( "Nudge UVs", () =>
{
meshRect.Min += delta;
meshRect.Max += delta;
meshRect.ApplyMapping( Session.Settings.FastTextureSettings, false );
Document.Modified = true;
Document.OnModified?.Invoke();
} );
Update();
}
}
else if ( Document.SelectedRectangles.Count > 0 )
{
Session.ExecuteUndoableAction( "Nudge Rectangles", () =>
{
foreach ( var rect in Document.SelectedRectangles )
{
rect.Min += delta;
rect.Max += delta;
}
Document.Modified = true;
Document.OnModified?.Invoke();
} );
Update();
}
}
private Vector2 SnapUVToGrid( Vector2 uv )
{
var gridCountX = GetGridCountX();
@@ -65,7 +124,7 @@ public class RectView : Widget
private Vector2 PixelToUV_OnGrid( Vector2 vPixel )
{
if ( Session.GridEnabled ) return SnapUVToGrid( PixelToUV( vPixel ) );
if ( !Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Ctrl ) ) return SnapUVToGrid( PixelToUV( vPixel ) );
return PixelToUV( vPixel );
}
@@ -79,6 +138,22 @@ public class RectView : Widget
NewRect = new Rect( min, max - min );
if ( Session.Settings.IsFastTextureTool )
{
var meshRect = Document.Rectangles.OfType<Document.MeshRectangle>().FirstOrDefault();
if ( meshRect != null )
{
meshRect.Min = NewRect.TopLeft;
meshRect.Max = NewRect.BottomRight;
// Re-apply mapping so the UVs deform to the new shape immediately
meshRect.ApplyMapping( Session.Settings.FastTextureSettings, false );
Document.Modified = true;
Document.OnModified?.Invoke();
}
}
Update();
}
@@ -339,6 +414,9 @@ public class RectView : Widget
{
var m = new ContextMenu( this );
if ( Session.Settings.IsFastTextureTool )
return;
m.AddOption( "Delete Rectangle", "delete", () => Session.ExecuteUndoableAction( "Delete Rectangle", () => Document.DeleteRectangles( [rectangle] ) ) );
m.OpenAtCursor();
@@ -605,7 +683,7 @@ public class RectView : Widget
}
else
{
Cursor = CursorShape.Arrow;
Cursor = CursorShape.Cross;
}
}
else
@@ -670,7 +748,7 @@ public class RectView : Widget
foreach ( var rectangle in Document.SelectedRectangles )
{
Paint.SetBrush( Color.Yellow.WithAlpha( 0.2f ) );
Paint.SetBrush( Color.White.WithAlpha( 0.1f ) );
Paint.SetPen( new Color32( 255, 255, 0 ), 3 );
DrawRectangle( rectangle, corner: (rectangle == rectangleUnderCursor && Document.SelectedRectangles.Count < 2) ? HoveredCorner : 0 );
}
@@ -846,7 +924,11 @@ public class RectView : Widget
return;
if ( UvAssetRects.Count == 0 )
{
ResetUV();
return;
}
var index = GetAssetRectIndexAtUV( uv );
if ( index < 0 )
@@ -873,6 +955,36 @@ public class RectView : Widget
} );
}
public void ResetUV()
{
Session.ExecuteUndoableAction( "Apply UV From Asset Rect", () =>
{
var meshRect = Document.Rectangles
.OfType<Document.MeshRectangle>()
.FirstOrDefault();
if ( meshRect == null )
return;
meshRect.Min = Vector2.Zero;
meshRect.Max = Vector2.One;
Document.Modified = true;
Document.OnModified?.Invoke();
} );
}
public void FocusOnUV()
{
//Focus the view on the selected rectangle UVs
var selectedRect = Document.SelectedRectangles.FirstOrDefault();
if ( selectedRect is null )
return;
var rectCenter = selectedRect.Max + selectedRect.Min;
rectCenter *= 0.5f;
PanOffset = rectCenter - (0.5f / ZoomLevel);
UpdateViewRect();
Update();
}
private Rect GetDrawRect()
{
const int marigin = 16;

View File

@@ -311,7 +311,7 @@ public abstract class SelectionTool<T>( MeshTool tool ) : SelectionTool where T
private void UpdateNudge()
{
if ( Gizmo.Pressed.Any || !Application.FocusWidget.IsValid() )
if ( Gizmo.Pressed.Any || !Application.FocusWidget.IsValid() || !Gizmo.HasMouseFocus )
return;
var keyUp = Application.IsKeyDown( KeyCode.Up );