editor/AdvancedMappingTools/Procedural/Sweep.cs

Editor-side procedural sweep helper for Advanced Mapping Tools. It builds orthonormal frames along 2D/3D paths (upright or parallel transport, with roll, width/height and miter stretch at corners), then sweeps cross-sections through those frames with MeshKit, plus blending, slicing and track side-line variants.

namespace AdvancedMappingTools;

public enum FrameMode
{
	[Title( "Upright" )] Upright,
	[Title( "Parallel Transport" )] ParallelTransport
}

/// <summary>
/// One station along a path: where it is and which way is forward, left and up.
/// <see cref="Stretch"/> widens the section at a corner so walls keep their thickness.
/// <see cref="Width"/> and <see cref="Height"/> scale what is built here, as set on the path.
/// </summary>
public readonly record struct PathFrame( Vector3 Position, Vector3 Forward, Vector3 Left, Vector3 Up, float Distance, float Stretch, float Width = 1.0f, float Height = 1.0f )
{
	public Vector3 Place( float left, float up, float forward = 0.0f ) =>
		Position + Left * (left * Stretch * Width) + Up * (up * Height) + Forward * forward;
}

/// <summary>
/// Turns a 3D path into stable frames.
/// </summary>
public static class PathFrames
{
	const float MaxStretch = 4.0f;

	/// <summary>
	/// Cut every stretch of a path into pieces no longer than <paramref name="step"/>. Corners stay where they are.
	/// </summary>
	public static List<Vector3> Resample( IReadOnlyList<Vector3> path, bool closed, float step )
	{
		var result = new List<Vector3>();
		var segments = closed ? path.Count : path.Count - 1;

		for ( var i = 0; i < segments; i++ )
		{
			var a = path[i];
			var b = path[(i + 1) % path.Count];
			var count = (int)MathF.Max( 1.0f, MathF.Ceiling( a.Distance( b ) / MathF.Max( step, 0.01f ) ) );

			for ( var s = 0; s < count; s++ )
				result.Add( Vector3.Lerp( a, b, s / (float)count ) );
		}

		if ( !closed ) result.Add( path[^1] );

		return result;
	}

	/// <summary>
	/// Frames along sampled points of a path, with their width, height and roll.
	/// </summary>
	public static List<PathFrame> Build( IReadOnlyList<PathSample> samples, bool closed, FrameMode mode )
	{
		var frames = Build( [.. samples.Select( x => x.Position )], closed, mode );

		for ( var i = 0; i < frames.Count; i++ )
		{
			var sample = samples[i % samples.Count];
			var frame = frames[i];
			var angle = sample.Roll.DegreeToRadian();
			var cos = MathF.Cos( angle );
			var sin = MathF.Sin( angle );

			frames[i] = frame with
			{
				Left = (frame.Left * cos + frame.Up * sin).Normal,
				Up = (frame.Up * cos - frame.Left * sin).Normal,
				Width = sample.Width,
				Height = sample.Height
			};
		}

		return frames;
	}

	/// <summary>
	/// Frames along a path. A closed path gets its first frame again at the end.
	/// </summary>
	public static List<PathFrame> Build( IReadOnlyList<Vector3> path, bool closed, FrameMode mode )
	{
		var count = path.Count;
		var tangents = new Vector3[count];
		var stretch = new float[count];

		for ( var i = 0; i < count; i++ )
		{
			var hasBefore = closed || i > 0;
			var hasAfter = closed || i < count - 1;

			var before = hasBefore ? (path[i] - path[(i + count - 1) % count]).Normal : Vector3.Zero;
			var after = hasAfter ? (path[(i + 1) % count] - path[i]).Normal : Vector3.Zero;

			tangents[i] = (before + after).Normal;

			if ( tangents[i].Length < 0.5f ) tangents[i] = after.Length > 0.5f ? after : before;

			// At a corner the section sits on the bisector, which is longer than the wall is wide.
			var flatBefore = before.WithZ( 0.0f ).Normal;
			var flatAfter = after.WithZ( 0.0f ).Normal;
			var flat = (flatBefore + flatAfter).Normal;
			var turn = hasBefore && hasAfter && flat.Length > 0.5f ? MathF.Max( flat.Dot( flatAfter ), 1.0f / MaxStretch ) : 1.0f;

			stretch[i] = 1.0f / turn;
		}

		var lefts = mode == FrameMode.Upright ? UprightLefts( tangents ) : TransportedLefts( path, tangents, closed );
		var frames = new List<PathFrame>( count + 1 );
		var distance = 0.0f;

		for ( var i = 0; i < count; i++ )
		{
			if ( i > 0 ) distance += path[i].Distance( path[i - 1] );

			frames.Add( new PathFrame( path[i], tangents[i], lefts[i], Vector3.Cross( tangents[i], lefts[i] ).Normal, distance, stretch[i] ) );
		}

		if ( closed )
			frames.Add( frames[0] with { Distance = distance + path[^1].Distance( path[0] ) } );

		return frames;
	}

	/// <summary>
	/// Left stays level. Floors stay flat, which is what roads and buildings want.
	/// </summary>
	static Vector3[] UprightLefts( Vector3[] tangents )
	{
		var lefts = new Vector3[tangents.Length];
		var last = Vector3.Left;

		for ( var i = 0; i < tangents.Length; i++ )
		{
			var left = Vector3.Cross( Vector3.Up, tangents[i] );

			// Going straight up there is no level left, so the last one carries on.
			left = left.Length > 0.01f ? left.Normal : (last - tangents[i] * last.Dot( tangents[i] )).Normal;

			lefts[i] = left;
			last = left;
		}

		return lefts;
	}

	/// <summary>
	/// Double reflection: each frame is mirrored along the path, which carries it
	/// forward with the least possible turning around the path. A closed path then
	/// spreads whatever twist is left evenly, so its end meets its start.
	/// </summary>
	static Vector3[] TransportedLefts( IReadOnlyList<Vector3> path, Vector3[] tangents, bool closed )
	{
		var count = path.Count;
		var lefts = new Vector3[count];

		var start = Vector3.Cross( Vector3.Up, tangents[0] );
		lefts[0] = start.Length > 0.01f ? start.Normal : Vector3.Cross( Vector3.Forward, tangents[0] ).Normal;

		Vector3 Carry( Vector3 left, Vector3 fromPoint, Vector3 fromTangent, Vector3 toPoint, Vector3 toTangent )
		{
			var v1 = toPoint - fromPoint;
			var c1 = v1.Dot( v1 );

			if ( c1 < 1e-8f ) return left;

			var reflectedLeft = left - v1 * (2.0f / c1 * v1.Dot( left ));
			var reflectedTangent = fromTangent - v1 * (2.0f / c1 * v1.Dot( fromTangent ));
			var v2 = toTangent - reflectedTangent;
			var c2 = v2.Dot( v2 );

			return c2 < 1e-8f ? reflectedLeft : reflectedLeft - v2 * (2.0f / c2 * v2.Dot( reflectedLeft ));
		}

		for ( var i = 1; i < count; i++ )
			lefts[i] = Carry( lefts[i - 1], path[i - 1], tangents[i - 1], path[i], tangents[i] ).Normal;

		if ( !closed ) return lefts;

		var closing = Carry( lefts[^1], path[^1], tangents[^1], path[0], tangents[0] ).Normal;
		var up = Vector3.Cross( tangents[0], lefts[0] );
		var error = MathF.Atan2( closing.Dot( up ), closing.Dot( lefts[0] ) );

		for ( var i = 0; i < count; i++ )
		{
			var angle = -error * i / count;
			var sideways = Vector3.Cross( tangents[i], lefts[i] );

			lefts[i] = (lefts[i] * MathF.Cos( angle ) + sideways * MathF.Sin( angle )).Normal;
		}

		return lefts;
	}
}

/// <summary>
/// How a section changes along a path: t runs from 0 at the start to 1 at the end.
/// </summary>
public sealed record SweepShape( float TwistDegrees = 0.0f, float StartScale = 1.0f, float EndScale = 1.0f )
{
	public Vector2 Apply( Vector2 point, float t )
	{
		var scale = StartScale + (EndScale - StartScale) * t;
		var angle = (TwistDegrees * t).DegreeToRadian();
		var cos = MathF.Cos( angle );
		var sin = MathF.Sin( angle );

		return new Vector2( point.x * cos - point.y * sin, point.x * sin + point.y * cos ) * scale;
	}
}

/// <summary>
/// Sweeps a cross section along frames. A section is a list of (left, up) points,
/// counter clockwise for a solid. Works on a range of frames, so one long sweep can
/// become many independent parts that still line up exactly.
/// </summary>
public static class SweepEngine
{
	public static void Build( MeshKit kit, IReadOnlyList<PathFrame> frames, int from, int to,
		Func<float, IReadOnlyList<Vector2>> section, bool closedProfile = true, SweepShape shape = null )
	{
		var total = frames[^1].Distance;
		var first = section( 0.0f );
		var rings = new Vector3[first.Count][];

		for ( var p = 0; p < first.Count; p++ )
			rings[p] = new Vector3[to - from + 1];

		for ( var i = from; i <= to; i++ )
		{
			var frame = frames[i];
			var t = total > 0.0f ? frame.Distance / total : 0.0f;
			var points = section( t );

			for ( var p = 0; p < points.Count; p++ )
			{
				var point = shape is null ? points[p] : shape.Apply( points[p], t );
				rings[p][i - from] = frame.Place( point.x, point.y );
			}
		}

		kit.Sweep( rings, false, closedProfile );
	}

	/// <summary>
	/// A sweep along the side lines of a track, between two stations. Where a section reaches
	/// past the inside of a tight turn the track keeps it from folding over itself.
	/// </summary>
	public static void Build( MeshKit kit, PathTrack track, int from, int to, IReadOnlyList<Vector2> section, IReadOnlyList<Material> materials = null )
	{
		var rings = section.Select( p =>
		{
			var ring = new Vector3[to - from + 1];

			for ( var i = from; i <= to; i++ )
				ring[i - from] = track.Place( i, p.x, p.y );

			return ring;
		} ).ToArray();

		kit.Sweep( rings, false, true, materials );
	}

	/// <summary>
	/// A section between two with the same number of points, for shapes that change along a path.
	/// </summary>
	public static Func<float, IReadOnlyList<Vector2>> Blend( IReadOnlyList<Vector2> start, IReadOnlyList<Vector2> end ) =>
		t => [.. start.Zip( end, ( a, b ) => a + (b - a) * t )];

	/// <summary>
	/// A section across the path at one frame, as thick as <paramref name="depth"/>. For ribs and frames.
	/// </summary>
	public static void Slice( MeshKit kit, PathFrame frame, IReadOnlyList<Vector2> section, float depth, float forward = 0.0f )
	{
		var half = depth * 0.5f;
		var rings = section.Select( p => new[] { frame.Place( p.x, p.y, forward - half ), frame.Place( p.x, p.y, forward + half ) } ).ToArray();

		kit.Sweep( rings, false );
	}
}