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