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