editor/AdvancedMappingTools/Placement/CableRouteEditor.cs

Editor tool that places cable routes along a drawn path. Generates cable geometry with sag, bundle layouts, corner clamps, end collars and bundle ties, and builds a preview mesh.

namespace AdvancedMappingTools;
public enum CableBundleLayout
{
	Row,
	Round
}

public sealed partial class CableRouteSettings : GeneratorSettings
{
	public override IReadOnlySet<string> UnusedOptions
	{
		get
		{
			var h = new HashSet<string>
			{
				nameof( Variation )
			};
			if ( !Sag )
				h.Add( nameof( SagDepth ) );
			if ( !Sag || Cables == 1 || CableRouteGeometry.Tied( this ) )
				h.Add( nameof( SagVariation ) );
			if ( Cables == 1 )
				h.UnionWith( [nameof( BundleLayout ), nameof( TieSpacing )] );
			if ( Cables == 1 )
				h.Add( nameof( CableGap ) );
			h.UnionWith( RouteMounting.Hidden( this ) );
			return h;
		}
	}

	public PathCurve Path { get; set; } = PathCurve.FromPoints( [new Vector3( 0, 0, 128 ), new Vector3( 256, 0, 128 )] );

	[Property, Group( "Cable" ), Range( .25f, 32, slider: false ), WideMode]
	public float Radius { get; set; } = 2;

	[Property, Group( "Cable" ), Range( 1, 12, slider: false ), WideMode]
	public int Cables { get; set; } = 1;

	[Property, Group( "Cable" ), Range( 0, 32, slider: false ), WideMode]
	public float CableGap { get; set; } = 2;

	[Property, Group( "Sag" ), WideMode]
	public bool Sag { get; set; } = true;

	[Property, Group( "Sag" ), Range( 0, 256, slider: false ), WideMode]
	public float SagDepth { get; set; } = 24;

	[Property, Group( "Detail" ), Range( 4, 32, slider: false ), WideMode]
	public int Sides { get; set; } = 12;

	[Property, Group( "Detail" ), Range( 4, 128, slider: false ), WideMode]
	public float SampleStep { get; set; } = 16;

	[Property, Group( "Supports" ), WideMode]
	public bool EndCollars { get; set; } = true;

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

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

	/// <summary>How much the sag of neighbouring cables differs, so a bundle does not look cloned.</summary>
	[Property, Group( "Sag" ), Title( "Sag Variation" ), Range( 0, 1, slider: false ), WideMode]
	public float SagVariation { get; set; } = .15f;

	/// <summary>A clamp on every cable where the route turns at a point.</summary>
	[Property, Group( "Supports" ), Title( "Corner Clamps" ), WideMode]
	public bool CornerClamps { get; set; } = true;

	/// <summary>Row lays the cables side by side; Round packs three or more in a circle.</summary>
	[Property, Group( "Cable" ), Title( "Bundle Layout" ), WideMode]
	public CableBundleLayout BundleLayout { get; set; } = CableBundleLayout.Row;

	/// <summary>A tie around all cables every this many units; below 8 means no ties.</summary>
	[Property, Group( "Supports" ), Title( "Tie Spacing" ), Range( 0, 512, slider: false ), WideMode]
	public float TieSpacing { get; set; }
}

public sealed class CableRouteGenerator : IPartGenerator
{
	public string Id => "cable_route";
	public string Title => "Cable Route";
	public Type SettingsType => typeof(CableRouteSettings);

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (CableRouteSettings)settings;
		var root = context.Group( "root", Title, "Building", "cable" );
		if ( s.Path is null || s.Path.SegmentCount == 0 )
			return root;
		var radius = Math.Clamp( s.Radius, .25f, 32 );
		var sides = Math.Clamp( s.Sides, 4, 32 );
		var outline = Enumerable.Range( 0, sides ).Select( i => new Vector2( MathF.Cos( i * MathF.PI * 2 / sides ), MathF.Sin( i * MathF.PI * 2 / sides ) ) * radius ).ToArray();
		foreach ( var route in CableRouteGeometry.Tracks( s ) )
		{
			var cable = route.Index;
			var track = route.Track;
			root.Add( context.Solid( $"cable{cable}", "Cable", "Cable", "cable", context.Material( s.CableMaterial ), kit =>
			{
				if ( !s.Path.Closed )
					SweepEngine.Build( kit, track, 0, track.Count - 1, outline );
				else
				{
					var rings = outline.Select( p => Enumerable.Range( 0, track.Count - 1 ).Select( i => track.Place( i, p.x, p.y ) ).ToArray() ).ToArray();
					kit.Sweep( rings, true );
				}
			} ) );
			if ( s.CornerClamps )
				foreach ( var corner in route.Corners.Where( i => (s.Path.Closed || i > 0) && i < track.Count - 1 ) )
				{
					var f = track.Frames[corner];
					root.Add( context.Solid( $"clamp{cable}_{corner}", "Corner Clamp", "CableCollar", "cable", context.Material( s.CollarMaterial ?? s.CableMaterial ), kit =>
					{
						var half = MathF.Max( radius * 1.2f, 1 );
						kit.Beam( f.Position - f.Forward * half, f.Position + f.Forward * half, radius * 2.6f, radius * 2.6f );
					} ) );
				}

			if ( s.EndCollars && !s.Path.Closed )
				foreach ( var end in new[]
				{
					0,
					track.Count - 1
				}

 )
				{
					var f = track.Frames[end];
					root.Add( context.Solid( $"collar{cable}_{end}", "End Collar", "CableCollar", "cable", context.Material( s.CollarMaterial ?? s.CableMaterial ), kit =>
					{
						var axis = end == 0 ? f.Forward : -f.Forward;
						kit.Beam( f.Position - axis * 2, f.Position, radius * 2.8f, radius * 2.8f );
					} ) );
				}
		}

		Ties( root, s, radius, context );
		return root;
	}

	/// <summary>Hollow bands around the whole bundle: four thin bars that touch the outer cables.</summary>
	static void Ties( PartPlan root, CableRouteSettings s, float radius, GenContext context )
	{
		if ( Math.Clamp( s.Cables, 1, 12 ) < 2 || !float.IsFinite( s.TieSpacing ) || s.TieSpacing < 8 )
			return;
		var center = CableRouteGeometry.Center( s );
		if ( center is null )
			return;
		var spacing = MathF.Min( s.TieSpacing, 512 );
		var length = center.Frames[^1].Distance;
		var tracks = CableRouteGeometry.Tracks( s ).Select( t => t.Track.Frames.Select( f => f.Position ).ToArray() ).ToArray();
		var bar = MathF.Max( .3f, radius * .25f );
		var width = MathF.Max( 1, radius * .8f );
		var index = 0;
		for ( var d = spacing; d < length - spacing * .25f; d += spacing )
		{
			var f = center.FrameAt( d );
			var up = MathF.Abs( f.Forward.z ) > .99f ? Vector3.Forward : Vector3.Up;
			var frame = new Transform( f.Position, Rotation.LookAt( f.Forward, up ) );
			// Measure where each cable crosses the tie's plane; near bends the cables spread out.
			var hx = 0.0f;
			var hy = 0.0f;
			var found = true;
			foreach ( var points in tracks )
			{
				var best = float.MaxValue;
				var hit = Vector3.Zero;
				var slant = 1.0f;
				for ( var i = 0; i < points.Length - 1; i++ )
				{
					var a = (points[i] - f.Position).Dot( f.Forward );
					var b = (points[i + 1] - f.Position).Dot( f.Forward );
					if ( a * b > 0 || MathF.Abs( a - b ) < 1e-5f )
						continue;
					var p = Vector3.Lerp( points[i], points[i + 1], a / (a - b) );
					if ( p.Distance( f.Position ) >= best )
						continue;
					best = p.Distance( f.Position );
					hit = p;
					slant = MathF.Max( MathF.Abs( (points[i + 1] - points[i]).Normal.Dot( f.Forward ) ), .5f );
				}

				if ( best == float.MaxValue )
				{
					found = false;
					break;
				}

				var local = frame.PointToLocal( hit );
				hx = MathF.Max( hx, MathF.Abs( local.y ) + radius / slant + width * .5f * MathF.Sqrt( 1 - slant * slant ) / slant );
				hy = MathF.Max( hy, MathF.Abs( local.z ) + radius / slant + width * .5f * MathF.Sqrt( 1 - slant * slant ) / slant );
			}

			if ( !found )
				continue;
			hx += .02f;
			hy += .02f;
			root.Add( context.Solid( $"tie{index++}", "Cable Tie", "CableCollar", "cable", context.Material( s.CollarMaterial ?? s.CableMaterial ), kit =>
			{
				kit.Frame = frame;
				kit.Box( new( -width * .5f, -hx - bar, hy ), new( width * .5f, hx + bar, hy + bar ) );
				kit.Box( new( -width * .5f, -hx - bar, -hy - bar ), new( width * .5f, hx + bar, -hy ) );
				kit.Box( new( -width * .5f, hx, -hy ), new( width * .5f, hx + bar, hy ) );
				kit.Box( new( -width * .5f, -hx - bar, -hy ), new( width * .5f, -hx, hy ) );
			} ) );
		}
	}
}

[ToolCategory( "Industrial" ), Title( "Cable Route" ), Icon( "cable" )]
public sealed class CableRouteEditor( PrimitiveTool tool ) : PathEditor( tool )
{
	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string>
	{
		"CurveSegments",
		"Variation",
		"Smooth"
	};

	readonly CableRouteGenerator generator = new();
	public CableRouteSettings Settings { get; } = new();
	public override object ExtraSettings => Settings;
	protected override int MinPoints => 2;
	protected override bool AllowsHeight => true;
	protected override bool CanClose => true;
	protected override bool UsesBends => false;
	protected override bool PlacesObjects => true;

	public override bool ChangesOtherSettings( string property ) => property is nameof( CableRouteSettings.MountsEnabled ) or nameof( CableRouteSettings.MountCollars ) or nameof( CableRouteSettings.MountBolts ) or nameof( CableRouteSettings.MountConnector ) or nameof( CableRouteSettings.Sag ) or nameof( CableRouteSettings.Cables ) or nameof( CableRouteSettings.BundleLayout ) or nameof( CableRouteSettings.TieSpacing );
	protected override void BuildMesh( MeshKit kit )
	{
	}

	void Prepare()
	{
		Settings.Path = Curve();
		Settings.CableMaterial = PartTool.Pick( Settings.CableMaterial, SurfaceMaterial );
		Settings.CollarMaterial = PartTool.Pick( Settings.CollarMaterial, TrimMaterial );
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( generator, Settings, surface: SurfaceIntegration.Context( Settings, SceneEditorSession.Active?.Scene, Transform.Zero ) ).Flatten( Transform.Zero );
	}

	public override void OnUpdate( SceneTrace trace )
	{
		base.OnUpdate( trace );
		if ( Settings.MountsEnabled && Settings.ShowMountDebug )
			SurfaceDebug.Draw( Settings.MountOwner );
	}

	protected override void PlaceObjects()
	{
		Prepare();
		PartTool.Place( generator, Settings, Transform.Zero );
	}
}