editor/AdvancedMappingTools/Placement/DuctRouteEditor.cs

Editor tool that places ventilation duct routes as part of a primitive path tool. It snaps new points to angle steps, previews the generated duct mesh, attaches to the open ends of existing duct routes (clearing their caps and grilles), and places the finished part through PartService.

namespace AdvancedMappingTools;

/// <summary>
/// Draws a ventilation duct. Every corner becomes an elbow, like with Pipe Route, and the
/// duct can be rectangular, round or oval, change its size, carry grilles and hang from the
/// ceiling. Points may be lifted with Shift and dragged, for a riser up to the next floor.
/// </summary>
[ToolCategory( "Industrial" )]
[Title( "Duct Route" ), Icon( "hvac" )]
public sealed class DuctRouteEditor : PathEditor
{
	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "CurveSegments", "Smooth", nameof( AngleStep ) };

	readonly DuctRouteGenerator _generator = new();

	public DuctRouteEditor( PrimitiveTool tool ) : base( tool ) { }

	public DuctRouteSettings Settings { get; } = new();

	public override object ExtraSettings => Settings;

	[Property, Group( "Routing" ), Title( "Angle Snap" ), Range( 0, 90, slider: false ), WideMode, Description( "Locks a new point to steps of this angle around the one before it. Zero places freely." )]
	public float RouteAngle { get; set; } = 45.0f;

	protected override int MinPoints => 2;

	protected override bool PlacesObjects => true;

	protected override bool AllowsHeight => true;

	/// <summary>
	/// Every corner becomes an elbow with the radius from the settings, so no curve handles.
	/// </summary>
	protected override bool UsesBends => false;

	protected override bool Connects => true;

	const float Touch = 0.5f;

	protected override IEnumerable<string> Hints =>
	[
		"Click: place a point",
		"Shift + drag a point: raise or lower it",
		"Every corner becomes an elbow with the bend radius",
		"Enter: create, Esc: stop"
	];

	protected override void BuildMesh( MeshKit kit ) { }

	/// <summary>
	/// Pull a new point onto a step of <see cref="RouteAngle"/> around the point before it.
	/// </summary>
	protected override Vector3 Dock( Vector3 point )
	{
		var snapped = base.Dock( point );

		if ( ConnectFinished && FreeEnd( snapped ) is { } end ) return end;

		if ( RouteAngle <= 0.0f || Points.Count == 0 ) return snapped;

		var origin = Points[^1].Position;
		var flat = (snapped - origin).WithZ( 0.0f );

		if ( flat.Length < 1.0f ) return snapped;

		var step = RouteAngle.DegreeToRadian();
		var angle = MathF.Round( MathF.Atan2( flat.y, flat.x ) / step ) * step;

		return origin + new Vector3( MathF.Cos( angle ), MathF.Sin( angle ), 0.0f ) * flat.Length + Vector3.Up * (snapped.z - origin.z);
	}

	/// <summary>
	/// Every finished duct route with its two ends in world space.
	/// </summary>
	static IEnumerable<(GameObject Root, DuctRouteSettings Settings, Vector3 Start, Vector3 End)> Finished()
	{
		if ( SceneEditorSession.Active is null ) yield break;

		foreach ( var part in SceneEditorSession.Active.Scene.GetAllComponents<AmtPart>() )
		{
			if ( part.Generator != "ductroute" || !PartReconciler.IsAlive( part ) || string.IsNullOrEmpty( part.Settings ) ) continue;
			if ( PartService.Read( part.GameObject ).Settings is not DuctRouteSettings settings || settings.Path is null || settings.Path.Closed || settings.Path.Nodes.Count < 2 ) continue;

			var world = part.WorldTransform;

			yield return (part.GameObject, settings, world.PointToWorld( settings.Path.Nodes[0].Position ), world.PointToWorld( settings.Path.Nodes[^1].Position ));
		}
	}

	/// <summary>
	/// The end of a finished duct near <paramref name="point"/>. The reach grows with the duct,
	/// a click anywhere on its end face counts.
	/// </summary>
	Vector3? FreeEnd( Vector3 point )
	{
		Vector3? best = null;
		var nearest = float.MaxValue;

		foreach ( var (_, settings, start, end) in Finished() )
		{
			var reach = DockRadius + MathF.Max( settings.Width, settings.Height ) * 0.5f;

			foreach ( var spot in new[] { start, end } )
			{
				var distance = spot.Distance( point );

				if ( distance > reach || distance >= nearest ) continue;

				nearest = distance;
				best = spot;
			}
		}

		return best;
	}

	/// <summary>
	/// Two ducts that meet are open to each other: the end of the finished one loses its grille
	/// or hood, and so does the matching end of the new one.
	/// </summary>
	void JoinEnds( out DuctEnd startWas, out DuctEnd endWas )
	{
		startWas = Settings.StartEnd;
		endWas = Settings.EndEnd;

		if ( !ConnectFinished || Points.Count < 2 ) return;

		var first = Points[0].Position;
		var last = Points[^1].Position;

		foreach ( var (root, settings, start, end) in Finished().ToList() )
		{
			var changed = false;

			foreach ( var (spot, atStart) in new[] { (start, true), (end, false) } )
			{
				var meetsFirst = spot.Distance( first ) <= Touch;
				var meetsLast = spot.Distance( last ) <= Touch;

				if ( !meetsFirst && !meetsLast ) continue;

				if ( meetsFirst ) Settings.StartEnd = DuctEnd.Cap;
				if ( meetsLast ) Settings.EndEnd = DuctEnd.Cap;

				if ( atStart && settings.StartEnd != DuctEnd.Cap ) { settings.StartEnd = DuctEnd.Cap; changed = true; }
				if ( !atStart && settings.EndEnd != DuctEnd.Cap ) { settings.EndEnd = DuctEnd.Cap; changed = true; }
			}

			if ( !changed ) continue;

			using ( SceneEditorSession.Scope() )
			using ( SceneEditorSession.Active.UndoScope( "Join Duct" ).WithGameObjectChanges( [root], GameObjectUndoFlags.All ).Push() )
			{
				PartReconciler.RootPart( root ).Settings = GeneratorRegistry.Save( settings );
				PartService.Regenerate( root, RegenerationOptions.Everything, true, settings );
			}
		}
	}

	void Prepare()
	{
		Settings.Path = Curve();
		Settings.DuctMaterial = PartTool.Pick( Settings.DuctMaterial, SurfaceMaterial );
		Settings.FittingMaterial = PartTool.Pick( Settings.FittingMaterial, TrimMaterial );
		Settings.SupportMaterial = PartTool.Pick( Settings.SupportMaterial, TrimMaterial );
		Settings.InsulationMaterial = PartTool.Pick( Settings.InsulationMaterial, SurfaceMaterial );
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();

		return PartService.Plan( _generator, Settings ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );
	}

	protected override void PlaceObjects()
	{
		Prepare();
		JoinEnds( out var startWas, out var endWas );

		PartTool.Place( _generator, Settings, new Transform( Vector3.Zero, Rotation.Identity ) );

		// The ends chosen in the sidebar stay for the next duct.
		Settings.StartEnd = startWas;
		Settings.EndEnd = endWas;
	}
}