editor/AdvancedMappingTools/Procedural/DuctRouteGenerator.cs

Editor proc- generator for ventilation duct runs in an advanced mapping toolkit. Sweeps a rectangular, round or oval section along a routed path built by PipeRoute, and emits separate solids for straight lengths, elbows, flanges, size transitions, side branches with collars and end pieces, grilles, outlets, insulation jackets and hangers or posts.

namespace AdvancedMappingTools;

public enum DuctShape
{
	Rectangular,
	Round,
	Oval
}

public enum DuctOutletSide
{
	None,
	[Title( "Below (Ceiling Diffusers)" )] Below,
	[Title( "Left Side" )] Left,
	[Title( "Right Side" )] Right,
	[Title( "Top" )] Top
}

public enum DuctEnd
{
	[Title( "Closed" )] Cap,
	Grille,
	[Title( "Exhaust Hood" )] Hood
}

/// <summary>
/// A duct that leaves the main one: a tee at 90 degrees, a wye when it leans, a cross on both sides.
/// </summary>
public sealed class DuctBranch
{
	[Property, Range( 0.02f, 0.98f ), WideMode, Description( "Where along the duct the branch leaves, as a share of its length." )]
	public float At { get; set; } = 0.5f;

	[Property, WideMode, Description( "Which way the branch leaves. Both Sides gives a cross." )]
	public PipeSide Side { get; set; } = PipeSide.Right;

	[Property, Range( 30, 150, slider: false ), WideMode, Description( "90 is a tee. Less leans the branch the way the air flows, a wye; more leans it back." )]
	public float Angle { get; set; } = 90.0f;

	[Property, Range( 4, 4096, slider: false ), WideMode, Description( "How far the branch reaches out from the duct." )]
	public float Length { get; set; } = 96.0f;

	[Property, Range( 0, 512, slider: false ), WideMode, Description( "Across the branch. Zero takes the size of the main duct." )]
	public float Width { get; set; } = 0.0f;

	[Property, Range( 0, 512, slider: false ), WideMode, Description( "Height of the branch. Zero takes the size of the main duct. It never gets larger than the face it leaves." )]
	public float Height { get; set; } = 0.0f;

	[Property, WideMode]
	public DuctEnd End { get; set; } = DuctEnd.Grille;
}

public sealed class DuctRouteSettings : GeneratorSettings
{
	/// <summary>
	/// The line the duct follows, set by the tool.
	/// </summary>
	public PathCurve Path { get; set; } = PathCurve.FromPoints( [new Vector3( 0, 0, 192 ), new Vector3( 512, 0, 192 ), new Vector3( 512, 384, 192 )] );

	[Property, Group( "Duct" ), WideMode]
	public DuctShape Shape { get; set; } = DuctShape.Rectangular;

	[Property, Group( "Duct" ), Range( 2, 512, slider: false ), WideMode, Description( "Across the duct. A round duct uses it as its diameter." )]
	public float Width { get; set; } = 32.0f;

	[Property, HideIf( nameof( Shape ), DuctShape.Round ), Group( "Duct" ), Range( 2, 512, slider: false ), WideMode, Description( "From the bottom to the top of a rectangular or oval duct." )]
	public float Height { get; set; } = 20.0f;

	[Property, HideIf( nameof( Shape ), DuctShape.Rectangular ), Group( "Duct" ), Range( 6, 48, slider: false ), WideMode, Description( "Sides of a round or oval duct." )]
	public int Sides { get; set; } = 16;

	[Property, Group( "Bends" ), Title( "Bend Radius" ), Range( 1, 1024, slider: false ), WideMode, Description( "Radius of every elbow along the centre. It never gets smaller than half the duct, or the inside of the bend would fold." )]
	public float BendRadius { get; set; } = 32.0f;

	[Property, Group( "Bends" ), Title( "Bend Segments" ), Range( 1, 32, slider: false ), WideMode, Description( "How many pieces one elbow is drawn with. One gives a mitred corner." )]
	public int BendSegments { get; set; } = 6;

	[Property, Group( "Bends" ), Title( "Max Bend Angle" ), Range( 10, 175, slider: false ), WideMode, Description( "Sharper corners than this stay square instead of getting an elbow." )]
	public float MaxBendAngle { get; set; } = 150.0f;

	[Property, Group( "Transition" ), Title( "Transition At" ), Range( 0, 8192, slider: false ), WideMode, Description( "Distance along the duct where it changes size. Zero leaves the size the same all the way." )]
	public float TransitionAt { get; set; } = 0.0f;

	[Property, HideIf( nameof( TransitionAt ), 0.0f ), Group( "Transition" ), Title( "Transition Length" ), Range( 1, 512, slider: false ), WideMode]
	public float TransitionLength { get; set; } = 32.0f;

	[Property, HideIf( nameof( TransitionAt ), 0.0f ), Group( "Transition" ), Title( "Width After" ), Range( 2, 512, slider: false ), WideMode]
	public float WidthAfter { get; set; } = 20.0f;

	[Property, HideIf( nameof( TransitionAt ), 0.0f ), Group( "Transition" ), Title( "Height After" ), Range( 2, 512, slider: false ), WideMode]
	public float HeightAfter { get; set; } = 14.0f;

	[Property, Group( "Branches" ), Title( "Branches" ), WideMode, Description( "Tees, wyes and crosses that leave the main duct, each with a collar and an end of its own." )]
	public List<DuctBranch> Branches { get; set; } = [];

	[Property, Group( "Joints" ), Title( "Flanges" ), WideMode, Description( "A frame where two lengths meet: at both ends of every elbow and in between at the joint spacing." )]
	public bool Flanges { get; set; } = true;

	[Property, ShowIf( nameof( Flanges ), true ), Group( "Joints" ), Title( "Joint Spacing" ), Range( 0, 2048, slider: false ), WideMode, Description( "Length of a straight duct section. Zero puts flanges only at the elbows." )]
	public float JointSpacing { get; set; } = 120.0f;

	[Property, ShowIf( nameof( Flanges ), true ), Group( "Joints" ), Title( "Flange Size" ), Range( 0.25f, 16, slider: false ), WideMode, Description( "How far a flange stands out from the duct." )]
	public float FlangeSize { get; set; } = 1.5f;

	[Property, ShowIf( nameof( Flanges ), true ), Group( "Joints" ), Title( "Flange Width" ), Range( 0.25f, 16, slider: false ), WideMode]
	public float FlangeWidth { get; set; } = 2.0f;

	[Property, Group( "Outlets" ), Title( "Outlets" ), WideMode, Description( "Grilles along the level stretches." )]
	public DuctOutletSide Outlets { get; set; } = DuctOutletSide.Below;

	[Property, HideIf( nameof( Outlets ), DuctOutletSide.None ), Group( "Outlets" ), Title( "Outlet Spacing" ), Range( 16, 2048, slider: false ), WideMode]
	public float OutletSpacing { get; set; } = 160.0f;

	[Property, HideIf( nameof( Outlets ), DuctOutletSide.None ), Group( "Outlets" ), Title( "Outlet Size" ), Range( 2, 256, slider: false ), WideMode, Description( "Length of a grille. Across it stays inside the face it sits on." )]
	public float OutletSize { get; set; } = 24.0f;

	[Property, HideIf( nameof( Outlets ), DuctOutletSide.None ), Group( "Outlets" ), Title( "Slats" ), Range( 0, 32, slider: false ), WideMode]
	public int Slats { get; set; } = 5;

	[Property, Group( "Outlets" ), Title( "Start" ), WideMode, Description( "What the first end of the duct ends in." )]
	public DuctEnd StartEnd { get; set; } = DuctEnd.Cap;

	[Property, Group( "Outlets" ), Title( "End" ), WideMode, Description( "What the last end of the duct ends in." )]
	public DuctEnd EndEnd { get; set; } = DuctEnd.Grille;

	[Property, Group( "Insulation" ), Title( "Insulation" ), WideMode, Description( "A jacket around runs and elbows." )]
	public bool Insulation { get; set; } = false;

	[Property, ShowIf( nameof( Insulation ), true ), Group( "Insulation" ), Title( "Thickness" ), Range( 0.25f, 32, slider: false ), WideMode]
	public float InsulationThickness { get; set; } = 2.0f;

	[Property, Group( "Supports" ), Title( "Supports" ), WideMode, Description( "Supports along the level stretches." )]
	public bool Supports { get; set; } = true;

	[Property, ShowIf( nameof( Supports ), true ), Group( "Supports" ), Title( "Hang From Ceiling" ), WideMode, Description( "Two rods and a bar under the duct, up to the ceiling height. Off, a post down to the ground height." )]
	public bool Hang { get; set; } = true;

	[Property, ShowIf( nameof( Supports ), true ), Group( "Supports" ), Title( "Support Spacing" ), Range( 16, 2048, slider: false ), WideMode]
	public float SupportSpacing { get; set; } = 160.0f;

	[Property, ShowIf( nameof( Hang ), true ), Group( "Supports" ), Title( "Ceiling Height" ), Range( -4096, 8192, slider: false ), WideMode, Description( "Where the rods end, in world height." )]
	public float CeilingHeight { get; set; } = 256.0f;

	[Property, HideIf( nameof( Hang ), true ), Group( "Supports" ), Title( "Ground Height" ), Range( -4096, 8192, slider: false ), WideMode]
	public float GroundHeight { get; set; } = 0.0f;

	[Property, Group( "Materials" ), Title( "Duct" ), ResourceType( "vmat" ), WideMode]
	public string DuctMaterial { get; set; }

	[Property, Group( "Materials" ), Title( "Fittings" ), ResourceType( "vmat" ), WideMode, Description( "Flanges, grilles and hoods." )]
	public string FittingMaterial { get; set; }

	[Property, ShowIf( nameof( Supports ), true ), Group( "Materials" ), Title( "Supports" ), ResourceType( "vmat" ), WideMode]
	public string SupportMaterial { get; set; }

	[Property, ShowIf( nameof( Insulation ), true ), Group( "Materials" ), Title( "Insulation" ), ResourceType( "vmat" ), WideMode]
	public string InsulationMaterial { get; set; }
}

/// <summary>
/// Ventilation duct along a routed line: straight lengths and elbows from the same router as
/// Pipe Route, in a rectangular, round or oval section, with flanges, a change of size,
/// grilles, end pieces, insulation and supports.
/// </summary>
public sealed class DuctRouteGenerator : IPartGenerator
{
	const string DuctCategory = "duct";
	const string FittingCategory = "fitting";
	const string SupportCategory = "support";
	const string InsulationCategory = "insulation";

	const float EndClearance = 4.0f;
	const float GrilleDepth = 1.5f;

	public string Id => "ductroute";
	public string Title => "Duct Route";
	public Type SettingsType => typeof( DuctRouteSettings );
	public IReadOnlyList<string> SeedNames => [DuctCategory, FittingCategory, SupportCategory, InsulationCategory];

	/// <summary>
	/// The centre line of the whole duct with one frame per point. Every part is swept along
	/// it, so a flange or a grille turns exactly like the duct it sits on, also on a riser.
	/// </summary>
	sealed class Rail
	{
		public readonly List<Vector3> Points = [];
		public readonly List<float> Distances = [];
		public Vector3[] Tangents;
		public Vector3[] Lefts;
		public bool Closed;

		public float Length => Distances.Count > 0 ? Distances[^1] : 0.0f;

		public void Add( Vector3 point )
		{
			if ( Points.Count > 0 && Points[^1].AlmostEqual( point, 0.01f ) ) return;

			Distances.Add( Points.Count == 0 ? 0.0f : Distances[^1] + Points[^1].Distance( point ) );
			Points.Add( point );
		}

		/// <summary>
		/// Position and frame at a distance. Past an end the line carries on straight.
		/// </summary>
		public (Vector3 Position, Vector3 Tangent, Vector3 Left) At( float distance )
		{
			if ( distance <= 0.0f ) return (Points[0] + Tangents[0] * distance, Tangents[0], Lefts[0]);
			if ( distance >= Length ) return (Points[^1] + Tangents[^1] * (distance - Length), Tangents[^1], Lefts[^1]);

			var i = Distances.FindLastIndex( x => x <= distance );
			i = Math.Clamp( i, 0, Points.Count - 2 );

			var part = Distances[i + 1] - Distances[i];
			var t = part > 0.0f ? (distance - Distances[i]) / part : 0.0f;

			return (Vector3.Lerp( Points[i], Points[i + 1], t ), Vector3.Lerp( Tangents[i], Tangents[i + 1], t ).Normal, Vector3.Lerp( Lefts[i], Lefts[i + 1], t ).Normal);
		}
	}

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (DuctRouteSettings)settings;
		var root = context.Group( "root", "Duct Route", "Building", DuctCategory );

		if ( s.Path is null || s.Path.SegmentCount == 0 ) return root;

		var input = s.Path.Nodes.Select( x => x.Position ).ToList();

		if ( input.Count < 2 || input.Zip( input.Skip( 1 ) ).All( x => x.First.AlmostEqual( x.Second, 0.5f ) ) ) return root;

		// The inside of an elbow folds when its radius is less than half the duct.
		var largest = MathF.Max( MathF.Max( s.Width, Tall( s, s.Height ) ), s.TransitionAt > 0.0f ? MathF.Max( s.WidthAfter, Tall( s, s.HeightAfter ) ) : 0.0f );
		var radius = MathF.Max( s.BendRadius, largest * 0.5f + 1.0f );
		var pieces = PipeRoute.Build( input, s.Path.Closed, radius, Math.Clamp( s.BendSegments, 1, PipeRoute.MaxSegments ), s.MaxBendAngle );

		if ( pieces.Count == 0 ) return root;

		var rail = new Rail { Closed = s.Path.Closed };

		foreach ( var piece in pieces )
			foreach ( var point in piece.Points )
				rail.Add( point );

		if ( rail.Closed && rail.Points.Count > 2 && rail.Points[0].AlmostEqual( rail.Points[^1], 0.01f ) )
		{
			rail.Points.RemoveAt( rail.Points.Count - 1 );
			rail.Distances.RemoveAt( rail.Distances.Count - 1 );
		}

		if ( rail.Points.Count < 2 ) return root;

		PipeRouteGenerator.Frames( rail.Points, rail.Closed, out rail.Tangents, out rail.Lefts );

		var duct = context.Material( s.DuctMaterial );
		var fitting = context.Material( s.FittingMaterial );
		var support = context.Material( s.SupportMaterial );
		var insulation = context.Material( s.InsulationMaterial );

		// Where each piece of the route starts and ends along the rail.
		var spans = new List<(float From, float To, bool Bend, bool Level)>();
		var walked = 0.0f;

		foreach ( var piece in pieces )
		{
			spans.Add( (walked, walked + piece.Length, piece.IsBend, piece.IsLevel) );
			walked += piece.Length;
		}

		if ( rail.Closed )
		{
			root.Add( context.Solid( "ring", "Duct", "DuctLength", DuctCategory, duct, kit => Sweep( kit, s, rail, 0.0f, rail.Length, 0.0f, true ) ) );
			return root;
		}

		var joints = Joints( s, spans );

		// Where the route meets a finished one, the editor closes that end with a cap. A cap
		// needs a flange at the joint, like every other place where two pieces meet.
		if ( s.Flanges && rail.Length > 1.0f )
		{
			var edge = MathF.Max( s.FlangeWidth, 0.25f ) * 0.5f;

			if ( s.StartEnd == DuctEnd.Cap && !joints.Any( x => MathF.Abs( x - edge ) < 0.5f ) )
				joints.Insert( 0, edge );

			if ( s.EndEnd == DuctEnd.Cap && !joints.Any( x => MathF.Abs( x - (rail.Length - edge) ) < 0.5f ) )
				joints.Add( rail.Length - edge );
		}

		var cuts = new List<float> { 0.0f };
		cuts.AddRange( joints );
		cuts.Add( rail.Length );

		for ( var i = 0; i + 1 < cuts.Count; i++ )
		{
			var (from, to) = (cuts[i], cuts[i + 1]);
			var bend = spans.Any( x => x.Bend && from >= x.From - 0.01f && to <= x.To + 0.01f );

			root.Add( context.Solid( $"length{i:00}", bend ? $"Elbow {i + 1}" : $"Length {i + 1}", bend ? "DuctElbow" : "DuctLength", DuctCategory, duct,
				kit => Sweep( kit, s, rail, from, to, 0.0f, false ) ) );

			if ( s.Insulation )
			{
				root.Add( context.Solid( $"insulation{i:00}", $"Insulation {i + 1}", "Insulation", InsulationCategory, insulation,
					kit => Sweep( kit, s, rail, from, to, MathF.Max( s.InsulationThickness, 0.25f ), false ) ) );
			}
		}

		if ( s.Flanges )
		{
			var half = MathF.Max( s.FlangeWidth, 0.25f ) * 0.5f;
			var grow = MathF.Max( s.FlangeSize, 0.25f ) + (s.Insulation ? s.InsulationThickness : 0.0f);

			for ( var i = 0; i < joints.Count; i++ )
			{
				var at = joints[i];

				root.Add( context.Solid( $"flange{i:00}", $"Flange {i + 1}", "DuctFlange", FittingCategory, fitting,
					kit => Sweep( kit, s, rail, at - half, at + half, grow, false ) ) );
			}
		}

		Ends( s, context, root, rail, fitting );

		// Grilles and hangers keep clear of where a branch leaves.
		var clear = new List<float>( joints );
		clear.AddRange( Branches( s, context, root, rail, duct, fitting, insulation ) );

		var grilles = s.Outlets != DuctOutletSide.None ? Outlets( s, context, root, rail, spans, clear, fitting ) : [];

		// A hanger under a grille would sit right on it.
		if ( s.Supports )
			Supports( s, context, root, rail, spans, [.. clear, .. grilles], support, s.Outlets == DuctOutletSide.None ? 0.0f : MathF.Max( s.OutletSize, 2.0f ) * 0.5f );

		return root;
	}

	static float Tall( DuctRouteSettings s, float height ) => s.Shape == DuctShape.Round ? 0.0f : height;

	/// <summary>
	/// Width and height at a distance, with the change of size where the settings put it.
	/// </summary>
	static (float Width, float Height) SizeAt( DuctRouteSettings s, float distance )
	{
		var width = MathF.Max( s.Width, 2.0f );
		var height = s.Shape == DuctShape.Round ? width : MathF.Max( s.Height, 2.0f );

		if ( s.TransitionAt <= 0.0f ) return (width, height);

		var afterWidth = MathF.Max( s.WidthAfter, 2.0f );
		var afterHeight = s.Shape == DuctShape.Round ? afterWidth : MathF.Max( s.HeightAfter, 2.0f );
		var half = MathF.Max( s.TransitionLength, 1.0f ) * 0.5f;
		var t = Math.Clamp( (distance - (s.TransitionAt - half)) / (half * 2.0f), 0.0f, 1.0f );

		return (MathX.Lerp( width, afterWidth, t ), MathX.Lerp( height, afterHeight, t ));
	}

	/// <summary>
	/// The outline of the section, counter clockwise as seen looking along the duct, in
	/// (left, up) around the centre.
	/// </summary>
	static List<Vector2> Section( DuctRouteSettings s, float width, float height, float grow )
	{
		var w = width * 0.5f + grow;
		var h = height * 0.5f + grow;

		if ( s.Shape == DuctShape.Rectangular )
			return [new( w, h ), new( -w, h ), new( -w, -h ), new( w, -h )];

		var sides = Math.Clamp( s.Sides, 6, 48 );

		return [.. Enumerable.Range( 0, sides ).Select( i => new Vector2( MathF.Cos( MathF.PI * 2.0f * i / sides ) * w, MathF.Sin( MathF.PI * 2.0f * i / sides ) * h ) )];
	}

	/// <summary>
	/// Sweep the section from one distance to another, through every point of the rail between.
	/// </summary>
	static void Sweep( MeshKit kit, DuctRouteSettings s, Rail rail, float from, float to, float grow, bool closed, float flare = 1.0f, (float Width, float Height)? size = null )
	{
		// A closed ring goes once through every point of the rail and back to the first.
		var stations = closed ? [.. rail.Distances] : new List<float> { from };

		if ( !closed )
		{
			stations.AddRange( rail.Distances.Where( x => x > from + 0.01f && x < to - 0.01f ) );
			stations.Add( to );
		}

		// A change of size needs more than its two ends to look like one.
		if ( s.TransitionAt > 0.0f && size is null )
		{
			var half = MathF.Max( s.TransitionLength, 1.0f ) * 0.5f;

			foreach ( var at in new[] { s.TransitionAt - half, s.TransitionAt, s.TransitionAt + half } )
				if ( at > from + 0.01f && at < to - 0.01f ) stations.Add( at );

			stations.Sort();
		}

		if ( stations.Count < 2 ) return;

		var count = Section( s, 2.0f, 2.0f, 0.0f ).Count;
		var rings = new Vector3[count][];

		for ( var k = 0; k < count; k++ ) rings[k] = new Vector3[stations.Count];

		for ( var i = 0; i < stations.Count; i++ )
		{
			var (position, tangent, left) = rail.At( stations[i] );
			var up = Vector3.Cross( tangent, left ).Normal;
			var (width, height) = size ?? SizeAt( s, stations[i] );
			var scale = flare == 1.0f || stations.Count < 2 ? 1.0f : MathX.Lerp( 1.0f, flare, (float)i / (stations.Count - 1) );
			var section = Section( s, width * scale, height * scale, grow );

			for ( var k = 0; k < count; k++ )
				rings[k][i] = position + left * section[k].x + up * section[k].y;
		}

		kit.Sweep( rings, closed, true );
	}

	/// <summary>
	/// Where flanges go: both ends of every elbow, and along straight stretches at the joint spacing.
	/// </summary>
	static List<float> Joints( DuctRouteSettings s, List<(float From, float To, bool Bend, bool Level)> spans )
	{
		var joints = new List<float>();
		var total = spans.Count > 0 ? spans[^1].To : 0.0f;
		var gap = MathF.Max( s.FlangeWidth, 0.25f ) * 2.0f;

		void Add( float at )
		{
			if ( at < gap || at > total - gap || joints.Any( x => MathF.Abs( x - at ) < gap ) ) return;
			joints.Add( at );
		}

		foreach ( var span in spans )
		{
			if ( span.Bend )
			{
				Add( span.From );
				Add( span.To );
				continue;
			}

			if ( s.JointSpacing <= 0.0f ) continue;

			var length = span.To - span.From;
			var count = (int)(length / s.JointSpacing);

			for ( var i = 1; i <= count; i++ )
			{
				var at = span.From + length * i / (count + 1);
				Add( at );
			}
		}

		joints.Sort();
		return joints;
	}

	/// <summary>
	/// The branches, and the distances along the main duct where they leave it.
	/// </summary>
	static List<float> Branches( DuctRouteSettings s, GenContext context, PartPlan root, Rail rail, Material duct, Material fitting, Material insulation )
	{
		var taken = new List<float>();

		if ( s.Branches is null ) return taken;

		var index = 0;

		foreach ( var branch in s.Branches )
		{
			if ( branch is null ) continue;

			var at = Math.Clamp( branch.At, 0.02f, 0.98f ) * rail.Length;
			var (position, tangent, left) = rail.At( at );
			var up = Vector3.Cross( tangent, left ).Normal;
			var (width, height) = SizeAt( s, at );

			var sides = branch.Side switch
			{
				PipeSide.Left => new[] { (left, width * 0.5f, height) },
				PipeSide.Right => [(-left, width * 0.5f, height)],
				PipeSide.Up => [(up, height * 0.5f, width)],
				PipeSide.Down => [(-up, height * 0.5f, width)],
				_ => [(left, width * 0.5f, height), (-left, width * 0.5f, height)]
			};

			var lean = Math.Clamp( branch.Angle, 30.0f, 150.0f ).DegreeToRadian();
			var number = index++;
			var letter = 0;

			foreach ( var (side, reach, face) in sides )
			{
				var direction = (side * MathF.Sin( lean ) + tangent * MathF.Cos( lean )).Normal;
				var square = MathF.Max( direction.Dot( side ), 0.1f );

				// Across the branch runs along the main duct, its height stands like the face it leaves.
				var across = (tangent - direction * tangent.Dot( direction )).Normal;
				var bw = branch.Width > 0.0f ? branch.Width : MathF.Min( width, height );
				var bh = MathF.Min( branch.Height > 0.0f ? branch.Height : face, face );

				if ( s.Shape == DuctShape.Round ) bh = bw = MathF.Min( bw, MathF.Min( width, height ) );

				// It starts inside the main duct, so the two meet without a gap at any angle.
				var wall = reach / square;
				var start = position + direction * (reach * 0.5f);
				var end = position + direction * (wall + MathF.Max( branch.Length, 4.0f ));
				var stub = new Rail();

				stub.Add( start );
				stub.Add( end );
				stub.Tangents = [direction, direction];
				stub.Lefts = [across, across];

				var size = (bw, bh);
				var suffix = sides.Length > 1 ? (letter++ == 0 ? "a" : "b") : "";
				var id = $"branch{number:00}{suffix}";
				var label = $"Branch {number + 1}{suffix}";
				var root0 = wall - reach * 0.5f;

				root.Add( context.Solid( id, label, "DuctBranch", DuctCategory, duct, kit => Sweep( kit, s, stub, 0.0f, stub.Length, 0.0f, false, 1.0f, size ) ) );

				if ( s.Insulation )
					root.Add( context.Solid( $"{id}_insulation", $"{label} Insulation", "Insulation", InsulationCategory, insulation,
						kit => Sweep( kit, s, stub, root0, stub.Length, MathF.Max( s.InsulationThickness, 0.25f ), false, 1.0f, size ) ) );

				if ( s.Flanges )
				{
					var half = MathF.Max( s.FlangeWidth, 0.25f ) * 0.5f;
					var grow = MathF.Max( s.FlangeSize, 0.25f ) + (s.Insulation ? s.InsulationThickness : 0.0f);
					var collar = root0 + half * 2.0f;

					root.Add( context.Solid( $"{id}_collar", $"{label} Collar", "DuctFlange", FittingCategory, fitting,
						kit => Sweep( kit, s, stub, collar - half, collar + half, grow, false, 1.0f, size ) ) );
				}

				if ( branch.End == DuctEnd.Grille )
				{
					root.Add( context.Solid( $"{id}_grille", $"{label} Grille", "Grille", FittingCategory, fitting,
						kit => Grille( kit, end, direction, across, bw, bh, s.Slats ) ) );
				}
				else if ( branch.End == DuctEnd.Hood )
				{
					var length = MathF.Max( bw, bh ) * 0.6f;

					root.Add( context.Solid( $"{id}_hood", $"{label} Hood", "DuctHood", FittingCategory, fitting,
						kit => Sweep( kit, s, stub, stub.Length, stub.Length + length, 0.5f, false, 1.6f, size ) ) );
				}
			}

			taken.Add( at );
		}

		return taken;
	}

	static void Ends( DuctRouteSettings s, GenContext context, PartPlan root, Rail rail, Material fitting )
	{
		foreach ( var (id, label, at, style, outward) in new[] { ("start", "Start", 0.0f, s.StartEnd, -1.0f), ("end", "End", rail.Length, s.EndEnd, 1.0f) } )
		{
			var (width, height) = SizeAt( s, at );
			var deep = MathF.Max( width, height );

			if ( style == DuctEnd.Hood )
			{
				var length = deep * 0.6f;
				var (from, to) = outward > 0.0f ? (at, at + length) : (at - length, at);

				root.Add( context.Solid( $"{id}_hood", $"{label} Hood", "DuctHood", FittingCategory, fitting,
					kit => Sweep( kit, s, rail, from, to, 0.5f, false, outward > 0.0f ? 1.6f : 1.0f / 1.6f ) ) );
			}
			else if ( style == DuctEnd.Grille )
			{
				var (position, tangent, left) = rail.At( at );

				root.Add( context.Solid( $"{id}_grille", $"{label} Grille", "Grille", FittingCategory, fitting,
					kit => Grille( kit, position, tangent * outward, left, width, height, s.Slats ) ) );
			}
		}
	}

	/// <summary>
	/// A grille on a face: a frame and slats standing out of the face by a hand's width.
	/// <paramref name="normal"/> points out of the face, <paramref name="along"/> lies in it.
	/// </summary>
	static void Grille( MeshKit kit, Vector3 center, Vector3 normal, Vector3 along, float length, float across, int slats )
	{
		var frame = kit.Frame;
		var bar = MathF.Max( MathF.Min( length, across ) * 0.08f, 0.5f );
		var x = length * 0.5f;
		var y = across * 0.5f;

		kit.Frame = frame.ToWorld( new Transform( center, Rotation.LookAt( along, normal ) ) );

		kit.Box( new Vector3( -x, -y, 0.0f ), new Vector3( x, -y + bar, GrilleDepth ) );
		kit.Box( new Vector3( -x, y - bar, 0.0f ), new Vector3( x, y, GrilleDepth ) );
		kit.Box( new Vector3( -x, -y + bar, 0.0f ), new Vector3( -x + bar, y - bar, GrilleDepth ) );
		kit.Box( new Vector3( x - bar, -y + bar, 0.0f ), new Vector3( x, y - bar, GrilleDepth ) );

		var count = Math.Clamp( slats, 0, 32 );
		var inner = x - bar;

		for ( var i = 0; i < count; i++ )
		{
			var at = -inner + inner * 2.0f * (i + 1) / (count + 1);
			kit.Box( new Vector3( at - bar * 0.35f, -y + bar, GrilleDepth * 0.2f ), new Vector3( at + bar * 0.35f, y - bar, GrilleDepth * 0.8f ) );
		}

		kit.Frame = frame;
	}

	/// <summary>
	/// Places at a spacing along the level straight stretches, clear of the flanges and ends.
	/// </summary>
	static IEnumerable<float> Along( Rail rail, List<(float From, float To, bool Bend, bool Level)> spans, List<float> joints, float spacing, float clear )
	{
		foreach ( var span in spans.Where( x => !x.Bend && x.Level ) )
		{
			var length = span.To - span.From;
			var count = Math.Max( (int)(length / MathF.Max( spacing, 16.0f )), 1 );

			for ( var i = 0; i < count; i++ )
			{
				var at = span.From + length * (i + 0.5f) / count;

				if ( at < clear || at > rail.Length - clear ) continue;
				if ( joints.Any( x => MathF.Abs( x - at ) < clear ) ) at += clear * 1.5f;
				if ( at > span.To - clear ) continue;

				yield return at;
			}
		}
	}

	static List<float> Outlets( DuctRouteSettings s, GenContext context, PartPlan root, Rail rail, List<(float From, float To, bool Bend, bool Level)> spans, List<float> joints, Material fitting )
	{
		var size = MathF.Max( s.OutletSize, 2.0f );
		var number = 0;
		var placed = new List<float>();

		foreach ( var at in Along( rail, spans, joints, s.OutletSpacing, size * 0.5f + s.FlangeWidth + EndClearance ) )
		{
			var (position, tangent, left) = rail.At( at );
			var up = Vector3.Cross( tangent, left ).Normal;
			var (width, height) = SizeAt( s, at );

			var (normal, reach, across) = s.Outlets switch
			{
				DuctOutletSide.Below => (-up, height * 0.5f, width),
				DuctOutletSide.Top => (up, height * 0.5f, width),
				DuctOutletSide.Left => (left, width * 0.5f, height),
				_ => (-left, width * 0.5f, height)
			};

			// A round duct has no flat face, the grille sits a little into it.
			var sink = s.Shape == DuctShape.Rectangular ? 0.0f : reach * 0.15f;
			var center = position + normal * (reach - sink);
			var side = MathF.Max( across * (s.Shape == DuctShape.Rectangular ? 0.8f : 0.5f), 2.0f );
			var i = number++;

			placed.Add( at );
			root.Add( context.Solid( $"outlet{i:00}", $"Outlet {i + 1}", "Grille", FittingCategory, fitting,
				kit => Grille( kit, center, normal, tangent, size, side, s.Slats ) ) );
		}

		return placed;
	}

	static void Supports( DuctRouteSettings s, GenContext context, PartPlan root, Rail rail, List<(float From, float To, bool Bend, bool Level)> spans, List<float> joints, Material material, float grille )
	{
		var number = 0;

		foreach ( var at in Along( rail, spans, joints, s.SupportSpacing, MathF.Max( s.FlangeWidth + EndClearance + 2.0f, grille + EndClearance ) ) )
		{
			var (position, tangent, left) = rail.At( at );
			var (width, height) = SizeAt( s, at );
			var jacket = s.Insulation ? s.InsulationThickness : 0.0f;
			var w = width * 0.5f + jacket;
			var h = height * 0.5f + jacket;
			var bar = MathF.Max( MathF.Min( width, height ) * 0.08f, 1.0f );
			var i = number++;

			if ( s.Hang )
			{
				var ceiling = s.CeilingHeight;

				if ( ceiling - (position.z + h) < 2.0f ) continue;

				root.Add( context.Solid( $"support{i:00}", $"Hanger {i + 1}", "DuctHanger", SupportCategory, material, kit =>
				{
					var under = position - Vector3.Up * (h + bar * 0.5f);

					kit.Beam( under - left * (w + bar * 2.0f), under + left * (w + bar * 2.0f), bar, bar );

					foreach ( var sign in new[] { -1.0f, 1.0f } )
					{
						var foot = under + left * (w + bar * 1.5f) * sign;
						kit.Beam( foot, foot.WithZ( ceiling ), bar * 0.5f, bar * 0.5f );
					}
				} ) );
			}
			else
			{
				var top = position - Vector3.Up * h;

				if ( top.z - s.GroundHeight < 2.0f ) continue;

				root.Add( context.Solid( $"support{i:00}", $"Support {i + 1}", "DuctSupport", SupportCategory, material, kit =>
					kit.Box( top.WithZ( s.GroundHeight ) - new Vector3( bar, bar, 0.0f ), top + new Vector3( bar, bar, 0.0f ) ) ) );
			}
		}
	}
}