editor/AdvancedMappingTools/Architecture/OrnamentEditors.cs

Editor tool definitions for the AdvancedMappingTools architecture kit. ArchEditor, CurvedArchEditor, VaultEditor, PortalEditor and RoseWindowEditor each generate a preview mesh via MeshKit or place generator-backed parts, with settings classes, material pickers, and a shared ArchTool.Fill helper. ArchEditor.Dock snaps a new arch against the piers of already placed arches.

namespace AdvancedMappingTools;

/// <summary>
/// An arch of independent parts on two piers, or a row of connected arches.
/// The two clicks set the clear length between the outer piers.
/// </summary>
[ToolCategory( "Arches & Vaults" )]
[Title( "Arch" ), Icon( "architecture" )]
public sealed class ArchEditor( PrimitiveTool tool ) : LineEditor( tool )
{
	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "Surface" };

	const float ConnectSlack = 24.0f;
	const float StraightTolerance = 0.35f;

	readonly ArchGenerator _generator = new();

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

	public override object ExtraSettings => Settings;

	protected override bool PlacesObjects => true;
	protected override bool Connects => true;

	protected override void BuildMesh( MeshKit kit ) { }

	Transform Frame => new( Start, Facing );

	/// <summary>
	/// A click near the outer pier of a finished arch puts this arch right against that pier, so
	/// the two stand against each other without a gap, in line or at a right angle from the front
	/// or the back of the pier. It works for either end of the new arch.
	/// </summary>
	protected override Vector3 Dock( Vector3 point )
	{
		var docked = base.Dock( point );

		if ( !ConnectFinished || SceneEditorSession.Active is null ) return docked;

		var best = float.MaxValue;
		var result = docked;
		Vector3? onward = null;
		var own = Settings.Piers && Settings.Shape != ArchShape.Circular ? MathF.Max( Settings.PierWidth, 1.0f ) : 0.0f;

		foreach ( var part in SceneEditorSession.Active.Scene.GetAllComponents<AmtPart>() )
		{
			if ( part.Generator != _generator.Id || !PartReconciler.IsAlive( part ) || string.IsNullOrEmpty( part.Settings ) ) continue;
			if ( PartService.Read( part.GameObject ).Settings is not ArchSettings arch || arch.GetType() != typeof( ArchSettings ) ) continue;

			var pier = arch.Piers && arch.Shape != ArchShape.Circular ? MathF.Max( arch.PierWidth, 1.0f ) : 0.0f;
			var world = part.WorldTransform;
			var forward = world.Rotation.Forward;
			var side = world.Rotation.Left;

			foreach ( var (end, away) in new[] { (world.PointToWorld( Vector3.Zero ), -forward), (world.PointToWorld( Vector3.Forward * arch.Length ), forward) } )
			{
				var middle = end + away * pier * 0.5f;

				// The outer face and the two faces of the pier a new arch can stand against.
				foreach ( var (facing, reach) in new[] { (away, pier * 0.5f), (side, arch.Depth * 0.5f), (-side, arch.Depth * 0.5f) } )
				{
					var face = middle + facing * reach;
					var seat = face + facing * own;

					// This face is taken by the first point, the arch carries on from it.
					if ( Points.Count > 0 && seat.WithZ( 0.0f ).Distance( Points[0].Position.WithZ( 0.0f ) ) < 1.0f )
					{
						onward = facing;
						continue;
					}

					var distance = face.WithZ( 0.0f ).Distance( docked.WithZ( 0.0f ) );

					if ( distance > DockRadius + pier + ConnectSlack || distance >= best ) continue;

					best = distance;
					result = seat;
				}
			}
		}

		if ( best < float.MaxValue )
		{
			DrawLabel( result, "Connects", Color.Green );
			return result;
		}

		// Nearly in line with the arch it starts from: exactly in line, or the piers wedge apart.
		if ( onward is Vector3 line )
		{
			var from = (docked - Points[0].Position).WithZ( 0.0f );
			var along = from.Dot( line );

			if ( along > 0.0f && (from - line * along).Length <= along * StraightTolerance )
				return Points[0].Position + line * along;
		}

		return docked;
	}

	void Prepare()
	{
		Settings.Length = Length;
		ArchTool.Fill( Settings, SurfaceMaterial, TrimMaterial );
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( _generator, Settings ).Flatten( Frame );
	}

	protected override void PlaceObjects()
	{
		Prepare();
		PartTool.Place( _generator, Settings, Frame );
	}
}

/// <summary>
/// Arches that stand along a drawn line or a spiral and bend with it.
/// </summary>
[ToolCategory( "Arches & Vaults" )]
[Title( "Curved Arch" ), Icon( "looks" )]
public sealed class CurvedArchEditor : PathEditor
{
	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "CurveSegments", "Surface" };

	readonly CurvedArchGenerator _generator = new();

	public CurvedArchEditor( PrimitiveTool tool ) : base( tool ) => Smooth = true;

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

	public override object ExtraSettings => Settings;

	protected override int MinPoints => 2;
	protected override int MaxPoints => Settings.Layout == PathLayout.Spiral ? 2 : int.MaxValue;
	protected override bool ConnectPoints => Settings.Layout == PathLayout.Drawn;
	protected override bool PlacesObjects => true;
	protected override bool AllowsHeight => true;
	protected override bool IsShapeValid => Points.Count >= 2 && Points[0].Position.Distance( Points[1].Position ) > 1.0f;

	protected override IEnumerable<string> Hints => Settings.Layout == PathLayout.Spiral
		? ["Point 1: centre", "Point 2: radius and start"]
		: [.. base.Hints, "Shift + drag a point: raise or lower it"];

	protected override void BuildMesh( MeshKit kit ) { }

	void Prepare()
	{
		Settings.Path = Settings.Layout == PathLayout.Spiral
			? PathTool.Spiral( Points[0].Position, Points[1].Position, Settings.Turns, Settings.Clockwise )
			: Curve();

		ArchTool.Fill( Settings, SurfaceMaterial, TrimMaterial );
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( _generator, Settings ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );
	}

	protected override void PlaceObjects()
	{
		Prepare();
		PartTool.Place( _generator, Settings, new Transform( Vector3.Zero, Rotation.Identity ) );
	}
}

/// <summary>
/// A vault of independent webs over a rectangle set by three clicks, in one bay or a row of them.
/// </summary>
[ToolCategory( "Arches & Vaults" )]
[Title( "Vault" ), Icon( "roofing" )]
public sealed class VaultEditor( PrimitiveTool tool ) : RectEditor( tool )
{
	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "Surface", "Variation" };

	readonly VaultGenerator _generator = new();

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

	public override object ExtraSettings => Settings;

	protected override bool PlacesObjects => true;

	protected override void BuildMesh( MeshKit kit ) { }

	void Prepare()
	{
		Settings.Length = RectLength;
		Settings.Depth = RectDepth;
		Settings.VaultMaterial = PartTool.Pick( Settings.VaultMaterial, SurfaceMaterial );
		Settings.RibMaterial = PartTool.Pick( Settings.RibMaterial, TrimMaterial );
		Settings.SupportMaterial = PartTool.Pick( Settings.SupportMaterial, SurfaceMaterial );
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( _generator, Settings ).Flatten( RectFrame );
	}

	protected override void PlaceObjects()
	{
		Prepare();
		PartTool.Place( _generator, Settings, RectFrame );
	}
}

/// <summary>
/// What the arch tools have in common.
/// </summary>
public static class ArchTool
{
	public static void Fill( ArchSettings settings, Material surface, Material trim )
	{
		settings.RingMaterial = PartTool.Pick( settings.RingMaterial, surface );
		settings.KeystoneMaterial = PartTool.Pick( settings.KeystoneMaterial, trim );
		settings.PierMaterial = PartTool.Pick( settings.PierMaterial, surface );
		settings.TrimMaterial = PartTool.Pick( settings.TrimMaterial, trim );
		settings.WallMaterial = PartTool.Pick( settings.WallMaterial, surface );
	}
}

/// <summary>
/// A classical church entrance: paired pilasters, a stepped entablature, a door with a
/// broken pediment, a lunette window above and pinnacles on the corners.
/// The front is to the right of the drawn line.
/// </summary>
[ToolCategory( "Arches & Vaults" )]
[Title( "Portal" ), Icon( "temple_buddhist" )]
public sealed class PortalEditor( PrimitiveTool tool ) : LineEditor( tool )
{
	[Property, Group( "Portal" ), Range( 64, 2048, slider: false ), WideMode]
	public float Height { get; set; } = 256.0f;

	[Property, Group( "Portal" ), Range( 8, 256, slider: false ), WideMode]
	public float Thickness { get; set; } = 32.0f;

	[Property, Group( "Door" ), Title( "Door Width" ), Range( 16, 512, slider: false ), WideMode]
	public float DoorWidth { get; set; } = 72.0f;

	[Property, Group( "Door" ), Title( "Door Height" ), Range( 16, 1024, slider: false ), WideMode]
	public float DoorHeight { get; set; } = 144.0f;

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

	[Property, Group( "Columns" ), Title( "Column Pairs" ), Range( 0, 8, slider: false ), WideMode]
	public int ColumnPairs { get; set; } = 2;

	[Property, Group( "Columns" ), Title( "Column Width" ), Range( 4, 128, slider: false ), WideMode]
	public float ColumnWidth { get; set; } = 28.0f;

	[Property, Group( "Columns" ), Title( "Column Depth" ), Range( 1, 64, slider: false ), WideMode]
	public float ColumnDepth { get; set; } = 10.0f;

	[Property, Group( "Top" ), Title( "Cornice Height" ), Range( 4, 128, slider: false ), WideMode]
	public float CorniceHeight { get; set; } = 28.0f;

	[Property, Group( "Top" ), Title( "Lunette" ), WideMode, Description( "A half round window over the cornice." )]
	public bool Lunette { get; set; } = true;

	[Property, ShowIf( nameof( Lunette ), true ), Group( "Top" ), Title( "Lunette Width" ), Range( 16, 512, slider: false ), WideMode]
	public float LunetteWidth { get; set; } = 96.0f;

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

	[Property, Group( "Steps" ), Title( "Round Steps" ), Range( 0, 16, slider: false ), WideMode, Description( "Half round steps in front of the door." )]
	public int RoundSteps { get; set; } = 3;

	protected override void BuildMesh( MeshKit kit )
	{
		var length = Length;
		var center = length * 0.5f;
		var width = MathF.Min( DoorWidth, length - 8.0f );
		var head = MathF.Min( DoorHeight, Height - 8.0f );
		var x0 = center - width * 0.5f;
		var x1 = center + width * 0.5f;
		var front = -Thickness * 0.5f;

		kit.Frame = LineFrame;
		kit.WallWithOpenings( length, Thickness, Height, [(x0, x1)], 0.0f, head );

		kit.Material = TrimMaterial;

		// Stepped plinth.
		kit.Box( new Vector3( -4.0f, front - ColumnDepth - 4.0f, 0.0f ), new Vector3( x0, front, 16.0f ) );
		kit.Box( new Vector3( x1, front - ColumnDepth - 4.0f, 0.0f ), new Vector3( length + 4.0f, front, 16.0f ) );

		// Door surround.
		kit.OpeningFrame( x0 - 8.0f, x1 + 8.0f, 0.0f, head + 8.0f, 8.0f, Thickness * 0.5f + 3.0f, false );

		if ( Pediment )
		{
			var overhang = width * 0.5f + 20.0f;
			var z = head + 12.0f;

			kit.Box( new Vector3( center - overhang, front - 8.0f, z ), new Vector3( center + overhang, front, z + 6.0f ) );

			foreach ( var sign in new[] { -1.0f, 1.0f } )
			{
				var eave = new Vector3( center + overhang * sign, front - 4.0f, z + 6.0f );
				var apex = new Vector3( center + overhang * 0.25f * sign, front - 4.0f, z + 6.0f + overhang * 0.38f );

				kit.Beam( eave, apex, 8.0f, 5.0f );
			}
		}

		// Column pairs beside the door.
		for ( var i = 0; i < ColumnPairs; i++ )
		{
			var side = MathF.Max( (x0 - 12.0f) / MathF.Max( ColumnPairs, 1 ), ColumnWidth );
			var a = x0 - 16.0f - ColumnWidth - side * i;
			var b = x1 + 16.0f + side * i;

			foreach ( var x in new[] { a, b } )
			{
				if ( x < 0.0f || x + ColumnWidth > length ) continue;

				kit.Box( new Vector3( x, front - ColumnDepth, 16.0f ), new Vector3( x + ColumnWidth, front, Height - CorniceHeight ) );
				kit.Box( new Vector3( x - 3.0f, front - ColumnDepth - 3.0f, 16.0f ), new Vector3( x + ColumnWidth + 3.0f, front, 28.0f ) );
				kit.Box( new Vector3( x - 3.0f, front - ColumnDepth - 3.0f, Height - CorniceHeight - 10.0f ), new Vector3( x + ColumnWidth + 3.0f, front, Height - CorniceHeight ) );
			}
		}

		// Entablature in three steps, each standing further out.
		var bottom = Height - CorniceHeight;

		kit.Box( new Vector3( -4.0f, front - ColumnDepth - 2.0f, bottom ), new Vector3( length + 4.0f, front, bottom + CorniceHeight * 0.4f ) );
		kit.Box( new Vector3( -8.0f, front - ColumnDepth - 6.0f, bottom + CorniceHeight * 0.4f ), new Vector3( length + 8.0f, front, bottom + CorniceHeight * 0.7f ) );
		kit.Box( new Vector3( -14.0f, front - ColumnDepth - 12.0f, bottom + CorniceHeight * 0.7f ), new Vector3( length + 14.0f, Thickness * 0.5f + 4.0f, Height ) );

		if ( Lunette ) BuildLunette( kit, center, front );

		if ( Pinnacles )
		{
			foreach ( var x in new[] { 4.0f, length - 4.0f } )
				kit.Lathe( new Vector3( x, front - 4.0f, Height ), [new( 0, 0 ), new( 9, 0 ), new( 9, 8 ), new( 6, 10 ), new( 1.5f, 44 ), new( 3, 46 ), new( 4, 49 ), new( 3, 52 ), new( 0, 54 )], 8 );
		}

		if ( RoundSteps > 0 )
		{
			kit.Material = TrimMaterial;

			var baseRadius = width * 0.5f + 24.0f;

			for ( var i = 0; i < RoundSteps; i++ )
			{
				var radius = baseRadius + 16.0f * (RoundSteps - 1 - i);
				var z = 5.0f * (i + 1);

				kit.Arc( new Vector3( center, front, 0.0f ), 0.0f, radius, 180.0f, 360.0f, 0.0f, z, 16 );
			}
		}
	}

	void BuildLunette( MeshKit kit, float center, float front )
	{
		var half = MathF.Max( LunetteWidth * 0.5f, 12.0f );
		var band = MathF.Min( 8.0f, half * 0.4f );
		var spring = Height + 4.0f;
		var y = front + 2.0f;
		var outer = MeshKit.ArchCurve( center - half, center + half, spring, half, ArchStyle.Round, 24 );
		var inner = MeshKit.ArchCurve( center - half + band, center + half - band, spring, half - band, ArchStyle.Round, 24 );

		kit.Material = SurfaceMaterial;
		kit.Profile( [new( center - half - 16.0f, Height ), new( center + half + 16.0f, Height ), new( center + half + 16.0f, spring + 8.0f ), .. outer.Reverse(), new( center - half - 16.0f, spring + 8.0f )], y, y + 8.0f );

		kit.Material = TrimMaterial;
		kit.Band( inner, outer, y - 3.0f, y + 2.0f );

		for ( var spoke = 1; spoke < 6; spoke++ )
		{
			var angle = MathF.PI * spoke / 6.0f;
			var end = new Vector3( center - MathF.Cos( angle ) * (half - band), y - 1.0f, spring + MathF.Sin( angle ) * (half - band) );

			kit.Beam( new Vector3( center, y - 1.0f, spring ), end, 1.5f, 1.5f );
		}

		kit.Box( new Vector3( center - half + band, y - 2.0f, spring - 1.0f ), new Vector3( center + half - band, y, spring + 1.0f ) );
	}
}

/// <summary>
/// Round windows standing upright: a rose window with spokes and a ring, or a half round lunette.
/// </summary>
[ToolCategory( "Doors & Windows" )]
[Title( "Rose Window" ), Icon( "brightness_7" )]
public sealed class RoseWindowEditor( PrimitiveTool tool ) : StampEditor( tool )
{
	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "Surface" };

	[Property, Group( "Rose Window" ), Range( 4, 1024, slider: false ), WideMode]
	public float Radius { get; set; } = 48.0f;

	[Property, Group( "Rose Window" ), Title( "Height Above Ground" ), Range( 0, 4096, slider: false ), WideMode]
	public float HeightAboveGround { get; set; } = 128.0f;

	[Property, Group( "Rose Window" ), Range( 3, 32, slider: false ), WideMode]
	public int Spokes { get; set; } = 12;

	[Property, Group( "Rose Window" ), Title( "Half Round" ), WideMode]
	public bool HalfRound { get; set; } = false;

	[Property, Group( "Rose Window" ), Title( "Frame Width" ), Range( 0.5f, 64, slider: false ), WideMode]
	public float FrameWidth { get; set; } = 6.0f;

	[Property, Group( "Rose Window" ), Range( 0.5f, 64, slider: false ), WideMode]
	public float Depth { get; set; } = 3.0f;

	[Property, Group( "Materials" ), Title( "Glass" ), WideMode]
	public Material GlassMaterial { get; set; }

	protected override void Stamp( MeshKit kit, Transform frame, Random random )
	{
		var center = new Vector3( 0.0f, 0.0f, HeightAboveGround );
		var inner = MathF.Max( Radius - FrameWidth, 1.0f );
		var from = HalfRound ? 0.0f : -180.0f;
		var segments = HalfRound ? 24 : 48;

		Vector2 At( float degrees, float radius ) => new(
			MathF.Cos( degrees.DegreeToRadian() ) * radius,
			HeightAboveGround + MathF.Sin( degrees.DegreeToRadian() ) * radius );

		kit.Material = TrimMaterial;

		// A full ring has no ends, its two halves are bands that close on each other.
		var angles = Enumerable.Range( 0, segments + 1 ).Select( i => from + (180.0f - from) * i / segments ).ToArray();

		if ( HalfRound )
		{
			kit.Band( [.. angles.Reverse().Select( a => At( a, inner ) )], [.. angles.Reverse().Select( a => At( a, Radius ) )], -Depth, Depth );
		}
		else
		{
			var halves = new[] { angles[..(segments / 2 + 1)], angles[(segments / 2)..] };

			foreach ( var half in halves )
				kit.Band( [.. half.Reverse().Select( a => At( a, inner ) )], [.. half.Reverse().Select( a => At( a, Radius ) )], -Depth, Depth );
		}

		var hub = inner * 0.18f;
		var sweep = 180.0f - from;

		for ( var s = 0; s < Spokes; s++ )
		{
			var degrees = from + sweep * (s + (HalfRound ? 1.0f : 0.0f)) / (HalfRound ? Spokes + 1f : Spokes);
			var tip = At( degrees, inner );
			var root = At( degrees, hub );

			kit.Beam( new Vector3( root.x, 0.0f, root.y ), new Vector3( tip.x, 0.0f, tip.y ), Depth * 1.6f, MathF.Max( FrameWidth * 0.4f, 1.5f ) );
		}

		if ( HalfRound )
			kit.Box( new Vector3( -Radius, -Depth, HeightAboveGround - FrameWidth ), new Vector3( Radius, Depth, HeightAboveGround ) );

		kit.Material = GlassMaterial ?? TrimMaterial;

		var pane = new List<Vector2>();
		for ( var i = 0; i <= segments; i++ )
			pane.Add( At( from + sweep * i / segments, inner ) );

		kit.Profile( pane, -0.2f, 0.2f );
	}
}