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;
}
}