Editor/Output/ArchBuildCache.cs

Editor-side cache for architecture builds. Tracks previously built parts, their content keys, settled keys and spatial extents by plane, and decides which parts need re-comparing or rewriting during a new build via a contact-closure algorithm.

File Access
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

// What one part of the last build came out as. Reach is a plane AND the extent it occupies in it: the plane alone is
// far too coarse to scope by, because every ground slab in a plan stands in the z=0 bucket.
sealed class ArchBuiltRecord
{
	public ulong Content { get; set; }
	public ulong Settled { get; set; }
	public Dictionary<(long, long, long, long), BBox> Reach { get; set; } = new();
}

// Which parts a rebuild actually has to hand the engine. Everything else keeps the mesh it already has, which is
// the whole point: assigning a mesh cooks a collision hull, a physics mesh and a trace mesh and uploads two
// buffers, and a handle drag was paying that for every part in the plan on every rung the mouse crossed.
public sealed class ArchBuildSettlement
{
	public IReadOnlySet<int> Write { get; init; }

	// Paths of parts nothing looked at this build because the scope holding them was gated out. Live, untouched,
	// and the prune has to be told so - their absence from the emitted list is not a deletion.
	public IReadOnlySet<string> Kept { get; init; } = new HashSet<string>();

	public int Reused { get; init; }
	public int Contacted { get; init; }

	// A gate decision turned out to hide half of a coplanar pair from the contact pass. Nothing here is usable;
	// the caller builds cold, and the scope that caused it is refused from now on.
	public bool Escaped { get; init; }
}

// The bridge between two builds. Held by the tool across every regenerate and dropped whenever the standing
// geometry is not ours to reason about any more.
public sealed class ArchBuildCache
{
	readonly Dictionary<string, ArchBuiltRecord> parts = new();
	readonly Dictionary<(long, long, long, long), HashSet<string>> byPlane = new();
	readonly Dictionary<int, ulong> scopes = new();

	// Scopes that turned out to stand where something else was being re-compared, so gating them out would have
	// hidden one half of a coplanar pair from the contact pass. Refused from then on, which is what stops the
	// escape below from firing over and over on a plan whose houses genuinely share a wall line.
	readonly HashSet<int> refused = new();

	public int Count => parts.Count;

	// What the last rebuild decided, so a report can say how much of the plan was actually handed to the engine.
	// Verify by MEASURING: a cache nobody can read the working of is a cache nobody can tell has stopped working.
	public ArchBuildSettlement Last { get; private set; }

	public void Forget()
	{
		Restart();
		refused.Clear();
	}

	// Everything about what is standing, but not what has been learned about which scopes may not be gated - that
	// lesson is why the escape it exists for cannot loop.
	public void Restart()
	{
		parts.Clear();
		byPlane.Clear();
		scopes.Clear();
	}

	public bool Holds( string path ) => parts.ContainsKey( path );

	// The gate this build asks whether a scope can be left alone. It carries the keys it computes as it answers,
	// and Settle is what commits them - so a build that threw leaves the cache saying what is actually standing.
	public ArchBuildGate Gate( ArchPlan plan, ArchKit kit, int terrain )
	{
		return ArchLayerHash.Worth( plan ) ? new ArchBuildGate( plan, kit, terrain, scopes, refused ) : null;
	}

	// Finish, contact and key only what has to be. A null cache is a cold build: everything is compared and
	// everything is written, which is exactly what the designers, the previews and the tests want.
	public static ArchBuildSettlement Settle( ArchArchitectureBuild build, ArchBuildCache cache, ArchBuildGate gate = null )
	{
		if ( cache is null )
		{
			var all = Enumerable.Range( 0, build.Parts.Count ).ToHashSet();

			new ArchMeshContactService().RemoveRedundantContacts( build.Parts );

			return new ArchBuildSettlement { Write = all, Contacted = all.Count };
		}

		return cache.Last = cache.Settled( build, gate );
	}

	ArchBuildSettlement Settled( ArchArchitectureBuild build, ArchBuildGate gate )
	{
		var built = build.Parts;
		var kept = Kept( build.Standing );
		var closing = new ArchContactClosure( this );

		// A part whose emitted geometry moved has to be re-compared, and so does wherever it USED to stand - the
		// neighbour that was covered by a face which has now gone is uncovered and must come back.
		for ( var index = 0; index < built.Count; index++ )
		{
			if ( parts.TryGetValue( built[index].Path, out var held ) && held.Content == built[index].Key )
			{
				continue;
			}

			closing.Enrol( built, index );

			if ( held is not null )
			{
				closing.Sight( held.Reach );
			}
		}

		// A part that is no longer emitted at all leaves its extent behind for the same reason - unless the scope
		// holding it was gated out, in which case it is not gone, it is simply not this build's business.
		var emitted = built.Select( part => part.Path ).ToHashSet();

		foreach ( var gone in parts.Where( entry => !emitted.Contains( entry.Key ) && !kept.Contains( entry.Key ) ) )
		{
			closing.Sight( gone.Value.Reach );
		}

		closing.Close( built );

		// A gated-out scope offered the pass no faces, so one standing where the pass is deciding leaves it half-blind
		// and a covered face survives as a z-fight. Refuse it and say so: the caller builds cold, once.
		if ( Escaped( kept, closing ) is { Count: > 0 } escapees )
		{
			foreach ( var scope in escapees )
			{
				refused.Add( scope );
			}

			return new ArchBuildSettlement { Write = new HashSet<int>(), Escaped = true };
		}

		new ArchMeshContactService().Resolve( closing.Faces() );

		var write = new HashSet<int>();

		for ( var index = 0; index < built.Count; index++ )
		{
			var path = built[index].Path;
			var content = built[index].Key;

			parts.TryGetValue( path, out var held );

			// Outside the closure nothing near it moved, so its settled key is the one it already had.
			var settled = closing.Holds( index ) ? Surviving( built[index], content ) : held?.Settled ?? content;

			if ( held is null || held.Settled != settled )
			{
				write.Add( index );
			}

			Record( path, content, settled, closing.Reach( index ) ?? held?.Reach );
		}

		foreach ( var gone in parts.Keys.Where( path => !emitted.Contains( path ) && !kept.Contains( path ) ).ToList() )
		{
			Drop( gone );
		}

		foreach ( var scope in gate?.Keys ?? new Dictionary<int, ulong>() )
		{
			scopes[scope.Key] = scope.Value;
		}

		return new ArchBuildSettlement
		{
			Write = write,
			Kept = kept,
			Reused = built.Count - write.Count,
			Contacted = closing.Count
		};
	}

	internal IEnumerable<string> Standing( (long, long, long, long) plane )
	{
		return byPlane.TryGetValue( plane, out var held ) ? held.ToList() : Enumerable.Empty<string>();
	}

	internal ArchBuiltRecord Held( string path ) => parts.TryGetValue( path, out var held ) ? held : null;

	// The scopes among the gated-out ones holding a part that stands where this build re-compared.
	List<int> Escaped( IReadOnlySet<string> kept, ArchContactClosure closing )
	{
		var escapees = new List<int>();

		foreach ( var path in kept )
		{
			if ( !parts.TryGetValue( path, out var held ) || !closing.Meets( held.Reach ) )
			{
				continue;
			}

			if ( ArchNames.TrySourceId( Segment( path ), out var scope ) )
			{
				escapees.Add( scope );
			}
		}

		return escapees;
	}

	// Every path filed under a scope the gate left standing. Their records stay, their extents stay, and the prune
	// is told to keep the nodes - none of it was this build's business.
	IReadOnlySet<string> Kept( IReadOnlyList<string> segments )
	{
		var kept = new HashSet<string>();

		if ( segments.Count == 0 )
		{
			return kept;
		}

		var standing = segments.ToHashSet();

		foreach ( var path in parts.Keys )
		{
			if ( standing.Contains( Segment( path ) ) )
			{
				kept.Add( path );
			}
		}

		return kept;
	}

	static string Segment( string path )
	{
		var end = path.IndexOf( '/' );

		return end < 0 ? path : path[..end];
	}

	// What the contact pass LEFT: the surviving face handles, folded onto the emitted key. Two builds that emitted
	// the same geometry and lost the same faces to their neighbours are the same part.
	static ulong Surviving( ArchBuiltPart part, ulong content )
	{
		var mesh = part.Canvas.Finish();
		var hash = content;

		foreach ( var handle in mesh.FaceHandles )
		{
			hash = ArchHash.Fold( hash, handle.Index );
		}

		return hash;
	}

	void Record( string path, ulong content, ulong settled, Dictionary<(long, long, long, long), BBox> reach )
	{
		if ( !parts.TryGetValue( path, out var held ) )
		{
			held = parts[path] = new ArchBuiltRecord();
		}

		held.Content = content;
		held.Settled = settled;

		if ( reach is null || ReferenceEquals( reach, held.Reach ) )
		{
			return;
		}

		Unfile( path, held.Reach );

		held.Reach = reach;

		foreach ( var plane in reach.Keys )
		{
			(byPlane.TryGetValue( plane, out var into ) ? into : byPlane[plane] = new HashSet<string>()).Add( path );
		}
	}

	void Drop( string path )
	{
		if ( !parts.TryGetValue( path, out var held ) )
		{
			return;
		}

		Unfile( path, held.Reach );

		parts.Remove( path );
	}

	void Unfile( string path, Dictionary<(long, long, long, long), BBox> reach )
	{
		foreach ( var plane in reach.Keys )
		{
			if ( byPlane.TryGetValue( plane, out var standing ) && standing.Remove( path ) && standing.Count == 0 )
			{
				byPlane.Remove( plane );
			}
		}
	}
}

// Which parts the contact pass still has to compare, closed over "stands in the same plane, near enough to touch".
// A part outside it shares no reachable plane with anything inside, so it could never have decided one of these
// faces and none of these could decide one of its - which is what makes the subset pass agree with a whole-plan one.
sealed class ArchContactClosure
{
	readonly ArchBuildCache cache;
	readonly Dictionary<int, List<ArchContactFace>> faces = new();
	readonly Dictionary<int, Dictionary<(long, long, long, long), BBox>> reaches = new();
	readonly Dictionary<(long, long, long, long), BBox> touched = new();
	readonly Queue<(long, long, long, long)> frontier = new();

	public ArchContactClosure( ArchBuildCache cache )
	{
		this.cache = cache;
	}

	public int Count => faces.Count;

	public bool Holds( int index ) => faces.ContainsKey( index );

	public Dictionary<(long, long, long, long), BBox> Reach( int index )
	{
		return reaches.TryGetValue( index, out var held ) ? held : null;
	}

	public List<ArchContactFace> Faces()
	{
		return faces.Keys.OrderBy( index => index ).SelectMany( index => faces[index] ).ToList();
	}

	public void Enrol( IReadOnlyList<ArchBuiltPart> built, int index )
	{
		if ( faces.ContainsKey( index ) )
		{
			return;
		}

		var collected = ArchMeshContactService.Faces( built[index], index ).ToList();
		var reach = new Dictionary<(long, long, long, long), BBox>();

		foreach ( var face in collected )
		{
			var bounds = ArchMeshContactService.Bounds( face );

			reach[face.Plane] = reach.TryGetValue( face.Plane, out var held ) ? Union( held, bounds ) : bounds;
		}

		faces[index] = collected;
		reaches[index] = reach;

		Sight( reach );
	}

	// Growing an extent can bring in a part the smaller one did not reach, so a plane whose extent grew is queued
	// again. Extents only ever grow and there are finitely many parts, so this settles.
	public void Sight( Dictionary<(long, long, long, long), BBox> reach )
	{
		foreach ( var entry in reach )
		{
			if ( touched.TryGetValue( entry.Key, out var held ) )
			{
				var grown = Union( held, entry.Value );

				if ( grown.Mins == held.Mins && grown.Maxs == held.Maxs )
				{
					continue;
				}

				touched[entry.Key] = grown;
			}
			else
			{
				touched[entry.Key] = entry.Value;
			}

			frontier.Enqueue( entry.Key );
		}
	}

	public void Close( IReadOnlyList<ArchBuiltPart> built )
	{
		var byPath = new Dictionary<string, int>();

		for ( var index = 0; index < built.Count; index++ )
		{
			byPath[built[index].Path] = index;
		}

		while ( frontier.Count > 0 )
		{
			var plane = frontier.Dequeue();

			if ( !touched.TryGetValue( plane, out var extent ) )
			{
				continue;
			}

			foreach ( var path in cache.Standing( plane ) )
			{
				if ( !byPath.TryGetValue( path, out var index ) || faces.ContainsKey( index ) )
				{
					continue;
				}

				if ( cache.Held( path )?.Reach.TryGetValue( plane, out var held ) == true
					&& ArchMeshContactService.Touches( extent, held ) )
				{
					Enrol( built, index );
				}
			}
		}
	}

	// Whether a part nobody generated stands where the pass is deciding - the question the gate's escape asks.
	public bool Meets( Dictionary<(long, long, long, long), BBox> reach )
	{
		foreach ( var entry in reach )
		{
			if ( touched.TryGetValue( entry.Key, out var extent ) && ArchMeshContactService.Touches( extent, entry.Value ) )
			{
				return true;
			}
		}

		return false;
	}

	static BBox Union( BBox left, BBox right )
	{
		return new BBox( Vector3.Min( left.Mins, right.Mins ), Vector3.Max( left.Maxs, right.Maxs ) );
	}
}