editor/AdvancedMappingTools/Framework/SurfaceSolver.cs

Editor-side surface probe solver for the Advanced Mapping Tools package. Raycasts the scene to fit parts to surfaces, with per-scene cache invalidation by scene fingerprint, nearest/farthest/highest/lowest selection, surface kind filtering, normal alignment and gizmo debug drawing.

using System.Runtime.CompilerServices;

namespace AdvancedMappingTools;
public enum SurfaceSelection
{
	Nearest,
	Farthest,
	Highest,
	Lowest
}

public enum SurfaceKind
{
	Any,
	Terrain,
	Foundation,
	Walkway,
	Other
}

public enum SurfaceStatus
{
	Hit,
	NoHit,
	Disabled,
	NoScene,
	InvalidInput,
	Manual,
	Rejected
}

public sealed class SurfaceProbeOptions
{
	public bool Enabled { get; set; } = true;
	public float MinimumDistance { get; set; } = .01f;
	public float MaximumDistance { get; set; } = 2048;
	public float Offset { get; set; }
	public SurfaceSelection Selection { get; set; } = SurfaceSelection.Nearest;
	public SurfaceKind Kind { get; set; }
	public string IncludeTags { get; set; } = "";
	public string ExcludeTags { get; set; } = "";
	public string SurfaceResource { get; set; } = "";
	public bool IgnoreStatic { get; set; }
	public bool IgnoreDynamic { get; set; }
	public bool IgnoreKeyframed { get; set; }
	public bool Cache { get; set; } = true;
	public bool Manual { get; set; }
	public float ManualDistance { get; set; } = 128;
	public bool OffsetAlongNormal { get; set; }
}

public sealed record SurfaceSample( SurfaceStatus Status, Vector3 Origin, Vector3 Direction, Vector3 Position, Vector3 Normal, float Distance, GameObject Object, SurfaceKind Kind, string Message )
{
	public bool Usable => Status is SurfaceStatus.Hit or SurfaceStatus.Manual;
}

/// <summary>Scene-bound read session. Fingerprint once per generation, never once per point.</summary>
public sealed class SurfaceReadSession
{
	readonly Scene scene;
	readonly GameObject[] ignore;
	readonly int revision;
	readonly SurfaceSolver.CacheState cache;
	internal SurfaceReadSession( Scene scene, GameObject[] ignore, int revision, SurfaceSolver.CacheState cache )
	{
		this.scene = scene;
		this.ignore = ignore;
		this.revision = revision;
		this.cache = cache;
	}

	public int Raycasts { get; private set; }
	public int CacheHits { get; private set; }
	public List<SurfaceSample> Samples { get; } = [];

	public SurfaceSample Probe( Vector3 origin, Vector3 direction, SurfaceProbeOptions options )
	{
		options ??= new();
		SurfaceSample Result( SurfaceStatus status, string message ) => new( status, origin, direction, status == SurfaceStatus.NoHit ? origin + direction * options.MaximumDistance : origin, Vector3.Up, 0, null, SurfaceKind.Other, message );
		SurfaceSample result;
		if ( !options.Enabled )
			result = Result( SurfaceStatus.Disabled, "Surface fitting is off." );
		else if ( scene is null )
			result = Result( SurfaceStatus.NoScene, "No scene context; original dimensions retained." );
		else if ( !SurfaceSolver.Finite( origin ) || !SurfaceSolver.Finite( direction ) || !float.IsFinite( direction.Length ) || direction.Length < .0001f || !float.IsFinite( options.MinimumDistance ) || !float.IsFinite( options.MaximumDistance ) || !float.IsFinite( options.Offset ) || options.MinimumDistance < 0 || options.MaximumDistance <= options.MinimumDistance || options.MaximumDistance > 100000 || !Enum.IsDefined( options.Selection ) || !Enum.IsDefined( options.Kind ) || !float.IsFinite( options.ManualDistance ) || options.ManualDistance < 0 || options.ManualDistance > 100000 || MathF.Abs( options.Offset ) > 100000 )
			result = Result( SurfaceStatus.InvalidInput, "Invalid surface range, direction or value; original dimensions retained." );
		else
		{
			direction = direction.Normal;
			if ( options.Manual )
			{
				var position = origin + direction * options.ManualDistance - direction * options.Offset;
				result = new( SurfaceStatus.Manual, origin, direction, position, -direction, options.ManualDistance, null, SurfaceKind.Other, "Manual support length; no raycast." );
			}
			else
			{
				var included = SurfaceSolver.Tags( options.IncludeTags );
				var excluded = SurfaceSolver.Tags( options.ExcludeTags );
				var key = string.Join( "|", revision, SurfaceSolver.Key( origin ), SurfaceSolver.Key( direction ), options.MinimumDistance, options.MaximumDistance, options.Offset, options.OffsetAlongNormal, options.Selection, options.Kind, string.Join( ",", included ), string.Join( ",", excluded ), options.SurfaceResource, options.IgnoreStatic, options.IgnoreDynamic, options.IgnoreKeyframed, string.Join( ",", ignore.Select( o => o.Id ) ) );
				if ( options.Cache && cache.Results.TryGetValue( key, out result ) && (result.Object is null || result.Object.IsValid()) )
					CacheHits++;
				else
				{
					var trace = scene.Trace.Ray( origin + direction * options.MinimumDistance, origin + direction * options.MaximumDistance );
					foreach ( var root in ignore )
						if ( root.IsValid() && root.Scene == scene )
							trace = trace.IgnoreGameObjectHierarchy( root );
					if ( included.Length > 0 )
						trace = trace.WithAnyTags( included );
					if ( excluded.Length > 0 )
						trace = trace.WithoutTags( excluded );
					if ( options.IgnoreStatic )
						trace = trace.IgnoreStatic();
					if ( options.IgnoreDynamic )
						trace = trace.IgnoreDynamic();
					if ( options.IgnoreKeyframed )
						trace = trace.IgnoreKeyframed();
					Raycasts++;
					var hits = trace.RunAll().Where( h => h.Hit && !h.StartedSolid && h.GameObject.IsValid() && SurfaceSolver.Finite( h.EndPosition ) && SurfaceSolver.Finite( h.Normal ) && h.Normal.Length > .01f ).Where( h => options.Kind == SurfaceKind.Any || SurfaceSolver.KindOf( h.GameObject ) == options.Kind ).Where( h => string.IsNullOrWhiteSpace( options.SurfaceResource ) || h.Surface?.ResourcePath == options.SurfaceResource ).ToArray();
					var ordered = options.Selection switch
					{
						SurfaceSelection.Farthest => hits.OrderByDescending( h => h.EndPosition.Distance( origin ) ),
						SurfaceSelection.Highest => hits.OrderByDescending( h => h.EndPosition.z ),
						SurfaceSelection.Lowest => hits.OrderBy( h => h.EndPosition.z ),
						_ => hits.OrderBy( h => h.EndPosition.Distance( origin ) )};
					if ( hits.Length == 0 )
						result = Result( SurfaceStatus.NoHit, "No matching surface in the search range; original dimensions retained." );
					else
					{
						var hit = ordered.ThenBy( h => h.GameObject.Id ).First();
						var normal = hit.Normal.Normal;
						var position = hit.EndPosition + (options.OffsetAlongNormal ? normal : -direction) * options.Offset;
						result = new( SurfaceStatus.Hit, origin, direction, position, normal, hit.EndPosition.Distance( origin ), hit.GameObject, SurfaceSolver.KindOf( hit.GameObject ), "Surface found." );
					}

					if ( options.Cache )
					{
						if ( cache.Results.Count >= 4096 )
							cache.Results.Clear();
						cache.Results[key] = result;
					}
				}
			}
		}

		Samples.Add( result );
		return result;
	}

	/// <summary>Validate a hit against the consuming support's local upper connection. Rejection is session-local; cached ray results remain unchanged.</summary>
	public bool AcceptBelow( SurfaceSample sample, float candidateHeight, float topHeight )
	{
		if ( !sample.Usable )
			return false;
		if ( float.IsFinite( candidateHeight ) && float.IsFinite( topHeight ) && candidateHeight < topHeight - .01f )
			return true;
		for ( var i = Samples.Count - 1; i >= 0; i-- )
			if ( ReferenceEquals( Samples[i], sample ) )
			{
				Samples[i] = sample with
				{
					Status = SurfaceStatus.Rejected,
					Message = "Target is not below the support connection; original dimensions retained."
				};
				break;
			}

		return false;
	}

	public IReadOnlyList<SurfaceSample> ProbePath( IEnumerable<Vector3> points, Vector3 direction, SurfaceProbeOptions options ) => points.Select( p => Probe( p, direction, options ) ).ToArray();
	public float HeightSpread => Samples.Where( s => s.Usable ).Select( s => s.Position.z ).DefaultIfEmpty( 0 ).Max() - Samples.Where( s => s.Usable ).Select( s => s.Position.z ).DefaultIfEmpty( 0 ).Min();
}

public static class SurfaceSolver
{
	internal sealed class CacheState
	{
		public int Revision;
		public Dictionary<string, SurfaceSample> Results { get; } = [];
	}

	static readonly ConditionalWeakTable<Scene, CacheState> States = new();
	public static bool Finite( Vector3 v ) => float.IsFinite( v.x ) && float.IsFinite( v.y ) && float.IsFinite( v.z );
	internal static string Key( Vector3 v ) => $"{BitConverter.SingleToInt32Bits( v.x )},{BitConverter.SingleToInt32Bits( v.y )},{BitConverter.SingleToInt32Bits( v.z )}";
	internal static string[] Tags( string text ) => (text ?? "").Split( [',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ).Distinct( StringComparer.Ordinal ).OrderBy( t => t ).ToArray();
	public static SurfaceKind KindOf( GameObject obj )
	{
		for ( var go = obj; go.IsValid(); go = go.Parent )
		{
			if ( go.Components.Get<Terrain>( FindMode.EverythingInSelf ) != null || go.Tags.Has( "amt.terrain" ) )
				return SurfaceKind.Terrain;
			if ( go.Tags.Has( "amt.foundation" ) )
				return SurfaceKind.Foundation;
			if ( go.Tags.Has( "amt.walkway" ) )
				return SurfaceKind.Walkway;
			var part = go.Components.Get<AmtPart>( FindMode.EverythingInSelf );
			if ( part?.Category == "sidewalk" || part?.Kind is "Sidewalk" or "Catwalk" )
				return SurfaceKind.Walkway;
			if ( part?.Kind is "Foundation" or "Footing" )
				return SurfaceKind.Foundation;
		}

		return SurfaceKind.Other;
	}

	public static void Invalidate( Scene scene )
	{
		if ( scene != null )
			States.Remove( scene );
	}

	static bool Under( GameObject obj, GameObject[] roots )
	{
		for ( var p = obj; p.IsValid(); p = p.Parent )
			if ( roots.Contains( p ) )
				return true;
		return false;
	}

	// Surface queries must notice sub-grid edits and material reassignment. PartHash intentionally
	// quantizes geometry for hand-edit reconciliation, so it is not a suitable ray-cache revision.
	static void MeshSignature( ref HashCode hash, PolygonMesh mesh )
	{
		hash.Add( mesh.Transform );
		var vertexCount = 0;
		foreach ( var vertex in mesh.VertexHandles )
		{
			hash.Add( vertex );
			var p = mesh.GetVertexPosition( vertex );
			hash.Add( BitConverter.SingleToInt32Bits( p.x ) );
			hash.Add( BitConverter.SingleToInt32Bits( p.y ) );
			hash.Add( BitConverter.SingleToInt32Bits( p.z ) );
			vertexCount++;
		}

		hash.Add( vertexCount );
		var faceCount = 0;
		foreach ( var face in mesh.FaceHandles )
		{
			hash.Add( face );
			var count = 0;
			foreach ( var vertex in mesh.GetFaceVertices( face ) )
			{
				hash.Add( vertex );
				count++;
			}

			hash.Add( count );
			hash.Add( mesh.GetFaceMaterial( face )?.ResourcePath );
			faceCount++;
		}

		hash.Add( faceCount );
	}

	static int Signature( Scene scene, GameObject[] ignore )
	{
		// Every scene object is visited once. Restrict component queries to its owner;
		// the default hierarchy search repeats ancestor components for their descendants.
		var hash = new HashCode();
		foreach ( var go in scene.GetAllObjects( true ).Where( g => g.IsValid() && !g.IsDestroyed ).OrderBy( g => g.Id ) )
		{
			if ( Under( go, ignore ) )
				continue;
			var meshes = go.Components.GetAll<MeshComponent>( FindMode.EverythingInSelf ).Where( m => m.Mesh != null && m.Collision != MeshComponent.CollisionType.None ).ToArray();
			var colliders = go.Components.GetAll<Collider>( FindMode.EverythingInSelf ).ToArray();
			var terrain = go.Components.Get<Terrain>( FindMode.EverythingInSelf );
			if ( meshes.Length == 0 && colliders.Length == 0 && terrain == null )
				continue;
			hash.Add( go.Id );
			hash.Add( go.Active );
			hash.Add( go.WorldTransform );
			hash.Add( KindOf( go ) );
			for ( var ancestor = go; ancestor.IsValid(); ancestor = ancestor.Parent )
			{
				hash.Add( ancestor.Id );
				foreach ( var tag in ancestor.Tags.OrderBy( t => t ) )
					hash.Add( tag );
			}

			foreach ( var mesh in meshes )
			{
				hash.Add( mesh.Active );
				hash.Add( mesh.Collision );
				MeshSignature( ref hash, mesh.Mesh );
			}

			foreach ( var c in colliders )
			{
				hash.Add( c.GetType() );
				hash.Add( c.Active );
				hash.Add( c.IsTrigger );
				hash.Add( c.LocalBounds );
				foreach ( var p in c.GetType().GetProperties().Where( p => p.CanRead && p.GetIndexParameters().Length == 0 && p.IsDefined( typeof(PropertyAttribute), true ) ) )
				{
					try
					{
						var value = p.GetValue( c );
						hash.Add( value?.ToString() );
						if ( value is Resource resource )
							hash.Add( resource.GetType().GetProperty( "ResourceVersion" )?.GetValue( resource ) );
					}
					catch
					{
					}
				}
			}

			if ( terrain?.Storage is { } storage )
			{
				hash.Add( storage.ResourceVersion );
				hash.Add( storage.Resolution );
				hash.Add( storage.TerrainSize );
				hash.Add( storage.TerrainHeight );
				if ( storage.HeightMap != null )
					foreach ( var h in storage.HeightMap )
						hash.Add( h );
				if ( storage.ControlMap != null )
					foreach ( var h in storage.ControlMap )
						hash.Add( h );
			}
		}

		return hash.ToHashCode();
	}

	public static SurfaceReadSession Begin( Scene scene, IEnumerable<GameObject> ignore = null, bool forceRefresh = false )
	{
		var roots = ignore?.Where( o => o.IsValid() ).Distinct().ToArray() ?? [];
		if ( scene == null )
			return new( null, roots, 0, new() );
		var revision = Signature( scene, roots );
		var state = States.GetValue( scene, _ => new() );
		if ( forceRefresh || state.Revision != revision )
		{
			state.Results.Clear();
			state.Revision = revision;
		}

		return new( scene, roots, revision, state );
	}

	public static Rotation AlignToNormal( Rotation rotation, Vector3 normal )
	{
		if ( !Finite( normal ) || normal.Length < .001f )
			return rotation;
		normal = normal.Normal;
		var forward = rotation.Forward - normal * rotation.Forward.Dot( normal );
		if ( forward.Length < .001f )
			forward = rotation.Left - normal * rotation.Left.Dot( normal );
		return Rotation.LookAt( forward.Normal, normal );
	}
}

public sealed record SurfaceBuildContext( SurfaceReadSession Read, Transform World )
{
	public SurfaceSample Ground( Vector3 local, SurfaceProbeOptions options ) => Read.Probe( World.PointToWorld( local ), Vector3.Down, options );
}

public static class SurfaceDebug
{
	public sealed record Mark( Vector3 Origin, Vector3 Position, Vector3 Normal, SurfaceStatus Status );
	static readonly Dictionary<string, (string Summary, Mark[] Marks)> Last = [];
	public static void Record( string owner, SurfaceReadSession read )
	{
		Last[owner] = ($"{read.Samples.Count( s => s.Usable )}/{read.Samples.Count} usable surface measurements; {read.Samples.Count( s => s.Status == SurfaceStatus.Rejected )} rejected targets; {read.Raycasts} rays, {read.CacheHits} cache hits; height spread {read.HeightSpread:0.##}.", read.Samples.Select( s => new Mark( s.Origin, s.Position, s.Normal, s.Status ) ).ToArray());
	}

	public static void Note( string owner, string message )
	{
		Last[owner] = (message, []);
	}

	public static string Summary( string owner ) => Last.TryGetValue( owner, out var value ) ? value.Summary : "No surface measurements yet.";
	public static void Draw( string owner )
	{
		if ( !Last.TryGetValue( owner, out var value ) )
			return;
		foreach ( var mark in value.Marks )
		{
			if ( !SurfaceSolver.Finite( mark.Origin ) || !SurfaceSolver.Finite( mark.Position ) || !SurfaceSolver.Finite( mark.Normal ) )
				continue;
			Gizmo.Draw.Color = mark.Status is SurfaceStatus.Hit or SurfaceStatus.Manual ? Color.Green : Color.Red;
			Gizmo.Draw.Line( mark.Origin, mark.Position );
			Gizmo.Draw.SolidSphere( mark.Position, 2 );
			Gizmo.Draw.Line( mark.Position, mark.Position + mark.Normal * 12 );
		}
	}
}