editor/AdvancedMappingTools/Components/PartReconciler.cs

Editor-side reconciler for the Advanced Mapping Tools generator. Compares a PartPlan against the AmtPart components already in a scene, detects which parts a person edited by hand (transform, mesh, tint, smoothing), and applies only the non-conflicting plan changes. Also creates new parts, removes parts dropped from the plan, and records a RegenerationReport of new, changed, unchanged, protected and user-deleted parts.

using System.Text.Json;

namespace AdvancedMappingTools;
public enum PartChange
{
	New,
	Changed,
	Unchanged,
	Removed,
	Protected,
	Excluded
}

/// <summary>
/// What a person changed on a part since it was generated.
/// </summary>
[Flags]
public enum PartEdits
{
	None = 0,
	Position = 1,
	Rotation = 2,
	Scale = 4,
	Geometry = 8,
	Material = 16,
	Visibility = 32,
	Tint = 64,
	Transform = Position | Rotation | Scale,
	Mesh = Geometry | Material
}

public sealed class RegenerationReport
{
	public List<(string Id, PartChange Change, string Note)> Entries { get; } = [];

	public int Count( PartChange change ) => Entries.Count( x => x.Change == change );

	public bool HasChanges => Entries.Any( x => x.Change is PartChange.New or PartChange.Changed or PartChange.Removed );

	public void Add( string id, PartChange change, string note = null ) => Entries.Add( (id, change, note) );

	public string Summary() =>
		$"New: {Count( PartChange.New )}\nChanged: {Count( PartChange.Changed )}\nUnchanged: {Count( PartChange.Unchanged )}\n" +
		$"Removed: {Count( PartChange.Removed )}\nProtected: {Count( PartChange.Protected )}\nDeleted by you: {Count( PartChange.Excluded )}";
}

/// <summary>
/// Limits a regeneration. By default everything may change except what was edited by hand.
/// </summary>
public sealed class RegenerationOptions
{
	public Func<string, string, bool> Include { get; init; } = ( _, _ ) => true;

	/// <summary>
	/// Bring back parts that were deleted by hand.
	/// </summary>
	public bool RestoreDeleted { get; init; }

	/// <summary>
	/// Parts whose manual edits are thrown away.
	/// </summary>
	public IReadOnlySet<string> Reset { get; init; }

	public static RegenerationOptions Everything => new();

	public static RegenerationOptions ForCategory( string category ) =>
		new() { Include = ( _, partCategory ) => partCategory == category };

	public static RegenerationOptions ForPart( string id ) =>
		new() { Include = ( partId, _ ) => partId == id || partId.StartsWith( $"{id}/" ) };

	public static RegenerationOptions ResetPart( string id ) =>
		new() { Include = ( partId, _ ) => partId == id || partId.StartsWith( $"{id}/" ), Reset = new HashSet<string> { id } };
}

/// <summary>
/// Brings a generated object in line with a plan. This is what makes regeneration
/// non-destructive: a part is matched by its stable id, compared by fingerprint, and
/// whatever a person changed on it stays. Unchanged parts are not touched at all.
/// </summary>
public static class PartReconciler
{
	const float PositionTolerance = 0.01f;
	const float RotationTolerance = 0.05f;
	const float ScaleTolerance = 0.001f;
	const float TintTolerance = 0.01f;

	public static List<AmtPart> Collect( GameObject root ) =>
		[.. root.Components.GetAll<AmtPart>( FindMode.EverythingInSelfAndDescendants )];

	public static AmtPart RootPart( GameObject root ) => root.Components.Get<AmtPart>( FindMode.EverythingInSelf );

	/// <summary>
	/// False for a part that is going away: it stays in the lists of the scene until the end of
	/// the frame, but its object has already let go of its components.
	/// </summary>
	public static bool IsAlive( AmtPart part ) => part.IsValid() && part.GameObject.IsValid() && RootPart( part.GameObject ) is not null;

	public static PolygonMesh MeshOf( AmtPart part ) => part.Components.Get<MeshComponent>( FindMode.EverythingInSelf )?.Mesh;

	/// <summary>
	/// Find what changed on a part by comparing it with what the generator made.
	/// </summary>
	public static PartEdits Detect( AmtPart part )
	{
		var edits = PartEdits.None;
		var now = part.GameObject.LocalTransform;
		var then = part.GeneratedTransform;
		if ( !now.Position.AlmostEqual( then.Position, PositionTolerance ) )
			edits |= PartEdits.Position;
		if ( now.Rotation.Distance( then.Rotation ) > RotationTolerance )
			edits |= PartEdits.Rotation;
		if ( !now.Scale.AlmostEqual( then.Scale, ScaleTolerance ) )
			edits |= PartEdits.Scale;
		if ( !part.GameObject.Enabled )
			edits |= PartEdits.Visibility;
		var mesh = MeshOf( part );
		if ( PartHash.Geometry( mesh ) != part.GeneratedGeometry )
			edits |= PartEdits.Geometry;
		if ( PartHash.Materials( mesh ) != part.GeneratedMaterial )
			edits |= PartEdits.Material;
		if ( part.GeneratedFaceMaterials != 0 && PartHash.FaceMaterials( mesh ) != part.GeneratedFaceMaterials )
			edits |= PartEdits.Material;
		var component = part.Components.Get<MeshComponent>( FindMode.EverythingInSelf );
		if ( component.IsValid() && !SameTint( component.Color, part.GeneratedTint ) )
			edits |= PartEdits.Tint;
		return edits;
	}

	static bool SameTint( Color a, Color b ) =>
		MathF.Abs( a.r - b.r ) < TintTolerance && MathF.Abs( a.g - b.g ) < TintTolerance && MathF.Abs( a.b - b.b ) < TintTolerance && MathF.Abs( a.a - b.a ) < TintTolerance;

	public static Dictionary<string, PartOverride> ReadOverrides( GameObject root )
	{
		var result = new Dictionary<string, PartOverride>();

		foreach ( var part in Collect( root ) )
		{
			if ( string.IsNullOrEmpty( part.Overrides ) ) continue;
			result[part.PartId] = JsonSerializer.Deserialize<PartOverride>( part.Overrides );
		}

		return result;
	}

	static bool IsFrozen( AmtPart part ) => part.State is PartState.Locked or PartState.Protected or PartState.Excluded;

	/// <summary>
	/// Compare a plan with the scene. With <paramref name = "apply"/> false nothing is changed,
	/// which is how the preview before a regeneration is made.
	/// </summary>
	public static RegenerationReport Run( GameObject root, PartPlan plan, RegenerationOptions options, bool apply )
	{
		var report = new RegenerationReport();
		var rootPart = RootPart( root );
		var existing = Collect( root ).Where( x => !string.IsNullOrEmpty( x.PartId ) ).GroupBy( x => x.PartId ).ToDictionary( x => x.Key, x => x.First() );
		var known = (rootPart?.KnownParts ?? "").Split( '\n', StringSplitOptions.RemoveEmptyEntries ).ToHashSet();
		var planned = new HashSet<string>();
		var stillKnown = new HashSet<string>();
		void Visit( PartPlan node, GameObject parent, bool frozen )
		{
			planned.Add( node.Id );
			existing.TryGetValue( node.Id, out var part );
			if ( part is null )
			{
				if ( known.Contains( node.Id ) && !options.RestoreDeleted )
				{
					report.Add( node.Id, PartChange.Excluded, "deleted by hand" );
					stillKnown.Add( node.Id );
					return;
				}

				if ( frozen || !options.Include( node.Id, node.Category ) )
					return;
				report.Add( node.Id, PartChange.New );
				stillKnown.Add( node.Id );
				var created = apply ? Create( node, parent ) : null;
				foreach ( var child in node.Children )
					Visit( child, created, frozen );
				return;
			}

			stillKnown.Add( node.Id );
			var isRoot = node == plan;
			var freeze = frozen || IsFrozen( part );
			if ( freeze )
				report.Add( node.Id, PartChange.Protected, part.State.ToString() );
			else
				Compare( part, node, isRoot );
			foreach ( var child in node.Children )
				Visit( child, part.GameObject, freeze );
		}

		void Compare( AmtPart part, PartPlan node, bool isRoot )
		{
			// An unselected part must not change tint, smoothing, seed or baseline either.
			if ( !options.Include( node.Id, node.Category ) )
			{
				report.Add( node.Id, PartChange.Unchanged, "not selected" );
				return;
			}

			var reset = options.Reset?.Contains( node.Id ) == true;
			var edits = reset ? PartEdits.None : Detect( part );
			// Keep old persisted material hashes compatible. Adopt the additional baseline
			// only against a matching old plan; differing assignments are preserved.
			if ( part.GeneratedFaceMaterials == 0 && node.Mesh is not null && part.GeneratedGeometry == node.GeometryHash && part.GeneratedMaterial == node.MaterialHash )
			{
				if ( !reset && PartHash.FaceMaterials( MeshOf( part ) ) != node.FaceMaterialHash )
					edits |= PartEdits.Material;
				if ( apply )
					part.GeneratedFaceMaterials = node.FaceMaterialHash;
			}

			if ( apply )
			{
				part.Seed = node.Seed;
				part.UsesSeed = node.UsesSeed;
			}

			if ( isRoot )
				edits &= ~PartEdits.Transform;
			var sameTransform = isRoot || (part.GeneratedTransform.Position.AlmostEqual( node.Local.Position, PositionTolerance ) && part.GeneratedTransform.Rotation.Distance( node.Local.Rotation ) <= RotationTolerance && part.GeneratedTransform.Scale.AlmostEqual( node.Local.Scale, ScaleTolerance ));
			var sameMesh = part.GeneratedGeometry == node.GeometryHash && part.GeneratedMaterial == node.MaterialHash && (part.GeneratedFaceMaterials == 0 || part.GeneratedFaceMaterials == node.FaceMaterialHash);
			var sameTint = node.Tint is not Color tint || SameTint( part.GeneratedTint, tint );
			var component = part.Components.Get<MeshComponent>( FindMode.EverythingInSelf );
			var sameSmoothing = node.Mesh is null || !component.IsValid() || component.SmoothingAngle == node.Smoothing;
			if ( apply && edits != PartEdits.None && part.State == PartState.Generated )
				part.State = PartState.Modified;
			if ( sameTransform && sameMesh && sameTint && sameSmoothing && !reset )
			{
				if ( apply && edits == PartEdits.None )
				{
					SetSmoothing( part.GameObject, node );
					SetTint( part, node );
				}

				if ( edits == PartEdits.None )
					report.Add( node.Id, PartChange.Unchanged );
				else
					report.Add( node.Id, PartChange.Protected, $"kept: {edits}" );
				return;
			}

			report.Add( node.Id, PartChange.Changed, edits == PartEdits.None ? null : $"kept: {edits}" );
			if ( !apply )
				return;
			if ( !isRoot && (edits & PartEdits.Transform) == 0 )
				part.GameObject.LocalTransform = node.Local;
			if ( (edits & PartEdits.Mesh) == 0 )
				SetMesh( part.GameObject, node );
			if ( (edits & PartEdits.Tint) == 0 )
				SetTint( part, node );
			if ( (edits & PartEdits.Visibility) == 0 )
				part.GameObject.Enabled = true;
			Stamp( part, node );
			if ( reset && part.State == PartState.Modified )
				part.State = PartState.Generated;
			part.Version++;
		}

		Visit( plan, null, false );
		foreach ( var(id, part)in existing )
		{
			if ( planned.Contains( id ) || !part.IsValid() )
				continue;
			if ( part.State == PartState.ManuallyCreated )
				continue;
			if ( !options.Include( id, part.Category ) )
				continue;
			if ( IsFrozen( part ) || Detect( part ) != PartEdits.None )
			{
				report.Add( id, PartChange.Protected, "no longer planned, kept" );
				continue;
			}

			report.Add( id, PartChange.Removed );
		}

		if ( apply )
			RemoveParts( report, existing );
		if ( apply && rootPart is not null )
			rootPart.KnownParts = string.Join( "\n", stillKnown.OrderBy( x => x, StringComparer.Ordinal ) );
		return report;
	}

	/// <summary>
	/// Take away parts that are gone from the plan, but keep any group that still holds
	/// something a person made or changed.
	/// </summary>
	static void RemoveParts( RegenerationReport report, Dictionary<string, AmtPart> existing )
	{
		var doomed = report.Entries.Where( x => x.Change == PartChange.Removed ).Select( x => existing[x.Id].GameObject ).ToHashSet();

		bool IsEmptied( GameObject go ) => doomed.Contains( go ) && go.Children.All( IsEmptied );

		foreach ( var go in doomed.Where( IsEmptied ).ToArray() )
		{
			if ( go.Parent is GameObject parent && doomed.Contains( parent ) && IsEmptied( parent ) ) continue;
			go.Destroy();
		}
	}

	public static GameObject Create( PartPlan node, GameObject parent )
	{
		var go = parent is null ? new GameObject( true, node.Name ) : new GameObject( parent, true, node.Name );

		go.LocalTransform = node.Local;
		go.IsStatic = true;

		var part = go.Components.Create<AmtPart>();

		SetMesh( go, node );
		Stamp( part, node );
		SetTint( part, node );

		return go;
	}

	static void Stamp( AmtPart part, PartPlan node )
	{
		part.PartId = node.Id;
		part.Kind = node.Kind;
		part.Category = node.Category;
		part.Seed = node.Seed;
		part.UsesSeed = node.UsesSeed;
		part.Dimensions = node.Dimensions;
		part.GeneratedTransform = node.Local;
		part.GeneratedGeometry = node.GeometryHash;
		part.GeneratedMaterial = node.MaterialHash;
		part.GeneratedFaceMaterials = node.FaceMaterialHash;
	}

	static void SetMesh( GameObject go, PartPlan node )
	{
		var component = go.Components.Get<MeshComponent>( FindMode.EverythingInSelf );

		if ( node.Mesh is null )
		{
			component?.Destroy();
			return;
		}

		if ( component.IsValid() )
		{
			component.Mesh = node.Mesh;
			component.SmoothingAngle = node.Smoothing;
			component.RebuildMesh();
			return;
		}

		component = go.Components.Create<MeshComponent>( false );
		component.Mesh = node.Mesh;
		component.SmoothingAngle = node.Smoothing;
		component.Enabled = true;
	}

	/// <summary>
	/// The colour a part is painted in is not part of its shape, so it can change on an unchanged mesh.
	/// </summary>
	static void SetTint( AmtPart part, PartPlan node )
	{
		if ( node.Tint is not Color tint ) return;

		var component = part.Components.Get<MeshComponent>( FindMode.EverythingInSelf );

		if ( !component.IsValid() ) return;

		component.Color = tint;
		part.GeneratedTint = tint;
	}

	/// <summary>
	/// How a part shades is not part of its shape, so it can change on an unchanged mesh.
	/// </summary>
	static void SetSmoothing( GameObject go, PartPlan node )
	{
		var component = go.Components.Get<MeshComponent>( FindMode.EverythingInSelf );

		if ( component.IsValid() && node.Mesh is not null && component.SmoothingAngle != node.Smoothing )
			component.SmoothingAngle = node.Smoothing;
	}
}