editor/AdvancedMappingTools/Editing/Arrange.cs

Editor utility for the AdvancedMappingTools package. Clones builder objects in rows with a per-copy offset and yaw under one undo step, and reflects builder geometry in place: path points in BuilderStore records, mirrored PathCurve settings for generated parts, and side swapping for HouseSettings doors and windows.

namespace AdvancedMappingTools;

public enum MirrorAxis
{
	[Title( "Across X (left to right)" )] X,
	[Title( "Across Y (front to back)" )] Y
}

/// <summary>
/// Copies in a row and mirror images of finished objects. A copy keeps the record of the
/// object it came from, so it snaps, joins and can be edited like the original.
/// </summary>
public static class Arrange
{
	/// <summary>
	/// <paramref name="copies"/> copies of every object, each one <paramref name="offset"/>
	/// further on and turned <paramref name="yaw"/> degrees more, as one undo step.
	/// </summary>
	public static List<GameObject> Array( IReadOnlyList<GameObject> objects, int copies, Vector3 offset, float yaw )
	{
		var made = new List<GameObject>();

		if ( SceneEditorSession.Active is null || objects.Count == 0 || copies < 1 ) return made;

		using var scope = SceneEditorSession.Scope();
		using ( SceneEditorSession.Active.UndoScope( "Array" ).WithGameObjectCreations().Push() )
		{
			foreach ( var go in objects.Where( x => x.IsValid() ) )
			{
				var record = BuilderStore.LoadRecord( go.Id );

				for ( var i = 1; i <= copies; i++ )
				{
					var clone = go.Clone();

					clone.WorldPosition = go.WorldPosition + offset * i;
					clone.WorldRotation = Rotation.FromYaw( yaw * i ) * go.WorldRotation;
					clone.WorldScale = go.WorldScale;

					if ( record is not null ) BuilderStore.SaveRecord( clone.Id, record );

					made.Add( clone );
				}
			}
		}

		return made;
	}

	/// <summary>
	/// Mirror an object made by a builder: its points are reflected across its own middle and it
	/// is built again in place. Returns why it could not be mirrored, or null.
	/// </summary>
	public static string Mirror( GameObject go, MirrorAxis axis )
	{
		var record = go.IsValid() ? BuilderStore.LoadRecord( go.Id ) : null;

		if ( record is null || record.Points.Count == 0 )
			return MirrorPart( go, axis );

		var points = record.Points.Select( x => new PathPoint( new Vector3( x[0], x[1], x[2] ), x.Length > 3 ? new Vector3( x[3], x[4], x[5] ) : null ) ).ToList();
		var low = points.Select( x => x.Position ).Aggregate( ( a, b ) => Vector3.Min( a, b ) );
		var high = points.Select( x => x.Position ).Aggregate( ( a, b ) => Vector3.Max( a, b ) );
		var middle = (low + high) * 0.5f;

		Vector3 Flip( Vector3 p ) => axis == MirrorAxis.X ? p.WithX( middle.x * 2.0f - p.x ) : p.WithY( middle.y * 2.0f - p.y );

		// A mirror image runs the other way round, or every outline would turn inside out.
		var flipped = new List<PathPoint>( points.Count );

		for ( var i = points.Count - 1; i >= 0; i-- )
		{
			var bend = record.Closed ? points[(i - 1 + points.Count) % points.Count].Bend : i > 0 ? points[i - 1].Bend : null;
			flipped.Add( new PathPoint( Flip( points[i].Position ), bend is Vector3 b ? Flip( b ) : null ) );
		}

		BuilderEditor.Rebuild( go, x => x.ReplacePoints( flipped ) );

		return null;
	}

	/// <summary>
	/// A part object has no points of its own: mirror the path in its settings and let the
	/// generator build it again, so walls, pipes and houses keep their parts and their record.
	/// </summary>
	static string MirrorPart( GameObject go, MirrorAxis axis )
	{
		if ( !go.IsValid() ) return "Nothing selected.";

		var root = PartService.RootOf( go );

		if ( !root.IsValid() ) return $"{go.Name} is not a Davelix Construct object.";

		var (generator, settings) = PartService.Read( root );

		if ( generator is null || settings is null ) return $"{go.Name} has no generator settings to mirror.";

		if ( settings is HouseSettings house )
		{
			MirrorHouse( house, axis );
			RegeneratePart( root, settings );
			return null;
		}

		var pathProperty = settings.GetType().GetProperty( "Path" );

		if ( pathProperty?.GetValue( settings ) is not PathCurve curve || curve.Nodes.Count < 2 )
			return $"{go.Name} has no path in its settings to mirror.";

		pathProperty.SetValue( settings, MirrorPath( curve, axis ) );
		RegeneratePart( root, settings );

		return null;
	}

	static void RegeneratePart( GameObject root, GeneratorSettings settings ) =>
		PartService.Transaction( "Mirror Part", root, () => PartService.Regenerate( root, RegenerationOptions.Everything, true, settings ) );

	/// <summary>
	/// Reflect a path across its own middle and run it the other way round, so its outlines
	/// keep facing outwards.
	/// </summary>
	public static PathCurve MirrorPath( PathCurve curve, MirrorAxis axis )
	{
		var points = curve.Nodes.Select( x => x.Position ).ToList();
		var low = points.Aggregate( ( a, b ) => Vector3.Min( a, b ) );
		var high = points.Aggregate( ( a, b ) => Vector3.Max( a, b ) );
		var middle = (low + high) * 0.5f;

		Vector3 Flip( Vector3 p ) => axis == MirrorAxis.X ? p.WithX( middle.x * 2.0f - p.x ) : p.WithY( middle.y * 2.0f - p.y );

		// Tangents are offsets from their point, so they are reflected as directions.
		Vector3 Turn( Vector3 d ) => axis == MirrorAxis.X ? d.WithX( -d.x ) : d.WithY( -d.y );

		var mirrored = new PathCurve { Closed = curve.Closed, Interpolation = curve.Interpolation };
		var count = curve.Nodes.Count;

		for ( var i = count - 1; i >= 0; i-- )
		{
			var old = curve.Nodes[i];
			var node = old.Copy();

			node.Position = Flip( old.Position );

			// Running the other way, the handle that pointed on now points back.
			node.TangentIn = Turn( old.TangentOut );
			node.TangentOut = Turn( old.TangentIn );

			// Reflecting and reversing both swap left and right, so a lean stays as it is.
			node.Roll = old.Roll;

			// A slope belongs to the stretch to the next point, which is now the one before.
			var before = i > 0 ? curve.Nodes[i - 1] : curve.Closed ? curve.Nodes[count - 1] : null;
			node.Slope = before?.Slope is float slope ? -slope : null;

			mirrored.Nodes.Add( node );
		}

		return mirrored;
	}

	/// <summary>
	/// A house is a rectangle, so a mirror only swaps which side has windows and the door.
	/// </summary>
	static void MirrorHouse( HouseSettings house, MirrorAxis axis )
	{
		if ( axis == MirrorAxis.X )
		{
			(house.WindowsLeft, house.WindowsRight) = (house.WindowsRight, house.WindowsLeft);
			house.DoorSide = house.DoorSide switch
			{
				HouseSide.Left => HouseSide.Right,
				HouseSide.Right => HouseSide.Left,
				_ => house.DoorSide
			};
		}
		else
		{
			(house.WindowsBack, house.WindowsFront) = (house.WindowsFront, house.WindowsBack);
			house.DoorSide = house.DoorSide switch
			{
				HouseSide.Front => HouseSide.Back,
				HouseSide.Back => HouseSide.Front,
				_ => house.DoorSide
			};
		}
	}
}