Editor/HeightmapConvertWindow.cs

Editor window UI that converts image files into a square single-channel raw heightmap (.raw). It lets the user pick an image (including EXR), previews output size/byte estimate based on settings, converts via HeightmapConverter, and saves the resulting raw file.

File Access
using System;
using System.IO;
using Sandbox;

namespace Editor.TerrainConvert;

/// <summary>
/// Editor tool that converts an image (.png, .jpg, .tga, .tif, .psd, .exr ...) into a
/// raw single-channel heightmap (.raw) that the s&box terrain importer can load.
/// </summary>
[EditorApp( "Heightmap Converter", "terrain", "Convert an image into a terrain heightmap (.raw)" )]
public class HeightmapConvertWindow : Widget
{
	HeightmapConvertSettings Settings { get; set; } = new();

	string inputFile;
	Bitmap loadedBitmap;
	ExrImage loadedExr;
	int srcWidth, srcHeight;

	bool HasImage => loadedBitmap is not null || loadedExr is not null;

	Label inputLabel;
	Label outputInfoLabel;
	Button convertButton;

	public HeightmapConvertWindow() : this( null ) { }

	public HeightmapConvertWindow( Widget parent ) : base( parent )
	{
		WindowFlags = WindowFlags.Dialog | WindowFlags.Customized | WindowFlags.WindowTitle | WindowFlags.CloseButton | WindowFlags.WindowSystemMenuHint;
		DeleteOnClose = true;
		WindowTitle = "Convert Image to Heightmap";
		SetWindowIcon( "terrain" );

		Layout = Layout.Column();
		Layout.Spacing = 8;
		Layout.Margin = 16;

		var warning = new WarningBox(
			"Converts an image into a raw heightmap (.raw) for the terrain system.\n" +
			"Output is square, single-channel, 8 or 16 bit. Import it via the Terrain component's heightmap import.", this );
		Layout.Add( warning );

		// Input file row
		{
			var row = Layout.Row();
			row.Spacing = 8;

			inputLabel = new Label( "No image selected", this );
			inputLabel.WordWrap = false;
			row.Add( inputLabel, 1 );

			var browse = new Button( "Browse Image...", "image", this );
			browse.Clicked = PickInputFile;
			row.Add( browse );

			Layout.Add( row );
		}

		// Settings sheet
		{
			var so = EditorUtility.GetSerializedObject( Settings );
			so.OnPropertyChanged += _ => UpdateInfo();

			var sheet = new ControlSheet();
			sheet.AddObject( so );
			Layout.Add( sheet );
		}

		outputInfoLabel = new Label( "", this );
		outputInfoLabel.Color = Theme.TextControl.WithAlpha( 0.6f );
		Layout.Add( outputInfoLabel );

		Layout.AddStretchCell();

		// Bottom bar
		{
			var row = Layout.Row();
			row.Margin = new Sandbox.UI.Margin( 0, 8, 0, 0 );
			row.AddStretchCell();

			convertButton = new Button.Primary( "Convert & Save...", "file_download", this );
			convertButton.Clicked = ConvertAndSave;
			convertButton.Enabled = false;
			row.Add( convertButton );

			Layout.Add( row );
		}

		Width = 440;
		MinimumWidth = 380;
		Height = 520;

		UpdateInfo();

		Show();
		Focus();
	}

	void PickInputFile()
	{
		var fd = new FileDialog( null )
		{
			Title = "Select Source Image",
		};
		fd.SetFindFile();
		fd.SetModeOpen();
		fd.SetNameFilter( "Images (*.png *.jpg *.jpeg *.tga *.tif *.tiff *.psd *.exr *.bmp *.raw)" );

		if ( !fd.Execute() )
			return;

		LoadInput( fd.SelectedFile );
	}

	void LoadInput( string path )
	{
		loadedBitmap?.Dispose();
		loadedBitmap = null;
		loadedExr = null;
		inputFile = null;

		try
		{
			var bytes = File.ReadAllBytes( path );

			// EXR isn't supported by the Skia-backed Bitmap loader, so decode it ourselves.
			bool isExr = path.EndsWith( ".exr", StringComparison.OrdinalIgnoreCase ) || ExrImage.IsExr( bytes );
			if ( isExr )
			{
				loadedExr = ExrImage.Load( bytes );
				srcWidth = loadedExr.Width;
				srcHeight = loadedExr.Height;
				inputFile = path;
			}
			else
			{
				var bitmap = Bitmap.CreateFromBytes( bytes );
				if ( bitmap is null || !bitmap.IsValid )
				{
					EditorUtility.DisplayDialog( "Couldn't load image",
						$"Failed to decode '{Path.GetFileName( path )}'.\nThis image format may not be supported." );
					return;
				}

				loadedBitmap = bitmap;
				srcWidth = bitmap.Width;
				srcHeight = bitmap.Height;
				inputFile = path;
			}
		}
		catch ( Exception e )
		{
			EditorUtility.DisplayDialog( "Couldn't load image", e.Message );
			return;
		}

		UpdateInfo();
	}

	void UpdateInfo()
	{
		if ( !HasImage )
		{
			inputLabel.Text = "No image selected";
			outputInfoLabel.Text = "Pick an image to begin.";
			convertButton.Enabled = false;
			return;
		}

		string kind = loadedExr is not null ? "EXR" : "image";
		inputLabel.Text = $"{Path.GetFileName( inputFile )}  ({srcWidth}x{srcHeight}, {kind})";
		convertButton.Enabled = true;

		int res = PreviewResolution();
		string depth = Settings.BitDepth == HeightBitDepth.Bit16 ? "16-bit" : "8-bit";
		int bytes = res * res * (Settings.BitDepth == HeightBitDepth.Bit16 ? 2 : 1);
		outputInfoLabel.Text = $"Output: {res}x{res}, {depth} → {bytes / 1024:n0} KB raw";
	}

	int PreviewResolution()
	{
		int res = Settings.Resolution > 0 ? Settings.Resolution : Math.Min( srcWidth, srcHeight );
		if ( Settings.PowerOfTwo )
			res = HeightmapConverter.RoundDownToPowerOfTwo( res );
		return Math.Clamp( res, 4, 16384 );
	}

	void ConvertAndSave()
	{
		if ( !HasImage )
			return;

		var fd = new FileDialog( null )
		{
			Title = "Save Heightmap",
			DefaultSuffix = ".raw",
		};
		fd.Directory = Path.GetDirectoryName( inputFile );
		fd.SelectFile( $"{Path.GetFileNameWithoutExtension( inputFile )}.raw" );
		fd.SetFindFile();
		fd.SetModeSave();
		fd.SetNameFilter( "Raw Heightmap (*.raw *.r16 *.r8)" );

		if ( !fd.Execute() )
			return;

		try
		{
			int resolution;
			var data = loadedExr is not null
				? HeightmapConverter.Convert( loadedExr, Settings, out resolution )
				: HeightmapConverter.Convert( loadedBitmap, Settings, out resolution );
			File.WriteAllBytes( fd.SelectedFile, data );

			EditorUtility.DisplayDialog( "Heightmap saved",
				$"Wrote {resolution}x{resolution} heightmap to:\n{fd.SelectedFile}\n\n" +
				"Import it from a Terrain component: open its heightmap import and choose this .raw file." );
		}
		catch ( Exception e )
		{
			EditorUtility.DisplayDialog( "Conversion failed", e.Message );
		}
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();
		loadedBitmap?.Dispose();
		loadedBitmap = null;
		loadedExr = null;
	}
}