editor/AdvancedMappingTools/Procedural/FrameGenerator.cs

Procedural window frame generator for the mapping tools editor. Builds a frame group of box bars (jambs, head, sill, mullions) plus an optional glass pane, with a profiled mode that goes through WindowKit for corner joints and seated glazing. Ships the settings class and the IPartGenerator entry point that wires it into the generator list.

namespace AdvancedMappingTools;

public enum FrameStyle
{
	Wooden,
	Metal,
	Stone
}

public sealed record FrameRequest(
	string Id, string Name, float Width, float Height, float Depth, float BarWidth, FrameStyle Style,
	int VerticalMullions, int HorizontalMullions, bool Sill, bool Glass, float BrokenGlass, float Variation,
	Material FrameMaterial, Material GlassMaterial, WindowSpec Profiled = null );

/// <summary>
/// Settings that can build their window frames from <see cref="WindowKit"/>: profiled bars with
/// real corner joints, glass seated in the frame, glazing bars.
/// </summary>
public interface IProfiledFrameSettings
{
	bool ProfiledFrames { get; }
	FrameCorner FrameCorner { get; }
	FrameProfile FrameProfile { get; }
	WindowDetail FrameDetail { get; }
	Glazing FrameGlazing { get; }
}

/// <summary>
/// A window frame where every bar is its own part. The origin is the middle of the sill line,
/// the frame reaches up along +Z. Frame and glass use separate seeds.
/// </summary>
public static class FrameGenerator
{
	public const string FrameCategory = "frame";
	public const string GlassCategory = "glass";
	public const string Broken = "Broken";
	public const string Intact = "Intact";

	const float CrossingStep = 0.15f;
	const float SillGap = 0.06f;
	const float GlassGap = 0.05f;

	/// <summary>
	/// The frame look of profiled settings, or null for the plain bars of earlier versions.
	/// </summary>
	public static WindowSpec Spec( IProfiledFrameSettings s ) => s is { ProfiledFrames: true }
		? new WindowSpec { Corner = s.FrameCorner, Profile = s.FrameProfile, Detail = s.FrameDetail, Glazing = s.FrameGlazing }
		: null;

	public static PartPlan Generate( FrameRequest request, GenContext context )
	{
		var group = context.Group( request.Id, request.Name, "WindowAssembly", FrameCategory );
		var frameId = $"{request.Id}/frame";
		var frame = group.Add( context.Group( frameId, "Frame", "Frame", FrameCategory ) );
		var stream = context.Stream( FrameCategory, frameId );

		var scale = request.Style switch
		{
			FrameStyle.Metal => 0.6f,
			FrameStyle.Stone => 1.6f,
			_ => 1.0f
		};

		var limit = MathF.Min( request.Width, request.Height ) * 0.2f;
		var bar = MathF.Min( request.BarWidth * scale * (1.0f + request.Variation * stream.Range( -0.3f, 0.3f )), limit );
		var depth = request.Depth * 0.5f * (1.0f + request.Variation * stream.Range( -0.1f, 0.1f ));
		var x = request.Width * 0.5f;
		var z = request.Height;

		void Bar( string name, string label, string kind, Vector3 mins, Vector3 maxs ) =>
			frame.Add( context.Solid( $"{frameId}/{name}", label, kind, FrameCategory, request.FrameMaterial, kit => kit.Box( mins, maxs ) ) );

		if ( request.Profiled is { } look )
		{
			Profiled( request, context, group, frame, frameId, look, x, z, bar, depth );
			if ( request.Sill )
				Bar( "sill", "Window Sill", "Sill", new( -x - 2.0f, -depth - 3.0f, -2.0f ), new( x + 2.0f, depth + 1.0f, -SillGap ) );
			return group;
		}

		Bar( "left", "Left Frame", "FrameBar", new( -x, -depth, 0.0f ), new( -x + bar, depth, z ) );
		Bar( "right", "Right Frame", "FrameBar", new( x - bar, -depth, 0.0f ), new( x, depth, z ) );
		Bar( "top", "Top Frame", "FrameBar", new( -x + bar, -depth, z - bar ), new( x - bar, depth, z ) );
		Bar( "bottom", "Bottom Frame", "FrameBar", new( -x + bar, -depth, 0.0f ), new( x - bar, depth, bar ) );

		var mullion = bar * 0.35f;
		var innerDepth = depth * 0.5f;

		for ( var i = 1; i <= request.VerticalMullions; i++ )
		{
			var at = -x + request.Width * i / (request.VerticalMullions + 1f);
			Bar( $"mullion_v{i}", $"Vertical Mullion {i}", "Mullion", new( at - mullion, -innerDepth, bar ), new( at + mullion, innerDepth, z - bar ) );
		}

		for ( var i = 1; i <= request.HorizontalMullions; i++ )
		{
			var at = z * i / (request.HorizontalMullions + 1f);
			Bar( $"mullion_h{i}", $"Horizontal Mullion {i}", "Mullion", new( -x + bar, -innerDepth + CrossingStep, at - mullion ), new( x - bar, innerDepth - CrossingStep, at + mullion ) );
		}

		if ( request.Sill )
			Bar( "sill", "Window Sill", "Sill", new( -x - 2.0f, -depth - 3.0f, -2.0f ), new( x + 2.0f, depth + 1.0f, -SillGap ) );

		if ( request.Glass )
			group.Add( BuildGlass( request, context, x - bar - GlassGap, bar + GlassGap, z - bar - GlassGap ) );

		return group;
	}

	/// <summary>
	/// The frame from <see cref="WindowKit"/>: one profiled frame with real corner joints, its glazing
	/// bars and seated glass, each a part of its own. A broken pane keeps its jagged look.
	/// </summary>
	static void Profiled( FrameRequest request, GenContext context, PartPlan group, PartPlan frame, string frameId, WindowSpec look, float x, float z, float bar, float depth )
	{
		var mullion = bar * 0.35f;
		var spec = new WindowSpec
		{
			Width = bar,
			Depth = depth * 2.0f,
			Edge = MathF.Min( look.Edge, bar * 0.3f ),
			Corner = look.Corner,
			Profile = look.Profile,
			Detail = look.Detail,
			Glazing = look.Glazing,
			Columns = request.VerticalMullions + 1,
			Rows = request.HorizontalMullions + 1,
			BarWidth = MathF.Max( mullion * 2.0f, 0.5f ),
			Glass = request.Glass,
			FrameMaterial = request.FrameMaterial,
			GlassMaterial = request.GlassMaterial,
			DetailMaterial = request.FrameMaterial
		};

		frame.Add( context.Solid( $"{frameId}/profiled", "Frame", "FrameBar", FrameCategory, request.FrameMaterial,
			kit => WindowKit.Build( kit, -x, x, 0.0f, z, -depth, spec, WindowPieces.Frame ) ) );
		if ( request.VerticalMullions + request.HorizontalMullions > 0 )
			frame.Add( context.Solid( $"{frameId}/bars", "Glazing Bars", "Mullion", FrameCategory, request.FrameMaterial,
				kit => WindowKit.Build( kit, -x, x, 0.0f, z, -depth, spec, WindowPieces.Bars ) ) );

		if ( !request.Glass )
			return;

		var id = $"{request.Id}/glass";
		var broken = context.Variant( id ) switch
		{
			Broken => true,
			Intact => false,
			_ => context.Stream( GlassCategory, id ).Chance( request.BrokenGlass )
		};
		group.Add( broken
			? BuildGlass( request, context, x - bar - GlassGap, bar + GlassGap, z - bar - GlassGap )
			: context.Solid( id, "Glass", "Glass", GlassCategory, request.GlassMaterial, kit => WindowKit.Build( kit, -x, x, 0.0f, z, -depth, spec, WindowPieces.Glass ) ) );
	}

	static PartPlan BuildGlass( FrameRequest request, GenContext context, float x, float z0, float z1 )
	{
		var id = $"{request.Id}/glass";
		var stream = context.Stream( GlassCategory, id );
		var broken = context.Variant( id ) switch
		{
			Broken => true,
			Intact => false,
			_ => stream.Chance( request.BrokenGlass )
		};

		if ( !broken )
		{
			return context.Solid( id, "Glass", "Glass", GlassCategory, request.GlassMaterial,
				kit => kit.Profile( [new( -x, z0 ), new( x, z0 ), new( x, z1 ), new( -x, z1 )], -0.15f, 0.15f ) );
		}

		// A jagged top edge, what is left of a smashed pane.
		const int Teeth = 6;

		var tops = new List<Vector2>();

		for ( var i = 0; i <= Teeth; i++ )
		{
			var t = i / (float)Teeth;
			tops.Add( new Vector2( -x + x * 2.0f * t, z0 + (z1 - z0) * stream.Range( 0.15f, i % 2 == 0 ? 0.75f : 0.4f ) ) );
		}

		Vector3[] Row( IEnumerable<Vector2> line, float y ) => [.. line.Select( p => new Vector3( p.x, y, p.y ) )];

		var bottoms = tops.Select( p => new Vector2( p.x, z0 ) ).ToList();

		return context.Solid( id, "Glass", "Glass", GlassCategory, request.GlassMaterial,
			kit => kit.Fan( Row( bottoms, 0.15f ), Row( tops, 0.15f ), Row( bottoms, -0.15f ), Row( tops, -0.15f ) ) );
	}
}

public sealed class WindowPartsSettings : GeneratorSettings, IProfiledFrameSettings
{
	public float Width { get; set; } = 48.0f;

	[Property, Group( "Window" ), Range( 8, 1024, slider: false ), WideMode]
	public float Height { get; set; } = 56.0f;

	[Property, Group( "Window" ), Range( 1, 128, slider: false ), WideMode]
	public float Depth { get; set; } = 8.0f;

	[Property, Group( "Window" ), Title( "Bar Width" ), Range( 0.5f, 32, slider: false ), WideMode]
	public float BarWidth { get; set; } = 3.0f;

	[Property, Group( "Window" ), WideMode]
	public FrameStyle Style { get; set; } = FrameStyle.Wooden;

	[Property, Group( "Window" ), Title( "Vertical Mullions" ), Range( 0, 16, slider: false ), WideMode]
	public int VerticalMullions { get; set; } = 1;

	[Property, Group( "Window" ), Title( "Horizontal Mullions" ), Range( 0, 16, slider: false ), WideMode]
	public int HorizontalMullions { get; set; } = 1;

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

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

	[Property, ShowIf( nameof( Glass ), true ), Group( "Window" ), Title( "Broken Glass" ), Range( 0, 1 ), WideMode]
	public float BrokenGlass { get; set; } = 0.0f;

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

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

	[Property, Group( "Frame" ), Title( "Profiled Frame" ), WideMode, Description( "A profiled frame with real corner joints, glass seated in it and glazing bars. Off: the plain bars of earlier versions." )]
	public bool ProfiledFrames { get; set; }

	[Property, ShowIf( nameof( ProfiledFrames ), true ), Group( "Frame" ), Title( "Corners" ), WideMode]
	public FrameCorner FrameCorner { get; set; } = FrameCorner.Mitred;

	[Property, ShowIf( nameof( ProfiledFrames ), true ), Group( "Frame" ), Title( "Profile" ), WideMode]
	public FrameProfile FrameProfile { get; set; } = FrameProfile.Chamfered;

	[Property, ShowIf( nameof( ProfiledFrames ), true ), Group( "Frame" ), Title( "Detail" ), WideMode]
	public WindowDetail FrameDetail { get; set; } = WindowDetail.Standard;

	[Property, ShowIf( nameof( ProfiledFrames ), true ), Group( "Frame" ), Title( "Glazing" ), WideMode]
	public Glazing FrameGlazing { get; set; } = Glazing.Double;
}

public sealed class WindowPartsGenerator : IPartGenerator
{
	public string Id => "window";
	public string Title => "Window";
	public Type SettingsType => typeof( WindowPartsSettings );

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (WindowPartsSettings)settings;
		var root = context.Group( "root", "Window", "Building", "frame" );

		root.Add( FrameGenerator.Generate( new FrameRequest( "window", "Window", s.Width, s.Height, s.Depth, s.BarWidth, s.Style,
			s.VerticalMullions, s.HorizontalMullions, s.Sill, s.Glass, s.BrokenGlass, s.Variation,
			context.Material( s.FrameMaterial ), context.Material( s.GlassMaterial ), FrameGenerator.Spec( s ) ), context ) );

		return root;
	}
}