Editor/SeamlessImageToolWindow.cs
using System;
using System.Collections.Generic;
using System.IO;
using Editor;
using Sandbox;
using Button = Editor.Button;
using Checkbox = Editor.Checkbox;
using ComboBox = Editor.ComboBox;
using Label = Editor.Label;
using LineEdit = Editor.LineEdit;
using TextEdit = Editor.TextEdit;
using Widget = Editor.Widget;
[Dock( "Editor", "Seam-Less™", "texture" )]
public class SeamlessImageToolWindow : Widget
{
public static SeamlessImageToolWindow Current { get; private set; }
private readonly SeamlessToolSettings settings = new();
private readonly List<string> supportedExtensions = new()
{
".png",
".jpg",
".jpeg",
".webp",
".bmp",
".tga",
".tif",
".tiff",
".psd",
".svg"
};
private SeamlessPreviewWidget previewWidget;
private Splitter mainSplitter;
private Splitter contentSplitter;
private TextEdit consoleOutput;
private Label currentFileLabel;
private Label imageInfoLabel;
private LineEdit outputFolderEdit;
private LineEdit suffixEdit;
private LineEdit seedEdit;
private Widget tileCountRow;
private Widget seedRow;
private Widget randomizeRow;
private Widget variationPresetRow;
private Widget patchShapeRow;
private Widget variationStrengthRow;
private Widget seamGravityRow;
private Widget patchCountRow;
private Widget patchSizeRow;
private Widget maskSoftnessRow;
private Widget bombDensityRow;
private Widget brickTilePatternRow;
private Widget brickTileCellWidthRow;
private Widget brickTileCellHeightRow;
private Widget brickTileGroutWidthRow;
private Widget brickTileRowOffsetRow;
private Widget brickTileStructureStrengthRow;
private string sourceImagePath;
private string lastExportPath;
private Bitmap originalBitmap;
private Bitmap processedBitmap;
private bool outputFolderFollowsSource = true;
private bool processQueued;
private DateTime queuedProcessTime;
private const int InspectorLabelWidth = 132;
private const string MainSplitterCookie = "SeamLessImageCreator.MainSplitter";
private const string ContentSplitterCookie = "SeamLessImageCreator.ContentSplitter";
public SeamlessImageToolWindow( Widget parent ) : base( parent )
{
Current = this;
Name = "Seam-Less™";
WindowTitle = "Seam-Less™";
MinimumSize = new Vector2( 1040, 680 );
AcceptDrops = true;
SetWindowIcon( "texture" );
BuildLayout();
LogMessage( "Ready. Open an image or drag one into the preview." );
ShowStatus( "Ready", 4f );
}
public override void OnDestroyed()
{
SaveSplitterState();
DisposeBitmaps();
if ( Current == this )
Current = null;
base.OnDestroyed();
}
public override void OnDragHover( Widget.DragEvent ev )
{
var files = GetFilesFromDragData( ev.Data );
ev.Action = FindFirstSupportedFile( files ) != null ? DropAction.Copy : DropAction.Ignore;
}
public override void OnDragDrop( Widget.DragEvent ev )
{
var files = GetFilesFromDragData( ev.Data );
if ( files.Count == 0 )
{
ev.Action = DropAction.Ignore;
return;
}
ev.Action = DropAction.Copy;
LoadDroppedFiles( files.ToArray() );
}
[EditorEvent.Frame]
public void Frame()
{
if ( !processQueued || DateTime.Now < queuedProcessTime )
return;
processQueued = false;
ProcessImage( false );
}
private void BuildLayout()
{
Layout = Layout.Column();
Layout.Margin = 8;
Layout.Spacing = 8;
Layout.Add( BuildToolbar() );
mainSplitter = new Splitter( this );
mainSplitter.IsVertical = true;
mainSplitter.OpaqueResize = true;
mainSplitter.HandleWidth = 5;
Layout.Add( mainSplitter, 1 );
contentSplitter = new Splitter( mainSplitter );
contentSplitter.IsHorizontal = true;
contentSplitter.OpaqueResize = true;
contentSplitter.HandleWidth = 5;
previewWidget = new SeamlessPreviewWidget( contentSplitter );
previewWidget.FilesDropped = LoadDroppedFiles;
var inspectorScroll = new ScrollArea( contentSplitter );
inspectorScroll.MinimumWidth = 360;
inspectorScroll.Canvas = BuildInspector( inspectorScroll );
inspectorScroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;
inspectorScroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;
contentSplitter.AddWidget( previewWidget );
contentSplitter.AddWidget( inspectorScroll );
contentSplitter.SetStretch( 0, 5 );
contentSplitter.SetStretch( 1, 2 );
contentSplitter.SetCollapsible( 0, false );
contentSplitter.SetCollapsible( 1, false );
var console = BuildConsole( mainSplitter );
mainSplitter.AddWidget( contentSplitter );
mainSplitter.AddWidget( console );
mainSplitter.SetStretch( 0, 5 );
mainSplitter.SetStretch( 1, 1 );
mainSplitter.SetCollapsible( 0, false );
mainSplitter.SetCollapsible( 1, false );
RestoreSplitterState();
}
private ToolBar BuildToolbar()
{
var toolbar = new ToolBar( this, "SeamLessImageCreatorToolbar" );
toolbar.Movable = false;
toolbar.Floatable = false;
toolbar.SetIconSize( new Vector2( 18, 18 ) );
toolbar.AddOption( "Open", "folder_open", OpenImageDialog );
toolbar.AddOption( "Process", "auto_fix_high", () => ProcessImage( true ) );
toolbar.AddOption( "Export", "save", ExportImage );
toolbar.AddOption( "Reveal Output", "folder", RevealOutput );
toolbar.AddSeparator();
toolbar.AddOption( "Clear Image and Console", "delete", ClearLog );
return toolbar;
}
private Widget BuildInspector( Widget parent )
{
var inspector = new Widget( parent );
inspector.MinimumWidth = 340;
inspector.Layout = Layout.Column();
inspector.Layout.Margin = 10;
inspector.Layout.Spacing = 3;
inspector.SetStyles( "background-color: #303030; border-left: 1px solid #202020;" );
AddSectionLabel( inspector, "Image" );
currentFileLabel = AddReadOnlyValueRow( inspector, "File", "No image loaded", "The image currently loaded into the tool." );
imageInfoLabel = AddReadOnlyValueRow( inspector, "Info", "Drop an image into the preview, or use Open.", "Shows the loaded image size and file type." );
AddSectionLabel( inspector, "Preview" );
AddPreviewModeControl( inspector );
tileCountRow = AddSliderControl( inspector, "Tile Count", 2f, 8f, 1f, settings.PreviewTileCount, value =>
{
settings.PreviewTileCount = Math.Clamp( (int)MathF.Round( value ), 2, 8 );
UpdatePreviewSettings();
}, "Changes how many repeats are shown in tiled preview. This does not change export size." );
AddPreviewButtonRow( inspector );
UpdateTileCountVisibility();
AddSectionLabel( inspector, "Seam Processing" );
AddProcessingModeControl( inspector );
AddSeamShapeControl( inspector );
seedRow = AddSeedRow( inspector, "Changes the random pattern used for patches, noise, and texture bombing." );
randomizeRow = AddRandomizeRow( inspector, "Creates a new random pattern for the current stochastic or noise settings." );
variationPresetRow = AddVariationPresetControl( inspector );
patchShapeRow = AddPatchShapeControl( inspector );
variationStrengthRow = AddSliderControl( inspector, "Variation Strength", 0f, 1f, 0.01f, settings.VariationStrength, value =>
{
settings.VariationStrength = value;
QueueAutoProcess();
}, "Blends between the safer base result and the randomized texture result." );
seamGravityRow = AddSliderControl( inspector, "Seam Gravity", 0f, 1f, 0.01f, settings.SeamGravity, value =>
{
settings.SeamGravity = value;
QueueAutoProcess();
}, "Pulls randomized patches and texture bombs toward the center seam cross. 0 keeps the normal spread." );
patchCountRow = AddSliderControl( inspector, "Patch Count", 2f, 512f, 1f, settings.PatchCount, value =>
{
settings.PatchCount = Math.Clamp( (int)MathF.Round( value ), 2, 512 );
QueueAutoProcess();
}, "Changes how many patch regions are used for irregular patch and bombing modes." );
patchSizeRow = AddSliderControl( inspector, "Patch Size", 8f, 512f, 1f, settings.PatchSize, value =>
{
settings.PatchSize = value;
QueueAutoProcess();
}, "Changes the average size of each randomized texture patch." );
maskSoftnessRow = AddSliderControl( inspector, "Mask Softness", 0.05f, 1f, 0.01f, settings.MaskSoftness, value =>
{
settings.MaskSoftness = value;
QueueAutoProcess();
}, "Controls how softly patch edges blend into the texture." );
bombDensityRow = AddSliderControl( inspector, "Bomb Density", 0.1f, 4f, 0.1f, settings.TextureBombDensity, value =>
{
settings.TextureBombDensity = value;
QueueAutoProcess();
}, "Adds more or fewer texture-bomb stamps across the image." );
brickTilePatternRow = AddBrickTilePatternControl( inspector );
brickTileCellWidthRow = AddSliderControl( inspector, "Cell Width", 4f, 4096f, 1f, settings.BrickTileCellWidth, value =>
{
settings.BrickTileCellWidth = value;
QueueAutoProcess();
}, "Matches the width of one tile, brick, plank, or panel repeat." );
brickTileCellHeightRow = AddSliderControl( inspector, "Cell Height", 4f, 4096f, 1f, settings.BrickTileCellHeight, value =>
{
settings.BrickTileCellHeight = value;
QueueAutoProcess();
}, "Matches the height of one tile, brick row, plank row, or panel repeat." );
brickTileGroutWidthRow = AddSliderControl( inspector, "Grout Width", 0f, 256f, 1f, settings.BrickTileGroutWidth, value =>
{
settings.BrickTileGroutWidth = value;
QueueAutoProcess();
}, "Controls how wide the structured grout or edge lines are." );
brickTileRowOffsetRow = AddSliderControl( inspector, "Row Offset", 0f, 1f, 0.01f, settings.BrickTileRowOffset, value =>
{
settings.BrickTileRowOffset = value;
QueueAutoProcess();
}, "Offsets alternating rows for running brick, plank, and panel layouts." );
brickTileStructureStrengthRow = AddSliderControl( inspector, "Grid Strength", 0f, 1f, 0.01f, settings.BrickTileStructureStrength, value =>
{
settings.BrickTileStructureStrength = value;
QueueAutoProcess();
}, "Makes seam blending respect tile, brick, grout, and plank edges more strongly." );
AddSliderControl( inspector, "Seam Width", 1f, 256f, 1f, settings.SeamWidth, value =>
{
settings.SeamWidth = value;
QueueAutoProcess();
}, "Controls how wide the seam-hiding blend area is." );
AddSliderControl( inspector, "Blend Strength", 0f, 1f, 0.01f, settings.BlendStrength, value =>
{
settings.BlendStrength = value;
QueueAutoProcess();
}, "Controls how strongly the seam area blends with its mirrored side." );
AddSliderControl( inspector, "Blur", 0f, 16f, 0.1f, settings.BlurRadius, value =>
{
settings.BlurRadius = value;
QueueAutoProcess();
}, "Softens the processed texture after the seamless pass." );
AddSliderControl( inspector, "Sharpen", 0f, 2f, 0.05f, settings.SharpenAmount, value =>
{
settings.SharpenAmount = value;
QueueAutoProcess();
}, "Adds crispness back after processing or blur." );
AddCheckboxRow( inspector, "Auto Process", settings.AutoProcess, state =>
{
settings.AutoProcess = state == CheckState.On;
if ( settings.AutoProcess )
QueueAutoProcess();
}, "Updates the preview automatically when settings change." );
UpdateProcessingControlVisibility();
AddSectionLabel( inspector, "Export" );
AddExportFormatControl( inspector );
AddSliderControl( inspector, "Quality", 1f, 100f, 1f, settings.ExportQuality, value =>
{
settings.ExportQuality = Math.Clamp( (int)MathF.Round( value ), 1, 100 );
}, "Controls JPG and WebP compression quality. PNG and BMP ignore this." );
suffixEdit = AddTextRow( inspector, "Suffix", settings.OutputSuffix, "Text added to the exported file name." );
suffixEdit.TextEdited += text =>
{
settings.OutputSuffix = text;
};
AddOutputFolderControl( inspector, "Folder where exported seamless textures will be written." );
AddCheckboxRow( inspector, "Overwrite Existing", settings.OverwriteExisting, state =>
{
settings.OverwriteExisting = state == CheckState.On;
}, "Allows export to replace an existing file with the same name." );
AddCheckboxRow( inspector, "Auto Create .VMAT?", settings.ExportVMAT, state =>
{
settings.ExportVMAT = state == CheckState.On;
}, "Auto creates a .vmat using the exported texture." );
AddLargeExportButton( inspector );
inspector.Layout.AddStretchCell();
return inspector;
}
private Widget BuildConsole( Widget parent )
{
var pane = new Widget( parent );
pane.Name = "Console";
pane.WindowTitle = "Console";
pane.SetWindowIcon( "terminal" );
pane.MinimumSize = new Vector2( 520, 140 );
pane.Layout = Layout.Column();
pane.Layout.Margin = 8;
pane.Layout.Spacing = 6;
pane.SetStyles( "background-color: #101215; border: 1px solid #2a3038; border-radius: 6px;" );
var title = pane.Layout.Add( new Label( "Console", pane ) );
title.SetStyles( "font-weight: bold; color: #d9dee7;" );
consoleOutput = pane.Layout.Add( new TextEdit( pane ), 1 );
consoleOutput.ReadOnly = true;
consoleOutput.TextSelectable = true;
consoleOutput.BackgroundVisible = false;
consoleOutput.MaximumBlockCount = 200;
consoleOutput.SetStyles( "font-family: monospace; font-size: 11px; color: #b8c0cc;" );
return pane;
}
private void AddSectionLabel( Widget inspector, string text )
{
var row = new Widget( inspector );
row.MinimumHeight = 24;
row.Layout = Layout.Row();
row.Layout.Spacing = 6;
row.SetStyles( "background-color: #2b2b2b; border-top: 1px solid #202020; border-bottom: 1px solid #1f1f1f; margin-top: 6px;" );
var marker = row.Layout.Add( new Label( "-", row ) );
marker.FixedWidth = 20;
marker.SetStyles( "color: #8b8b8b; font-weight: bold; font-size: 12px;" );
var label = row.Layout.Add( new Label( text, row ), 1 );
label.SetStyles( "font-size: 11px; font-weight: bold; color: #ffffff; padding-left: 2px;" );
inspector.Layout.Add( row );
}
private void AddPreviewModeControl( Widget inspector )
{
var combo = AddComboRow( inspector, "View Mode", "Changes what the preview shows. This does not change the exported image." );
combo.AddItem( "Original" );
combo.AddItem( "Processed" );
combo.AddItem( "Tiled" );
combo.CurrentIndex = (int)settings.PreviewMode;
combo.ItemChanged += () =>
{
settings.PreviewMode = (SeamlessPreviewMode)Math.Clamp( combo.CurrentIndex, 0, 2 );
UpdatePreviewSettings();
};
}
private void AddPreviewButtonRow( Widget inspector )
{
var row = new Widget( inspector );
row.Layout = Layout.Row();
row.Layout.Spacing = 10;
row.MinimumHeight = 30;
row.SetStyles( "padding-top: 4px; padding-bottom: 2px;" );
var spacer = row.Layout.Add( new Label( "", row ) );
spacer.FixedWidth = InspectorLabelWidth;
var fitButton = row.Layout.Add( new Button( "Fit", "fit_screen", row ) );
fitButton.MinimumWidth = 86;
fitButton.Clicked = () => previewWidget.FitToView();
StyleInspectorButton( fitButton );
SetInspectorToolTip( "Fit the current preview into the preview panel.", fitButton );
var oneToOneButton = row.Layout.Add( new Button( "1:1", "center_focus_strong", row ) );
oneToOneButton.MinimumWidth = 86;
oneToOneButton.Clicked = () => previewWidget.SetOneToOne();
StyleInspectorButton( oneToOneButton );
SetInspectorToolTip( "Show the current preview at original pixel size.", oneToOneButton );
row.Layout.AddStretchCell();
inspector.Layout.Add( row );
}
private Widget AddProcessingModeControl( Widget inspector )
{
var combo = AddComboRow( inspector, "Processing Mode", "Chooses the method used to make the texture seamless." );
combo.AddItem( "Offset Blend" );
combo.AddItem( "Irregular Patch" );
combo.AddItem( "Texture Bombing" );
combo.AddItem( "Brick, Tile & Planks" );
combo.CurrentIndex = (int)settings.ProcessingMode;
combo.ItemChanged += () =>
{
settings.ProcessingMode = (SeamlessProcessingMode)Math.Clamp( combo.CurrentIndex, 0, 3 );
UpdateProcessingControlVisibility();
QueueAutoProcess();
};
return combo.Parent;
}
private Widget AddSeamShapeControl( Widget inspector )
{
var combo = AddComboRow( inspector, "Seam Shape", "Changes the shape of the center blend used to hide seams." );
combo.AddItem( "Smooth" );
combo.AddItem( "Linear" );
combo.AddItem( "Wavy" );
combo.AddItem( "Noise" );
combo.AddItem( "Irregular" );
combo.CurrentIndex = (int)settings.SeamShape;
combo.ItemChanged += () =>
{
settings.SeamShape = (SeamlessSeamShape)Math.Clamp( combo.CurrentIndex, 0, 4 );
UpdateProcessingControlVisibility();
QueueAutoProcess();
};
return combo.Parent;
}
private Widget AddVariationPresetControl( Widget inspector )
{
var combo = AddComboRow( inspector, "Variation", "Controls how subtle or strong the random texture variation feels." );
combo.AddItem( "Gentle" );
combo.AddItem( "Balanced" );
combo.AddItem( "Strong" );
combo.CurrentIndex = (int)settings.VariationPreset;
combo.ItemChanged += () =>
{
settings.VariationPreset = (SeamlessVariationPreset)Math.Clamp( combo.CurrentIndex, 0, 2 );
QueueAutoProcess();
};
return combo.Parent;
}
private Widget AddPatchShapeControl( Widget inspector )
{
var combo = AddComboRow( inspector, "Shape", "Changes the mask shape used by irregular patches and texture bombs." );
combo.AddItem( "Circle" );
combo.AddItem( "Square" );
combo.AddItem( "Diamond" );
combo.AddItem( "Hexagon" );
combo.CurrentIndex = (int)settings.PatchShape;
combo.ItemChanged += () =>
{
settings.PatchShape = (SeamlessPatchShape)Math.Clamp( combo.CurrentIndex, 0, 3 );
QueueAutoProcess();
};
return combo.Parent;
}
private Widget AddBrickTilePatternControl( Widget inspector )
{
var combo = AddComboRow( inspector, "Brick & Tile Type", "Chooses the structured layout used for non-organic textures." );
combo.AddItem( "Square Tile" );
combo.AddItem( "Running Brick" );
combo.AddItem( "Stacked Brick" );
combo.AddItem( "Planks" );
combo.AddItem( "Panels" );
combo.CurrentIndex = (int)settings.BrickTilePattern;
combo.ItemChanged += () =>
{
settings.BrickTilePattern = (SeamlessBrickTilePattern)Math.Clamp( combo.CurrentIndex, 0, 4 );
UpdateProcessingControlVisibility();
QueueAutoProcess();
};
return combo.Parent;
}
private void AddExportFormatControl( Widget inspector )
{
var combo = AddComboRow( inspector, "Format", "Chooses the image format used when exporting." );
combo.AddItem( "PNG" );
combo.AddItem( "JPG" );
combo.AddItem( "WebP" );
combo.AddItem( "BMP" );
combo.CurrentIndex = (int)settings.ExportFormat;
combo.ItemChanged += () =>
{
settings.ExportFormat = (SeamlessExportFormat)Math.Clamp( combo.CurrentIndex, 0, 3 );
};
}
private ComboBox AddComboRow( Widget inspector, string labelText, string description = null )
{
var row = new Widget( inspector );
row.Layout = Layout.Row();
row.Layout.Spacing = 6;
row.MinimumHeight = 24;
var label = row.Layout.Add( new Label( labelText, row ) );
StylePropertyLabel( label );
var combo = row.Layout.Add( new ComboBox( row ), 1 );
StyleInputWidget( combo );
SetInspectorToolTip( description, label );
inspector.Layout.Add( row );
return combo;
}
private LineEdit AddTextRow( Widget inspector, string labelText, string text, string description = null )
{
var row = new Widget( inspector );
row.Layout = Layout.Row();
row.Layout.Spacing = 6;
row.MinimumHeight = 24;
var label = row.Layout.Add( new Label( labelText, row ) );
StylePropertyLabel( label );
var lineEdit = row.Layout.Add( new LineEdit( row ), 1 );
lineEdit.Text = text ?? "";
lineEdit.ClearButtonEnabled = true;
StyleInputWidget( lineEdit );
SetInspectorToolTip( description, label );
inspector.Layout.Add( row );
return lineEdit;
}
private Widget AddSliderControl( Widget inspector, string labelText, float min, float max, float step, float initialValue, Action<float> changed, string description = null )
{
var row = new Widget( inspector );
row.Layout = Layout.Row();
row.Layout.Spacing = 6;
row.MinimumHeight = 24;
var label = row.Layout.Add( new Label( labelText, row ) );
StylePropertyLabel( label );
var valueBox = row.Layout.Add( new Widget( row ), 1 );
valueBox.Layout = Layout.Row();
valueBox.Layout.Spacing = 5;
valueBox.SetStyles( "background-color: #171717; border: 1px solid #252525; border-radius: 3px;" );
var typeBadge = valueBox.Layout.Add( new Label( "f", valueBox ) );
typeBadge.FixedWidth = 20;
typeBadge.SetStyles( "background-color: #26361d; color: #91c65c; font-weight: bold; font-size: 11px; padding-left: 6px;" );
var slider = valueBox.Layout.Add( new FloatSlider( valueBox ), 1 );
slider.Minimum = min;
slider.Maximum = max;
slider.Step = step;
slider.Value = initialValue;
slider.HighlightColor = new Color( 0.55f, 0.78f, 0.28f, 1f );
slider.MinimumHeight = 22;
var valueLabel = valueBox.Layout.Add( new Label( FormatSliderValue( initialValue, step ), valueBox ) );
valueLabel.FixedWidth = 48;
valueLabel.SetStyles( "color: #f0f0f0; font-size: 11px; padding-left: 4px;" );
slider.OnValueEdited = () =>
{
var snapped = SnapSliderValue( slider.Value, step, min, max );
slider.Value = snapped;
valueLabel.Text = FormatSliderValue( snapped, step );
changed?.Invoke( snapped );
};
SetInspectorToolTip( description, label );
inspector.Layout.Add( row );
return row;
}
private Widget AddSeedRow( Widget inspector, string description = null )
{
var row = new Widget( inspector );
row.Layout = Layout.Row();
row.Layout.Spacing = 6;
row.MinimumHeight = 24;
var label = row.Layout.Add( new Label( "Seed", row ) );
StylePropertyLabel( label );
seedEdit = row.Layout.Add( new LineEdit( row ), 1 );
seedEdit.Text = settings.RandomSeed.ToString();
StyleInputWidget( seedEdit );
seedEdit.TextEdited += text =>
{
if ( int.TryParse( text, out var seed ) )
{
settings.RandomSeed = seed;
QueueAutoProcess();
}
};
SetInspectorToolTip( description, label );
inspector.Layout.Add( row );
return row;
}
private Widget AddRandomizeRow( Widget inspector, string description = null )
{
var row = new Widget( inspector );
row.Layout = Layout.Row();
row.Layout.Spacing = 6;
row.MinimumHeight = 24;
var spacer = row.Layout.Add( new Label( "", row ) );
spacer.FixedWidth = InspectorLabelWidth;
var button = row.Layout.Add( new Button( "Randomize", "casino", row ) );
StyleInspectorButton( button );
button.Clicked = RandomizeSeed;
row.Layout.AddStretchCell();
inspector.Layout.Add( row );
return row;
}
private void AddOutputFolderControl( Widget inspector, string description = null )
{
var row = new Widget( inspector );
row.Layout = Layout.Row();
row.Layout.Spacing = 6;
row.MinimumHeight = 24;
var label = row.Layout.Add( new Label( "Output Folder", row ) );
StylePropertyLabel( label );
outputFolderEdit = row.Layout.Add( new LineEdit( row ), 1 );
outputFolderEdit.Text = settings.OutputFolder;
outputFolderEdit.PlaceholderText = "Uses the source image folder";
outputFolderEdit.ClearButtonEnabled = true;
StyleInputWidget( outputFolderEdit );
outputFolderEdit.TextEdited += text =>
{
outputFolderFollowsSource = false;
settings.OutputFolder = text;
};
SetInspectorToolTip( description, label );
inspector.Layout.Add( row );
var buttonRow = new Widget( inspector );
buttonRow.Layout = Layout.Row();
buttonRow.Layout.Spacing = 10;
buttonRow.MinimumHeight = 30;
buttonRow.SetStyles( "padding-top: 4px; padding-bottom: 2px;" );
var spacer = buttonRow.Layout.Add( new Label( "", buttonRow ) );
spacer.FixedWidth = InspectorLabelWidth;
var sourceButton = buttonRow.Layout.Add( new Button( "Use Source", "source", buttonRow ) );
sourceButton.MinimumWidth = 104;
sourceButton.Clicked = UseSourceFolder;
StyleInspectorButton( sourceButton );
SetInspectorToolTip( "Use the loaded image's folder as the export folder.", sourceButton );
var browseButton = buttonRow.Layout.Add( new Button( "Browse", "folder_open", buttonRow ) );
browseButton.MinimumWidth = 88;
browseButton.Clicked = BrowseOutputFolder;
StyleInspectorButton( browseButton );
SetInspectorToolTip( "Choose a custom export folder.", browseButton );
buttonRow.Layout.AddStretchCell();
inspector.Layout.Add( buttonRow );
}
private Label AddReadOnlyValueRow( Widget inspector, string labelText, string value, string description = null )
{
var row = new Widget( inspector );
row.Layout = Layout.Row();
row.Layout.Spacing = 6;
row.MinimumHeight = 24;
var label = row.Layout.Add( new Label( labelText, row ) );
StylePropertyLabel( label );
var valueLabel = row.Layout.Add( new Label( value, row ), 1 );
valueLabel.WordWrap = true;
valueLabel.SetStyles( "background-color: #171717; border: 1px solid #252525; border-radius: 3px; color: #f0f0f0; font-size: 11px; padding-left: 8px; padding-top: 3px;" );
SetInspectorToolTip( description, label );
inspector.Layout.Add( row );
return valueLabel;
}
private void AddCheckboxRow( Widget inspector, string labelText, bool initialValue, Action<CheckState> changed, string description = null )
{
var row = new Widget( inspector );
row.Layout = Layout.Row();
row.Layout.Spacing = 6;
row.MinimumHeight = 24;
var label = row.Layout.Add( new Label( labelText, row ) );
StylePropertyLabel( label );
var checkbox = row.Layout.Add( new Checkbox( row ) );
checkbox.Value = initialValue;
checkbox.MinimumWidth = 24;
checkbox.StateChanged += changed;
SetInspectorToolTip( description, label );
row.Layout.AddStretchCell();
inspector.Layout.Add( row );
}
private void AddLargeExportButton( Widget inspector )
{
var row = new Widget( inspector );
row.Layout = Layout.Row();
row.Layout.Spacing = 8;
row.MinimumHeight = 58;
row.SetStyles( "padding-top: 10px;" );
row.Layout.AddStretchCell();
var button = row.Layout.Add( new Button( "Export Seamless Image", "save", row ) );
button.MinimumWidth = 250;
button.MinimumHeight = 40;
button.Clicked = ExportImage;
button.SetStyles( "background-color: #467022; border: 1px solid #6d9d36; border-radius: 4px; color: #ffffff; font-size: 13px; font-weight: bold;" );
row.Layout.AddStretchCell();
inspector.Layout.Add( row );
}
private void SetInspectorToolTip( string description, params Widget[] widgets )
{
if ( string.IsNullOrWhiteSpace( description ) )
return;
foreach ( var widget in widgets )
{
if ( widget != null )
widget.ToolTip = description;
}
}
private void StylePropertyLabel( Label label )
{
label.FixedWidth = InspectorLabelWidth;
label.SetStyles( "color: #d6d6d6; font-size: 11px; padding-top: 4px; padding-left: 10px;" );
}
private void StyleInputWidget( Widget widget )
{
widget.MinimumHeight = 22;
widget.SetStyles( "background-color: #171717; border: 1px solid #252525; border-radius: 3px; color: #f0f0f0; font-size: 11px;" );
}
private void StyleInspectorButton( Button button )
{
button.MinimumHeight = 22;
button.SetStyles( "background-color: #1d1d1d; border: 1px solid #292929; border-radius: 3px; color: #e6e6e6; font-size: 11px;" );
}
private void RandomizeSeed()
{
settings.RandomSeed = new Random().Next( 1, int.MaxValue );
if ( seedEdit != null )
seedEdit.Text = settings.RandomSeed.ToString();
QueueAutoProcess();
LogMessage( $"Random seed set to {settings.RandomSeed}" );
}
private void UpdateProcessingControlVisibility()
{
var stochastic = IsStochasticMode();
var seededSeamShape = settings.SeamShape == SeamlessSeamShape.Wavy
|| settings.SeamShape == SeamlessSeamShape.Noise
|| settings.SeamShape == SeamlessSeamShape.Irregular;
var bombing = settings.ProcessingMode == SeamlessProcessingMode.TextureBombing;
var brickTile = settings.ProcessingMode == SeamlessProcessingMode.BrickAndTile;
var brickTileUsesRowOffset = settings.BrickTilePattern == SeamlessBrickTilePattern.RunningBrick
|| settings.BrickTilePattern == SeamlessBrickTilePattern.Planks
|| settings.BrickTilePattern == SeamlessBrickTilePattern.Panels;
SetRowHidden( seedRow, !stochastic && !seededSeamShape );
SetRowHidden( randomizeRow, !stochastic && !seededSeamShape );
SetRowHidden( variationPresetRow, !stochastic );
SetRowHidden( patchShapeRow, !stochastic );
SetRowHidden( variationStrengthRow, !stochastic );
SetRowHidden( seamGravityRow, !stochastic );
SetRowHidden( patchCountRow, !stochastic );
SetRowHidden( patchSizeRow, !stochastic );
SetRowHidden( maskSoftnessRow, !stochastic );
SetRowHidden( bombDensityRow, !bombing );
SetRowHidden( brickTilePatternRow, !brickTile );
SetRowHidden( brickTileCellWidthRow, !brickTile );
SetRowHidden( brickTileCellHeightRow, !brickTile );
SetRowHidden( brickTileGroutWidthRow, !brickTile );
SetRowHidden( brickTileRowOffsetRow, !brickTile || !brickTileUsesRowOffset );
SetRowHidden( brickTileStructureStrengthRow, !brickTile );
}
private bool IsStochasticMode()
{
return settings.ProcessingMode == SeamlessProcessingMode.IrregularPatch
|| settings.ProcessingMode == SeamlessProcessingMode.TextureBombing;
}
private void SetRowHidden( Widget row, bool hidden )
{
if ( row != null )
row.Hidden = hidden;
}
private void RestoreSplitterState()
{
try
{
if ( EditorCookie.TryGetString( ContentSplitterCookie, out var contentState ) && !string.IsNullOrWhiteSpace( contentState ) )
contentSplitter?.RestoreState( contentState );
if ( EditorCookie.TryGetString( MainSplitterCookie, out var mainState ) && !string.IsNullOrWhiteSpace( mainState ) )
mainSplitter?.RestoreState( mainState );
}
catch ( Exception ex )
{
LogMessage( $"Could not restore panel sizes, using defaults: {ex.Message}" );
}
}
private void SaveSplitterState()
{
try
{
if ( contentSplitter != null )
EditorCookie.SetString( ContentSplitterCookie, contentSplitter.SaveState() );
if ( mainSplitter != null )
EditorCookie.SetString( MainSplitterCookie, mainSplitter.SaveState() );
}
catch ( Exception ex )
{
Log.Info( $"Seam-Less: Could not save panel sizes: {ex.Message}" );
}
}
private void OpenImageDialog()
{
var dialog = new FileDialog( this )
{
Title = "Open Image",
DefaultSuffix = ".png"
};
dialog.SetModeOpen();
dialog.SetFindExistingFile();
dialog.SetNameFilter( "Images (*.png *.jpg *.jpeg *.webp *.bmp *.tga *.tif *.tiff *.psd *.svg);;All Files (*.*)" );
if ( !dialog.Execute() )
return;
LoadImage( dialog.SelectedFile );
}
private void LoadDroppedFiles( string[] droppedFiles )
{
var files = new List<string>();
foreach ( var file in droppedFiles )
{
files.Add( file );
}
var supportedFile = FindFirstSupportedFile( files );
if ( supportedFile == null )
{
LogMessage( "Drop ignored. No supported image file was found." );
ShowStatus( "No supported image file found", 4f );
return;
}
LoadImage( supportedFile );
var skippedCount = Math.Max( 0, files.Count - 1 );
if ( skippedCount > 0 )
LogMessage( $"One-image mode loaded the first supported image and skipped {skippedCount} other file(s)." );
}
private void LoadImage( string path )
{
var resolvedPath = ResolveFilePath( path );
if ( string.IsNullOrWhiteSpace( resolvedPath ) || !File.Exists( resolvedPath ) )
{
LogMessage( $"Could not find image: {path}" );
ShowStatus( "Image file not found", 4f );
return;
}
if ( !IsSupportedImageFile( resolvedPath ) )
{
LogMessage( $"Unsupported image type: {Path.GetExtension( resolvedPath )}" );
ShowStatus( "Unsupported image type", 4f );
return;
}
try
{
var bitmap = LoadBitmapFromFile( resolvedPath );
if ( bitmap == null || !bitmap.IsValid )
{
bitmap?.Dispose();
LogMessage( $"S&box could not decode image: {Path.GetFileName( resolvedPath )}" );
ShowStatus( "Image decode failed", 4f );
return;
}
originalBitmap?.Dispose();
processedBitmap?.Dispose();
originalBitmap = bitmap;
processedBitmap = null;
sourceImagePath = resolvedPath;
if ( outputFolderFollowsSource || string.IsNullOrWhiteSpace( settings.OutputFolder ) )
SetOutputFolderToSource();
currentFileLabel.Text = Path.GetFileName( sourceImagePath );
imageInfoLabel.Text = $"{originalBitmap.Width} x {originalBitmap.Height} | {Path.GetExtension( sourceImagePath ).TrimStart( '.' ).ToUpperInvariant()}";
previewWidget.SetCaption( $"{Path.GetFileName( sourceImagePath )} | {originalBitmap.Width} x {originalBitmap.Height}" );
LogMessage( $"Loaded {sourceImagePath}" );
ShowStatus( "Image loaded", 4f );
ProcessImage( false );
}
catch ( Exception ex )
{
LogMessage( $"Load failed: {ex.Message}" );
ShowStatus( "Load failed", 4f );
}
}
private Bitmap LoadBitmapFromFile( string path )
{
var extension = Path.GetExtension( path ).ToLowerInvariant();
var bytes = File.ReadAllBytes( path );
return extension switch
{
".tga" => Bitmap.CreateFromTgaBytes( bytes ),
".tif" => Bitmap.CreateFromTifBytes( bytes ),
".tiff" => Bitmap.CreateFromTifBytes( bytes ),
".psd" => Bitmap.CreateFromPsdBytes( bytes ),
".svg" => Bitmap.CreateFromSvgString( File.ReadAllText( path ), null, null, null, null, null ),
_ => Bitmap.CreateFromBytes( bytes )
};
}
private void ProcessImage( bool manual )
{
if ( originalBitmap == null || !originalBitmap.IsValid )
{
if ( manual )
LogMessage( "No image loaded. Open or drop an image first." );
ShowStatus( "No image loaded", 4f );
return;
}
try
{
var processStarted = DateTime.Now;
processedBitmap?.Dispose();
processedBitmap = SeamlessImageProcessor.Process( originalBitmap, settings );
if ( processedBitmap == null || !processedBitmap.IsValid )
{
LogMessage( "Process failed. The bitmap result was invalid." );
ShowStatus( "Process failed", 4f );
return;
}
SeamlessSuiteActiveTexture.Set( processedBitmap, sourceImagePath, Path.GetFileNameWithoutExtension( sourceImagePath ) );
UpdatePreviewSettings();
if ( manual )
LogMessage( "Processed image with current seam settings." );
var elapsed = DateTime.Now - processStarted;
if ( elapsed.TotalMilliseconds > 1500 )
LogMessage( $"Processing took {elapsed.TotalSeconds:0.0}s. Large stochastic textures can take a moment." );
ShowStatus( "Preview updated", 2f );
}
catch ( Exception ex )
{
LogMessage( $"Process failed: {ex.Message}" );
ShowStatus( "Process failed", 4f );
}
}
private void ExportImage()
{
if ( processedBitmap == null || !processedBitmap.IsValid )
ProcessImage( true );
if ( processedBitmap == null || !processedBitmap.IsValid )
return;
try
{
var outputDirectory = GetOutputDirectory();
Directory.CreateDirectory( outputDirectory );
var outputPath = BuildOutputPath( outputDirectory );
var bytes = GetExportBytes();
File.WriteAllBytes( outputPath, bytes );
var revealPath = outputPath;
var vmatCreated = false;
try
{
AssetSystem.RegisterFile( outputPath );
}
catch ( Exception registerException )
{
LogMessage( $"Export wrote the file, but S&box asset registration skipped it: {registerException.Message}" );
}
LogMessage( $"Exported {outputPath}" );
if ( settings.ExportVMAT )
{
var materialResult = SeamlessMaterialExporter.Export( outputPath, settings.OverwriteExisting );
if ( !string.IsNullOrWhiteSpace( materialResult.Warning ) )
LogMessage( $"VMAT warning: {materialResult.Warning}" );
if ( materialResult.Success )
{
revealPath = materialResult.VmatPath;
vmatCreated = true;
LogMessage( $"Exported VMAT {materialResult.VmatPath}" );
}
else
{
var error = string.IsNullOrWhiteSpace( materialResult.Error )
? "Unknown error."
: materialResult.Error;
LogMessage( $"VMAT export failed: {error}" );
}
}
lastExportPath = revealPath;
if ( vmatCreated )
ShowStatus( "Texture and VMAT export complete", 5f );
else if ( settings.ExportVMAT )
ShowStatus( "Texture exported; VMAT failed", 5f );
else
ShowStatus( "Export complete", 5f );
}
catch ( Exception ex )
{
LogMessage( $"Export failed: {ex.Message}" );
ShowStatus( "Export failed", 5f );
}
}
private byte[] GetExportBytes()
{
var quality = Math.Clamp( settings.ExportQuality, 1, 100 );
return settings.ExportFormat switch
{
SeamlessExportFormat.Jpg => processedBitmap.ToJpg( quality ),
SeamlessExportFormat.WebP => processedBitmap.ToWebP( quality ),
SeamlessExportFormat.Bmp => processedBitmap.ToBmp(),
_ => processedBitmap.ToPng()
};
}
private string BuildOutputPath( string outputDirectory )
{
var sourceName = string.IsNullOrWhiteSpace( sourceImagePath )
? "seamless_texture"
: Path.GetFileNameWithoutExtension( sourceImagePath );
var suffix = CleanFileNamePart( settings.OutputSuffix );
var extension = GetExportExtension();
var basePath = Path.Combine( outputDirectory, $"{sourceName}{suffix}{extension}" );
if ( settings.OverwriteExisting || !File.Exists( basePath ) )
return basePath;
for ( var i = 1; i < 1000; i++ )
{
var numberedPath = Path.Combine( outputDirectory, $"{sourceName}{suffix}_{i:000}{extension}" );
if ( !File.Exists( numberedPath ) )
return numberedPath;
}
return basePath;
}
private string GetExportExtension()
{
return settings.ExportFormat switch
{
SeamlessExportFormat.Jpg => ".jpg",
SeamlessExportFormat.WebP => ".webp",
SeamlessExportFormat.Bmp => ".bmp",
_ => ".png"
};
}
private string GetOutputDirectory()
{
if ( !string.IsNullOrWhiteSpace( settings.OutputFolder ) )
return settings.OutputFolder;
if ( !string.IsNullOrWhiteSpace( sourceImagePath ) )
return Path.GetDirectoryName( sourceImagePath ) ?? Directory.GetCurrentDirectory();
return Directory.GetCurrentDirectory();
}
private void RevealOutput()
{
if ( !string.IsNullOrWhiteSpace( lastExportPath ) && File.Exists( lastExportPath ) )
{
EditorUtility.OpenFileFolder( lastExportPath );
return;
}
var outputDirectory = GetOutputDirectory();
if ( Directory.Exists( outputDirectory ) )
{
EditorUtility.OpenFolder( outputDirectory );
return;
}
LogMessage( "No output folder exists yet." );
ShowStatus( "No output folder exists yet", 4f );
}
private void BrowseOutputFolder()
{
var dialog = new FileDialog( this )
{
Title = "Choose Output Folder"
};
dialog.SetModeOpen();
dialog.SetFindDirectory();
if ( !string.IsNullOrWhiteSpace( settings.OutputFolder ) )
dialog.Directory = settings.OutputFolder;
if ( !dialog.Execute() )
return;
var folder = !string.IsNullOrWhiteSpace( dialog.SelectedFile )
? dialog.SelectedFile
: dialog.Directory;
if ( string.IsNullOrWhiteSpace( folder ) )
return;
outputFolderFollowsSource = false;
settings.OutputFolder = folder;
outputFolderEdit.Text = folder;
LogMessage( $"Output folder set to {folder}" );
}
private void UseSourceFolder()
{
outputFolderFollowsSource = true;
SetOutputFolderToSource();
LogMessage( "Output folder now follows the source image folder." );
}
private void SetOutputFolderToSource()
{
var sourceFolder = !string.IsNullOrWhiteSpace( sourceImagePath )
? Path.GetDirectoryName( sourceImagePath )
: "";
settings.OutputFolder = sourceFolder ?? "";
if ( outputFolderEdit != null )
outputFolderEdit.Text = settings.OutputFolder;
}
private void QueueAutoProcess()
{
if ( !settings.AutoProcess || originalBitmap == null || !originalBitmap.IsValid )
return;
processQueued = true;
queuedProcessTime = DateTime.Now.AddMilliseconds( 140 );
}
private void UpdatePreviewSettings()
{
previewWidget.SetPreviewMode( settings.PreviewMode );
previewWidget.SetTileCount( settings.PreviewTileCount );
previewWidget.SetBitmaps( originalBitmap, processedBitmap );
UpdateTileCountVisibility();
}
private void UpdateTileCountVisibility()
{
if ( tileCountRow == null )
return;
tileCountRow.Hidden = settings.PreviewMode != SeamlessPreviewMode.Tiled;
}
private string ResolveFilePath( string path )
{
if ( string.IsNullOrWhiteSpace( path ) )
return path;
if ( File.Exists( path ) )
return path;
var asset = AssetSystem.FindByPath( path );
if ( asset != null && File.Exists( asset.AbsolutePath ) )
return asset.AbsolutePath;
return path;
}
private List<string> GetFilesFromDragData( DragData data )
{
var files = new List<string>();
if ( data?.Files != null )
{
foreach ( var file in data.Files )
{
files.Add( file );
}
}
if ( data?.Assets != null )
{
foreach ( var dragAsset in data.Assets )
{
if ( !string.IsNullOrWhiteSpace( dragAsset.AssetPath ) )
files.Add( dragAsset.AssetPath );
}
}
if ( data != null )
{
foreach ( var asset in data.OfType<Editor.Asset>() )
{
AddAssetPathCandidates( files, asset );
}
}
return files;
}
private void AddAssetPathCandidates( List<string> files, Editor.Asset asset )
{
if ( asset == null || asset.IsDeleted )
return;
if ( !string.IsNullOrWhiteSpace( asset.AbsolutePath ) )
files.Add( asset.AbsolutePath );
if ( !string.IsNullOrWhiteSpace( asset.RelativePath ) )
files.Add( asset.RelativePath );
if ( !string.IsNullOrWhiteSpace( asset.Path ) )
files.Add( asset.Path );
}
private string FindFirstSupportedFile( List<string> files )
{
foreach ( var file in files )
{
var resolved = ResolveFilePath( file );
if ( IsSupportedImageFile( resolved ) )
return resolved;
}
return null;
}
private bool IsSupportedImageFile( string path )
{
if ( string.IsNullOrWhiteSpace( path ) )
return false;
var extension = Path.GetExtension( path ).ToLowerInvariant();
return supportedExtensions.Contains( extension );
}
private string CleanFileNamePart( string value )
{
if ( string.IsNullOrEmpty( value ) )
return "";
var invalid = Path.GetInvalidFileNameChars();
var cleaned = value;
foreach ( var character in invalid )
{
cleaned = cleaned.Replace( character, '_' );
}
return cleaned;
}
private float SnapSliderValue( float value, float step, float min, float max )
{
var clamped = Math.Clamp( value, min, max );
if ( step <= 0f )
return clamped;
return Math.Clamp( MathF.Round( clamped / step ) * step, min, max );
}
private string FormatSliderValue( float value, float step )
{
if ( step >= 1f )
return MathF.Round( value ).ToString();
if ( step >= 0.05f )
return value.ToString( "0.0" );
return value.ToString( "0.00" );
}
private void ShowStatus( string message, float seconds )
{
var line = $"[{DateTime.Now:HH:mm:ss}] Status: {message}";
consoleOutput?.AppendPlainText( line );
consoleOutput?.ScrollToBottom();
}
private void LogMessage( string message )
{
var line = $"[{DateTime.Now:HH:mm:ss}] {message}";
consoleOutput?.AppendPlainText( line );
consoleOutput?.ScrollToBottom();
Log.Info( $"Seam-Less: {message}" );
}
private void ClearLog()
{
DisposeBitmaps();
sourceImagePath = null;
lastExportPath = null;
processQueued = false;
SeamlessSuiteActiveTexture.Clear();
if ( outputFolderFollowsSource )
SetOutputFolderToSource();
if ( currentFileLabel != null )
currentFileLabel.Text = "No image loaded";
if ( imageInfoLabel != null )
imageInfoLabel.Text = "Drop an image into the preview, or use Open.";
previewWidget?.SetCaption( "Drop an image here or use Open" );
previewWidget?.SetBitmaps( null, null );
consoleOutput?.Clear();
Log.Info( "Seam-Less: Cleared image and log." );
}
private void DisposeBitmaps()
{
originalBitmap?.Dispose();
processedBitmap?.Dispose();
originalBitmap = null;
processedBitmap = null;
}
}