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