editor/AdvancedMappingTools/UI/PartInspector.cs

Editor dock widget for the Advanced Mapping Tools package. Shows the selected AmtPart: name, kind, id, state, generator, seed, size and hand-edit history, plus buttons to lock/hide, reseed, regenerate, reset, swap step or glass variants, and jump the selection to its building, floor or assembly. On the root of a generated object it also draws the generator settings sheet, presets, seed locks and copy/paste of settings.

namespace AdvancedMappingTools;
/// <summary>
/// Shows the selected part: what it is, what was changed on it and what can be done with it.
/// On the root of a generated object it also holds the generator settings and seeds.
/// Open it from the View menu of the editor and dock it wherever you like.
/// </summary>
[Dock( "Editor", "Davelix Inspector", "tune" )]
public sealed class PartInspector : Widget
{
	readonly Sheet _sheet;
	GameObject _selected;

	public PartInspector( Widget parent ) : base( parent )
	{
		Layout = Layout.Column();
		Layout.Margin = 4;

		var scroll = Layout.Add( new ScrollArea( this ), 1 );

		_sheet = new Sheet( scroll );
		scroll.Canvas = _sheet;
		_sheet.Show( null );
	}

	[EditorEvent.Frame]
	public void Frame()
	{
		var current = SceneEditorSession.Active?.Selection.OfType<GameObject>().FirstOrDefault();
		if ( current == _selected ) return;

		_selected = current;
		_sheet.Show( current );
	}

	sealed class Sheet : ToolSidebarWidget
	{
		GameObject _go;
		AmtPart _part;
		GameObject _root;
		GeneratorSettings _settings;
		SerializedObject _serialized;

		public Sheet( Widget parent ) : base( parent )
		{
			Layout.Margin = 0;
		}

		public void Show( GameObject go )
		{
			_go = go;
			_part = go.IsValid() ? go.Components.Get<AmtPart>( FindMode.EverythingInSelf ) : null;
			_root = go.IsValid() ? PartService.RootOf( go ) : null;
			_settings = null;
			_serialized = null;

			Layout.Clear( true );

			if ( _part is not null )
			{
				AddInfo();
				AddGameTags();
				AddSelect();
				AddActions();
				AddVariants();
			}
			else
			{
				AddGroup( "Part" ).Add( new Label( go.IsValid() ? "This object was not made by Davelix Construct." : "Nothing selected." ) );
			}

			if ( _root.IsValid() && _go == _root )
				AddGenerator();

			AddManual();
			Layout.AddStretchCell();
		}

		void Refresh() => Show( _go.IsValid() ? _go : null );

		void AddGameTags()
		{
			_part.GameTags ??= [];

			var tags = new[] { "Breakable", "Climbable", "Cover", "Loot Spot", "Navigable" };
			var group = AddGroup( "Game Tags" );
			var row = group.AddRow();
			var per = 2;

			row.Spacing = 2;

			foreach ( var tag in tags )
			{
				var has = _part.GameTags.Contains( tag );

				// Keep the engine tag in sync: the part list is the source, the tag is what game code sees.
				if ( has && !_go.Tags.Has( tag ) ) _go.Tags.Add( tag );

				row.Add( new ListButton( has ? $"✓ {tag}" : tag )
				{
					ToolTip = "Stored on this part, game code can read it.",
					Clicked = () =>
					{
						if ( _part.GameTags.Contains( tag ) )
						{
							_part.GameTags.Remove( tag );
							_go.Tags.Remove( tag );
						}
						else
						{
							_part.GameTags.Add( tag );
							_go.Tags.Add( tag );
						}

						Refresh();
					}
				}, 1 );

				if ( --per == 0 )
				{
					row = group.AddRow();
					row.Spacing = 2;
					per = 2;
				}
			}
		}

		void AddInfo()
		{
			var group = AddGroup( "Part" );
			var edits = PartReconciler.Detect( _part );

			void Row( string text ) => group.Add( new Label( text ) );

			Row( _go.Name );
			Row( $"Type: {_part.Kind}" );
			Row( $"Id: {_part.PartId}" );
			Row( $"State: {_part.State}" );
			Row( $"Generator: {(string.IsNullOrEmpty( _part.Generator ) ? "none" : _part.Generator)}" );
			Row( $"Seed: {_part.Seed}" );
			Row( $"Size: {_part.Dimensions}" );

			if ( _part.State != PartState.ManuallyCreated && !string.IsNullOrEmpty( _part.Generator ) )
				Row( edits == PartEdits.None ? "Changed by hand: nothing" : $"Changed by hand: {edits}" );

			if ( !string.IsNullOrEmpty( _part.History ) )
			{
				var history = AddGroup( "History", collapsible: true );

				foreach ( var line in _part.History.Split( '\n' ).TakeLast( 8 ) )
					history.Add( new Label( line ) );
			}
		}

		void AddSelect()
		{
			var group = AddGroup( "Select" );
			var row = group.AddRow();
			row.Spacing = 2;

			void Button( string label, GameObject target )
			{
				var button = row.Add( new ListButton( label ) { Clicked = () => Select( target ) }, 1 );
				button.Enabled = target.IsValid() && target != _go;
			}

			Button( "Building", _root );
			Button( "Floor", Ancestor( "Floor" ) );
			Button( "Assembly", Ancestor( "WindowAssembly", "DoorAssembly", "StairAssembly", "RoofAssembly", "Wall", "Slab" ) );
		}

		GameObject Ancestor( params string[] kinds )
		{
			for ( var go = _go.Parent; go.IsValid(); go = go.Parent )
			{
				var kind = go.Components.Get<AmtPart>( FindMode.EverythingInSelf )?.Kind;
				if ( kinds.Contains( kind ) ) return go;
			}

			return null;
		}

		static void Select( GameObject go )
		{
			var selection = SceneEditorSession.Active.Selection;
			selection.Clear();
			selection.Add( go );
		}

		void Do( string title, Action action )
		{
			var scope = _root.IsValid() ? _root : _go;

			PartService.Transaction( title, scope, action );
			Refresh();
		}

		void AddActions()
		{
			var group = AddGroup( "This Part" );
			var isLocked = _part.State == PartState.Locked;
			var isHidden = !_go.Enabled;

			var row = group.AddRow();
			row.Spacing = 2;

			row.Add( new ListButton( isLocked ? "Unlock" : "Lock", isLocked ? "lock_open" : "lock" )
			{
				ToolTip = "A locked part is skipped by every regeneration.",
				Clicked = () => Do( isLocked ? "Unlock Part" : "Lock Part", () => PartService.SetLocked( _part, !isLocked ) )
			}, 1 );

			row.Add( new ListButton( isHidden ? "Show" : "Hide", isHidden ? "visibility" : "visibility_off" )
			{
				Clicked = () => Do( isHidden ? "Show Part" : "Hide Part", () => PartService.SetHidden( _part, !isHidden ) )
			}, 1 );

			if ( string.IsNullOrEmpty( _part.Generator ) || !_root.IsValid() || _go == _root ) return;

			row = group.AddRow();
			row.Spacing = 2;

			// A button only exists where it can do something: many parts take their look from their assembly.
			if ( _part.UsesSeed )
			{
				row.Add( new ListButton( "New Seed", "casino" )
				{
					ToolTip = "Give only this part a new look.",
					Clicked = () => Do( "New Part Seed", () => PartService.SetSeed( _part, Random.Shared.Next( 1, int.MaxValue ) ) )
				}, 1 );
			}

			row.Add( new ListButton( "Regenerate", "refresh" )
			{
				ToolTip = "Rebuild this part and what is inside it. Changes by hand stay.",
				Clicked = () => Do( "Regenerate Part", () => PartService.Regenerate( _root, RegenerationOptions.ForPart( _part.PartId ), true ) )
			}, 1 );

			group.Add( new ListButton( "Reset To Generated", "restart_alt" )
			{
				ToolTip = "Throw away everything changed on this part by hand.",
				Clicked = () => Do( "Reset Part", () => PartService.Reset( _part ) )
			} );
		}

		void AddVariants()
		{
			if ( !_root.IsValid() ) return;

			if ( _part.Kind == "Step" )
			{
				var group = AddGroup( "Replace Step" );
				var row = group.AddRow();
				row.Spacing = 2;

				foreach ( var variant in Enum.GetValues<StepVariant>() )
				{
					row.Add( new ListButton( variant.ToString() )
					{
						Clicked = () => Do( $"Step {variant}", () => PartService.SetVariant( _part, variant.ToString() ) )
					}, 1 );
				}
			}

			if ( _part.Kind == "Glass" )
			{
				var group = AddGroup( "Glass" );
				var row = group.AddRow();
				row.Spacing = 2;

				foreach ( var variant in new[] { FrameGenerator.Intact, FrameGenerator.Broken } )
				{
					row.Add( new ListButton( variant )
					{
						Clicked = () => Do( $"Glass {variant}", () => PartService.SetVariant( _part, variant ) )
					}, 1 );
				}
			}
		}

		/// <summary>
		/// Show what a regeneration would do, and only do it once it is confirmed.
		/// </summary>
		void Regenerate( string title, RegenerationOptions options )
		{
			var settings = _settings;
			var root = _root;
			var report = PartService.Regenerate( root, options, false, settings );
			Dialog.AskConfirm( () =>
			{
				if ( PartReconciler.RootPart( root ).Generator is RoadGenerator.GeneratorId or JunctionGenerator.GeneratorId )
					RegenerateRoad( title, root, options, settings );
				else
					PartService.Transaction( title, root, () => PartService.Regenerate( root, options, true, settings ), PartService.RemovedByRegeneration( root, options, settings ) );
				root.Scene.ProcessDeletes();
				Refresh();
			}, report.Summary(), title, "Apply", "Cancel" );
		}

		void Reconnect()
		{
			var walls = WallLinker.Network( _root );
			var scene = _root.Scene;

			if ( _settings is not null )
				PartService.Regenerate( _root, RegenerationOptions.Everything, true, _settings );

			PartService.Transaction( "Reconnect Walls", walls, () => WallLinker.Reconnect( scene ) );
			Refresh();
		}

		/// <summary>
		/// A road that changes takes its junctions along, as one undo step. What the change would take
		/// away has to be known before, so the new settings are looked at for a moment.
		/// </summary>
		static void RegenerateRoad( string title, GameObject root, RegenerationOptions options, GeneratorSettings settings )
		{
			var scene = root.Scene;
			var part = PartReconciler.RootPart( root );
			var stored = part.Settings;

			part.Settings = GeneratorRegistry.Save( settings );

			var removed = RoadLinker.Removed( scene );

			part.Settings = stored;

			PartService.Transaction( title, [.. RoadLinker.Network( scene ).Except( removed )], () =>
			{
				PartService.Regenerate( root, options, true, settings );
				RoadLinker.Reconnect( scene );
			}, removed );
		}

		void ReconnectRoads()
		{
			var scene = _root.Scene;

			if ( _settings is not null )
				PartService.Regenerate( _root, RegenerationOptions.Everything, true, _settings );

			var removed = RoadLinker.Removed( scene );

			PartService.Transaction( "Reconnect Roads", [.. RoadLinker.Network( scene ).Except( removed )], () => RoadLinker.Reconnect( scene ), removed );
			Refresh();
		}

		void AddGenerator()
		{
			var (generator, settings) = PartService.Read( _root );

			if ( generator is null )
			{
				AddGroup( "Generator" ).Add( new Label( $"Generator {_part.Generator} was not found." ) );
				return;
			}

			_settings = settings;
			_serialized = settings.GetSerialized();

			var actions = AddGroup( "Regenerate" );

			actions.Add( new ListButton( "Preview And Apply", "preview" )
			{
				ToolTip = "Shows what would change first. Changes by hand and locked parts stay.",
				Clicked = () => Regenerate( "Regenerate", RegenerationOptions.Everything )
			} );

			actions.Add( new ListButton( "Reset Settings", "restart_alt" )
			{
				ToolTip = "Sets every generator option and seed back to its default value.",
				Clicked = () =>
				{
					if ( System.Activator.CreateInstance( settings.GetType() ) is not GeneratorSettings defaults ) return;

					_settings = GeneratorRegistry.Merge( generator, _settings, defaults );
					_serialized = _settings.GetSerialized();
					Regenerate( "Reset Settings", RegenerationOptions.Everything );
				}
			} );

			actions.Add( new ListButton( "Bring Back Deleted Parts", "undo" )
			{
				Clicked = () => Regenerate( "Restore Deleted Parts", new RegenerationOptions { RestoreDeleted = true } )
			} );

			if ( generator is ConnectedWallGenerator )
			{
				actions.Add( new ListButton( "Reconnect Walls", "hub" )
				{
					ToolTip = "Work out all wall connections again, after moving walls or changing their thickness.",
					Clicked = () => Reconnect()
				} );
			}

			if ( generator is RoadGenerator or JunctionGenerator )
			{
				actions.Add( new ListButton( "Reconnect Roads", "hub" )
				{
					ToolTip = "Work out all junctions again, after moving, deleting or changing roads.",
					Clicked = () => ReconnectRoads()
				} );
			}

			var presets = AddGroup( "Presets", collapsible: true );

			presets.Add( new ListButton( "Save Preset", "save" )
			{
				ToolTip = "Stores the current options as a named JSON preset for this generator.",
				Clicked = () =>
				{
					SettingsPresetService.Save( generator.Id, SettingsPresetService.NextName( generator.Id ), GeneratorRegistry.Save( settings ) );
					Refresh();
				}
			} );

			foreach ( var name in SettingsPresetService.List( generator.Id ) )
			{
				var presetRow = presets.AddRow();
				presetRow.Spacing = 2;

				presetRow.Add( new ListButton( name, "folder_open" )
				{
					ToolTip = "Load this preset and regenerate.",
					Clicked = () =>
					{
						var loaded = SettingsPresetService.Load( generator, name );

						if ( loaded is null ) return;

						_settings = GeneratorRegistry.Merge( generator, _settings, loaded );
						_serialized = _settings.GetSerialized();
						Regenerate( $"Preset {name}", RegenerationOptions.Everything );
					}
				}, 1 );

				presetRow.Add( new IconButton( "delete", () =>
				{
					SettingsPresetService.Delete( generator.Id, name );
					Refresh();
				} )
				{ ToolTip = "Delete preset" } );
			}

			var clip = AddGroup( "Copy Settings" );
			var clipRow = clip.AddRow();
			clipRow.Spacing = 2;

			clipRow.Add( new ListButton( "Copy", "content_copy" )
			{
				ToolTip = "Copy all generator options and seeds, to paste them on another object.",
				Clicked = () => SettingsClipboard.Copy( generator, settings )
			}, 1 );

			clipRow.Add( new ListButton( "Paste", "content_paste" )
			{
				ToolTip = "Paste the copied options onto this object. Only works for the same generator.",
				Clicked = () =>
				{
					if ( !SettingsClipboard.Matches( generator ) )
					{
						Log.Warning( "Davelix Construct: the copied settings belong to another generator." );
						return;
					}

					var loaded = SettingsClipboard.Load( generator );

					if ( loaded is null ) return;

					_settings = GeneratorRegistry.Merge( generator, _settings, loaded );
					_serialized = _settings.GetSerialized();
					Regenerate( "Paste Settings", RegenerationOptions.Everything );
				}
			}, 1 );

			AddSeeds( generator, settings );
			PropertySheet.Fill( this, _serialized, "Settings", settings.UnusedOptions );
		}

		void AddSeeds( IPartGenerator generator, GeneratorSettings settings )
		{
			var group = AddGroup( "Seeds" );

			group.Add( new ListButton( "Randomize All", "casino" )
			{
				ToolTip = "Locked seeds keep their value.",
				Clicked = () =>
				{
					settings.Seeds.RandomizeAll( generator.SeedNames );
					Regenerate( "Randomize All", RegenerationOptions.Everything );
				}
			} );

			foreach ( var name in generator.SeedNames )
			{
				var row = group.AddRow();
				row.Spacing = 2;

				row.Add( new Label( $"{name}: {settings.Seeds.For( name )}" ), 1 );

				row.Add( new IconButton( settings.Seeds.IsLocked( name ) ? "lock" : "lock_open", () =>
				{
					settings.Seeds.SetLocked( name, !settings.Seeds.IsLocked( name ) );
					PartService.Transaction( "Lock Seed", _root, () => PartReconciler.RootPart( _root ).Settings = GeneratorRegistry.Save( settings ) );
					Refresh();
				} )
				{ ToolTip = "A locked seed stays when randomizing." } );

				row.Add( new IconButton( "casino", () =>
				{
					if ( settings.Seeds.Randomize( name ) )
						Regenerate( $"Randomize {name}", RegenerationOptions.Everything );
				} )
				{ ToolTip = $"New seed for {name}, nothing else changes." } );
			}
		}

		void AddManual()
		{
			var group = AddGroup( "Create By Hand", collapsible: true );
			var parent = _go;
			var row = group.AddRow();
			var count = 0;

			row.Spacing = 2;

			foreach ( var shape in Enum.GetValues<ManualShape>() )
			{
				if ( count++ == 3 )
				{
					row = group.AddRow();
					row.Spacing = 2;
				}

				row.Add( new ListButton( shape.ToString() )
				{
					ToolTip = "Regeneration never touches parts made by hand.",
					Clicked = () => CreateManual( shape, parent )
				}, 1 );
			}
		}

		static void CreateManual( ManualShape shape, GameObject parent )
		{
			var session = SceneEditorSession.Active;
			var world = parent.IsValid() ? parent.WorldTransform : new Transform( Vector3.Zero, Rotation.Identity );

			using ( session.Scene.Push() )
			using ( session.UndoScope( $"Create {shape}" ).WithGameObjectCreations().Push() )
			{
				Select( ManualParts.Create( shape, new Vector3( 32, 32, 32 ), Material.Load( GenContext.DefaultMaterial ), parent, world ) );
			}
		}
	}
}