editor/AdvancedMappingTools/Core/LineSnap.cs

Editor helper that snap-to-lines new path points onto already finished path objects. Rebuilds each finished object's line from its BuilderRecord (sampled curve), caches it per scene with a half second refresh, and answers snap queries, endpoint queries, side hits, top corners and reversed point lists for the path and trench tools.

using System.Globalization;

namespace AdvancedMappingTools;
/// <summary>
/// The lines of finished objects from path tools. Those are plain meshes, what they were drawn
/// along is only known from their records. New points snap onto these lines, on their ends
/// first, and a new line of the same kind takes a finished one it touches into itself, so the
/// corner between them is cut like any other corner.
/// </summary>
public static class LineSnap
{
	const float RefreshSeconds = 0.5f;
	const float CurveStep = 16.0f;
	const float SameSpot = 0.5f;

	/// <summary>
	/// Settings of how a tool places points. They say nothing about the object itself.
	/// </summary>
	static readonly HashSet<string> PlacingOnly = [nameof( BuilderEditor.DockToVertices ), nameof( BuilderEditor.DockRadius ), nameof( BuilderEditor.ConnectFinished ), nameof( PathEditor.StackOnTop ), nameof( TrenchEditor.Branches )];

	sealed record Known( BuilderRecord Record, Vector3[] Line, float Height, float Reach );

	static readonly Dictionary<Guid, Known> Records = [];
	static readonly List<(GameObject Owner, Known Line)> Lines = [];
	static Scene _scene;
	static RealTimeSince _age;

	/// <summary>
	/// A record changed, read it again next time.
	/// </summary>
	public static void Forget( Guid id )
	{
		Records.Remove( id );
		_scene = null;
	}

	static float Number( BuilderRecord record, string name ) =>
		record.Settings.TryGetValue( name, out var text ) && float.TryParse( text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) ? value : 0.0f;

	static Vector3 Origin( BuilderRecord record ) => new( record.Origin[0], record.Origin[1], record.Origin[2] );

	static List<PathPoint> Placed( BuilderRecord record, Transform world )
	{
		var origin = Origin( record );

		return [.. record.Points.Select( x => new PathPoint(
			world.PointToWorld( new Vector3( x[0], x[1], x[2] ) - origin ),
			x.Length >= 6 ? world.PointToWorld( new Vector3( x[3], x[4], x[5] ) - origin ) : null ) )];
	}

	static Known Read( Guid id )
	{
		if ( Records.TryGetValue( id, out var known ) ) return known;

		var record = BuilderStore.LoadRecord( id );

		// Only tools that follow a line have one to snap to.
		if ( record is not null && record.Points.Count >= 2 && ToolCatalog.Tools.Any( x => x.Name == record.Type && x.TargetType.IsAssignableTo( typeof( PathEditor ) ) ) )
		{
			var smooth = record.Settings.TryGetValue( nameof( PathEditor.Smooth ), out var flag ) && flag == "true";
			var points = Placed( record, new Transform( Origin( record ) ) );
			var line = PathEditor.CurveOf( points, record.Closed, smooth ).Sample( CurveStep ).Select( x => x.Position ).ToArray();
			var across = MathF.Max( Number( record, "Thickness" ), Number( record, "Width" ) );

			known = new Known( record, line, Number( record, "Height" ), across * 0.5f );
		}

		Records[id] = known;

		return known;
	}

	static List<(GameObject Owner, Known Line)> All( Scene scene )
	{
		if ( scene == _scene && _age < RefreshSeconds ) return Lines;

		_scene = scene;
		_age = 0.0f;
		Lines.Clear();

		foreach ( var mesh in scene.GetAllComponents<MeshComponent>() )
		{
			var line = Read( mesh.GameObject.Id );

			if ( line is not null ) Lines.Add( (mesh.GameObject, line) );
		}

		return Lines;
	}

	/// <summary>
	/// Put a point on the nearest line of a finished object. With <paramref name = "stack"/> a point
	/// near the top of an object lands on that top, so the next one stands on it.
	/// </summary>
	public static Vector3 Snap( Scene scene, Vector3 point, float radius, bool stack ) => TrySnap( scene, point, radius, stack, out var result ) ? result : point;

	/// <summary>Compare both object systems against the original cursor, so neither lowers it first.</summary>
	public static Vector3 SnapToFinished( Scene scene, Vector3 point, float radius, bool stack ) =>
		TrySnapToFinished( scene, point, radius, stack, out var snapped ) ? snapped : point;

	/// <summary>The nearest finished line or wall in reach of <paramref name="point"/>, on its centre line.</summary>
	public static bool TrySnapToFinished( Scene scene, Vector3 point, float radius, bool stack, out Vector3 snapped )
	{
		var hasLine = TrySnap( scene, point, radius, stack, out var line );
		var hasWall = WallLinker.TrySnap( scene, point, radius, stack, out var wall );
		snapped = hasWall && (!hasLine || wall.Distance( point ) <= line.Distance( point )) ? wall : hasLine ? line : point;
		if ( !hasLine && !hasWall )
			return false;

		// A corner in reach wins over the side of the wall next to it: a click on a corner means the
		// corner, also when the walls stand at an angle and the click is a little beside it.
		var best = radius;
		var level = snapped.z;
		foreach ( var corner in WallTops.All( scene ) )
		{
			var spot = MathF.Abs( corner.Top.z - level ) < MathF.Abs( corner.Foot.z - level ) ? corner.Top : corner.Foot;
			var distance = spot.WithZ( 0.0f ).Distance( point.WithZ( 0.0f ) );
			if ( MathF.Abs( spot.z - level ) > 0.5f || distance >= best )
				continue;
			best = distance;
			snapped = spot;
		}

		return true;
	}

	public static bool TrySnap( Scene scene, Vector3 point, float radius, bool stack, out Vector3 snapped )
	{
		var flat = point.WithZ( 0.0f );
		var best = float.MaxValue;
		var bestEnd = float.MaxValue;
		var result = point;
		var endResult = point;
		float Level( Vector3 on, float height )
		{
			var bottom = MathF.Min( on.z, on.z + height );
			var top = MathF.Max( on.z, on.z + height );
			var nearTop = height != 0.0f && MathF.Abs( point.z - top ) < MathF.Abs( point.z - bottom );
			return stack && nearTop ? top : bottom;
		}

		foreach ( var(owner, known)in All( scene ) )
		{
			if ( !owner.IsValid() )
				continue;
			var origin = Origin( known.Record );
			var world = owner.WorldTransform;
			var reach = radius + known.Reach * MathF.Max( MathF.Abs( world.Scale.x ), MathF.Abs( world.Scale.y ) );
			var worldHeight = world.PointToWorld( Vector3.Up * known.Height ).z - world.Position.z;
			var points = known.Line.Select( x => world.PointToWorld( x - origin ) ).ToArray();
			// Any end in reach wins over the side of a line, it is what a line carries on from.
			if ( !known.Record.Closed )
			{
				foreach ( var end in new[]
				{
					points[0],
					points[^1]
				}

	 )
				{
					var horizontal = end.WithZ( 0.0f ).Distance( flat );
					var candidate = end.WithZ( Level( end, worldHeight ) );
					var distance = candidate.Distance( point );
					if ( horizontal > reach || distance >= bestEnd )
						continue;
					bestEnd = distance;
					endResult = candidate;
				}
			}

			var steps = known.Record.Closed ? points.Length : points.Length - 1;
			for ( var i = 0; i < steps; i++ )
			{
				var a = points[i];
				var b = points[(i + 1) % points.Length];
				var along = (b - a).WithZ( 0.0f );
				var t = along.LengthSquared > 0.0f ? Math.Clamp( (flat - a.WithZ( 0.0f )).Dot( along ) / along.LengthSquared, 0.0f, 1.0f ) : 0.0f;
				var on = Vector3.Lerp( a, b, t );
				var horizontal = on.WithZ( 0.0f ).Distance( flat );
				var candidate = on.WithZ( Level( on, worldHeight ) );
				var distance = candidate.Distance( point );
				if ( horizontal > reach || distance >= best )
					continue;
				best = distance;
				result = candidate;
			}
		}

		// Keep endpoint preference on the same level, but never let a lower endpoint
		// override a closer upper segment just because their XY coordinates overlap.
		var preferEnd = bestEnd < float.MaxValue && (best == float.MaxValue || bestEnd <= best || MathF.Abs( endResult.z - point.z ) <= MathF.Abs( result.z - point.z ) + 0.01f);
		snapped = preferEnd ? endResult : result;
		return bestEnd < float.MaxValue || best < float.MaxValue;
	}

	/// <summary>
	/// A finished object of this kind whose line runs through <paramref name="point"/> away from
	/// its ends: where a new line meets it from the side.
	/// </summary>
	public static (GameObject Owner, BuilderRecord Record, Vector3 On, Vector3 Along)? Side( Scene scene, Vector3 point, string type )
	{
		foreach ( var (owner, known) in All( scene ) )
		{
			if ( !owner.IsValid() || known.Record.Type != type ) continue;

			var origin = Origin( known.Record );
			var world = owner.WorldTransform;
			var points = known.Line.Select( x => world.PointToWorld( x - origin ) ).ToArray();

			if ( !known.Record.Closed && (points[0].Distance( point ) <= SameSpot || points[^1].Distance( point ) <= SameSpot) ) continue;

			var steps = known.Record.Closed ? points.Length : points.Length - 1;

			for ( var i = 0; i < steps; i++ )
			{
				var a = points[i];
				var along = points[(i + 1) % points.Length] - a;
				var t = along.LengthSquared > 0.0f ? Math.Clamp( (point - a).Dot( along ) / along.LengthSquared, 0.0f, 1.0f ) : 0.0f;
				var on = a + along * t;

				if ( on.Distance( point ) <= SameSpot ) return (owner, known.Record, on, along.Normal);
			}
		}

		return null;
	}

	/// <summary>
	/// The drawn points of finished objects that have a height, at the height of their top.
	/// </summary>
	public static IEnumerable<WallTops.Corner> Corners( Scene scene )
	{
		foreach ( var(owner, known)in All( scene ).ToList() )
		{
			if ( !owner.IsValid() || known.Height <= 0.0f )
				continue;
			var world = owner.WorldTransform;
			var delta = world.PointToWorld( Vector3.Up * known.Height ) - world.Position;
			var half = known.Reach * MathF.Max( MathF.Abs( world.Scale.x ), MathF.Abs( world.Scale.y ) );
			foreach ( var point in Placed( known.Record, world ) )
			{
				var end = point.Position + delta;
				yield return point.Position.z <= end.z ? new WallTops.Corner( point.Position, end, half ) : new WallTops.Corner( end, point.Position, half );
			}
		}
	}

	/// <summary>
	/// The open end of a finished object of one of these kinds near <paramref name = "point"/>,
	/// with its width, or null. A bridge starts where a road stops.
	/// </summary>
	public static (Vector3 End, float Width)? NearestEnd( Scene scene, Vector3 point, float radius, IReadOnlySet<string> types )
	{
		(Vector3 End, float Width)? best = null;
		var nearest = float.MaxValue;
		foreach ( var(owner, known)in All( scene ) )
		{
			if ( !owner.IsValid() || known.Record.Closed || !types.Contains( known.Record.Type ) )
				continue;
			var points = Placed( known.Record, owner.WorldTransform );
			foreach ( var end in new[]
			{
				points[0].Position,
				points[^1].Position
			}

	 )
			{
				var horizontal = end.WithZ( 0.0f ).Distance( point.WithZ( 0.0f ) );
				var distance = end.Distance( point );
				if ( horizontal > radius + known.Reach || distance >= nearest )
					continue;
				nearest = distance;
				best = (end, known.Reach * 2.0f);
			}
		}

		return best;
	}

	static bool Same( BuilderRecord record, string type, Dictionary<string, string> settings ) =>
		record.Type == type && settings.All( x => PlacingOnly.Contains( x.Key ) || record.Settings.GetValueOrDefault( x.Key ) == x.Value );

	/// <summary>
	/// The points of a finished object with the same settings that has an open end on
	/// <paramref name="point"/>, starting at that end. Null if there is none.
	/// </summary>
	public static List<PathPoint> Touching( Scene scene, Vector3 point, string type, Dictionary<string, string> settings, List<GameObject> taken )
	{
		foreach ( var (owner, known) in All( scene ) )
		{
			if ( !owner.IsValid() || taken.Contains( owner ) || known.Record.Closed || !Same( known.Record, type, settings ) ) continue;

			var points = Placed( known.Record, owner.WorldTransform );

			if ( points[0].Position.Distance( point ) <= SameSpot )
			{
				taken.Add( owner );
				return points;
			}

			if ( points[^1].Position.Distance( point ) <= SameSpot )
			{
				taken.Add( owner );
				return Reversed( points );
			}
		}

		return null;
	}

	/// <summary>
	/// The same line walked the other way. A bend belongs to the segment after its point.
	/// </summary>
	public static List<PathPoint> Reversed( List<PathPoint> points )
	{
		var result = new List<PathPoint>( points.Count );

		for ( var i = points.Count - 1; i >= 0; i-- )
			result.Add( new PathPoint( points[i].Position, i > 0 ? points[i - 1].Bend : null ) );

		return result;
	}
}