Editor/Output/ArchBuildCache.cs
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
sealed class ArchBuiltRecord {
public string Path { get; set; }
public ulong Content { get; set; }
public ulong Settled { get; set; }
public ulong Settings { get; set; }
public Dictionary<(long, long, long, long), BBox> Reach { get; set; } = new();
}
public sealed class ArchBuildSettlement {
public IReadOnlySet<int> Write { get; init; }
// Settings changed but geometry unchanged — take flags, keep model
public IReadOnlySet<int> Dressed { get; init; } = new HashSet<int>();
// Gated-out parts — live but not this build's business; prune must not delete them
public IReadOnlySet<string> Kept { get; init; } = new HashSet<string>();
public int Reused { get; init; }
public int Contacted { get; init; }
// A gated scope hid half of a coplanar pair — caller must rebuild cold
public bool Escaped { get; init; }
}
// Filed by stable path (author names stripped) so renames don't invalidate the cache
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();
readonly Dictionary<int, ulong> interiors = new();
// Scopes whose gating hid a coplanar pair — refused permanently to prevent repeat escapes
readonly HashSet<int> refused = new();
public int Count => parts.Count;
public ArchBuildSettlement Last { get; private set; }
public void Forget() {
Restart();
refused.Clear();
}
public void Restart() {
parts.Clear();
byPlane.Clear();
scopes.Clear();
interiors.Clear();
}
internal bool Lined( int wallId, ulong content ) {
if ( interiors.TryGetValue( wallId, out var held ) && held == content ) {
return true;
}
interiors[wallId] = content;
return false;
}
internal void Unlined( int wallId ) => interiors.Remove( wallId );
public ArchBuildGate Gate( ArchPlan plan, ArchKit kit, int terrain, IReadOnlySet<int> rebuild = null ) {
return ArchLayerHash.Worth( plan ) || rebuild is not null
? new ArchBuildGate( plan, kit, terrain, scopes, refused, rebuild )
: null;
}
// Null cache = cold build (everything compared and written)
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 ArchFaceOverlap().RemoveCoveredFaces( 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 ArchOverlapClosure( this );
var keys = new string[built.Count];
for ( var index = 0; index < built.Count; index++ ) {
keys[index] = ArchNames.Stable( built[index].Path );
}
for ( var index = 0; index < built.Count; index++ ) {
if ( parts.TryGetValue( keys[index], out var held ) && held.Content == built[index].Key ) {
continue;
}
closing.Enrol( built, index );
if ( held is not null ) {
closing.Sight( held.Reach );
}
}
var emitted = keys.ToHashSet();
foreach ( var gone in parts.Where( entry => !emitted.Contains( entry.Key ) && !kept.Contains( entry.Key ) ) ) {
closing.Sight( gone.Value.Reach );
}
closing.Close( built, keys );
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 ArchFaceOverlap().Resolve( closing.Faces() );
var write = new HashSet<int>();
var dressed = new HashSet<int>();
for ( var index = 0; index < built.Count; index++ ) {
var content = built[index].Key;
parts.TryGetValue( keys[index], 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 );
} else if ( held.Settings != built[index].Settings ) {
dressed.Add( index );
}
Record( keys[index], built[index].Path, content, settled, built[index].Settings, 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,
Dressed = dressed,
Kept = Standing( kept ),
Reused = built.Count - write.Count,
Contacted = closing.Count
};
}
// Converts stable keys back to scene paths for the prune
IReadOnlySet<string> Standing( IReadOnlySet<string> kept ) {
var standing = new HashSet<string>();
foreach ( var key in kept ) {
if ( parts.TryGetValue( key, out var held ) && held.Path is not null ) {
standing.Add( held.Path );
}
}
return standing;
}
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;
List<int> Escaped( IReadOnlySet<string> kept, ArchOverlapClosure 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;
}
IReadOnlySet<string> Kept( IReadOnlyList<string> segments ) {
var kept = new HashSet<string>();
if ( segments.Count == 0 ) {
return kept;
}
var standing = segments.Select( ArchNames.Stable ).ToList();
foreach ( var path in parts.Keys ) {
if ( standing.Any( segment => path == segment || path.StartsWith( $"{segment}/" ) ) ) {
kept.Add( path );
}
}
return kept;
}
static string Segment( string path ) {
var end = path.IndexOf( '/' );
return end < 0 ? path : path[..end];
}
// Folds surviving face handles onto the emitted key — same geometry + same culled faces = 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 key, string path, ulong content, ulong settled, ulong settings, Dictionary<(long, long, long, long), BBox> reach ) {
if ( !parts.TryGetValue( key, out var held ) ) {
held = parts[key] = new ArchBuiltRecord();
}
held.Path = path;
held.Content = content;
held.Settled = settled;
held.Settings = settings;
if ( reach is null || ReferenceEquals( reach, held.Reach ) ) {
return;
}
Unfile( key, held.Reach );
held.Reach = reach;
foreach ( var plane in reach.Keys ) {
(byPlane.TryGetValue( plane, out var into ) ? into : byPlane[plane] = new HashSet<string>()).Add( key );
}
}
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 );
}
}
}
}
// Incrementally grows the set of parts the contact pass must re-compare via plane adjacency
sealed class ArchOverlapClosure {
readonly ArchBuildCache cache;
readonly Dictionary<int, List<ArchOverlapFace>> 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 ArchOverlapClosure( 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<ArchOverlapFace> 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 = ArchFaceOverlap.Faces( built[index], index ).ToList();
var reach = new Dictionary<(long, long, long, long), BBox>();
foreach ( var face in collected ) {
var bounds = ArchFaceOverlap.Bounds( face );
reach[face.Plane] = reach.TryGetValue( face.Plane, out var held ) ? Union( held, bounds ) : bounds;
}
faces[index] = collected;
reaches[index] = reach;
Sight( reach );
}
// Grown extents are re-queued — monotonic growth guarantees convergence
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, string[] keys ) {
var byPath = new Dictionary<string, int>();
for ( var index = 0; index < built.Count; index++ ) {
byPath[keys[index]] = 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
&& ArchFaceOverlap.Touches( extent, held ) ) {
Enrol( built, index );
}
}
}
}
public bool Meets( Dictionary<(long, long, long, long), BBox> reach ) {
foreach ( var entry in reach ) {
if ( touched.TryGetValue( entry.Key, out var extent ) && ArchFaceOverlap.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 ) );
}
}