editor/AdvancedMappingTools/Editing/OpeningCutters.cs

Editor tooling for window and door cutters, in a namespace called AdvancedMappingTools. OpeningCutter holds the shared placement code: it finds the frame of a wall or roof face, traces through the mesh with Scene.Trace to get its thickness, builds the cutter geometry with MeshKit and MeshCutter.SubtractClean, then attaches the finished window, door, bay/bow/garden window, dormer or roof window as a child of the wall or roof. WindowCutterSettings and DormerCutterSettings hold the property sheet options. WindowCutterEditor and DormerCutterEditor are BuilderEditor tools that hover a mesh, preview the outline with Gizmo.Draw and place the element on click inside an undo scope.

namespace AdvancedMappingTools;

/// <summary>What the window cutter puts into a wall.</summary>
public enum CutterElement
{
	Window,
	Door,
	[Title( "Opening Only" )] Opening,
	[Title( "Bay Window" )] BayWindow,
	[Title( "Bow Window" )] BowWindow,
	[Title( "Garden Window" )] GardenWindow
}

/// <summary>What the dormer cutter puts onto a roof.</summary>
public enum RoofElement
{
	Dormer,
	[Title( "Roof Window" )] RoofWindow
}

/// <summary>
/// Options of the window cutter. A plain class so the placing code can be used and tested on its own.
/// </summary>
public sealed class WindowCutterSettings
{
	[Property, Group( "Element" ), Title( "Place" ), WideMode, Description( "A window, a door, or only the opening." )]
	public CutterElement Element { get; set; } = CutterElement.Window;

	[Property, Group( "Element" ), Range( 4, 1024, slider: false ), WideMode]
	public float Width { get; set; } = 48;

	[Property, Group( "Element" ), Range( 4, 1024, slider: false ), WideMode]
	public float Height { get; set; } = 56;

	[Property, Group( "Element" ), Title( "Sill Height" ), Range( 0, 2048, slider: false ), WideMode, Description( "Above the bottom of the clicked wall. Doors stand on the bottom." )]
	public float SillHeight { get; set; } = 36;

	[Property, Group( "Element" ), Title( "Follow Cursor Height" ), WideMode, Description( "Centres the window on the cursor instead of the sill height." )]
	public bool FollowCursorHeight { get; set; }

	[Property, Group( "Element" ), Title( "Snap To Grid" ), WideMode]
	public bool SnapToGrid { get; set; } = true;

	[Property, Group( "Element" ), Title( "Bind To Wall" ), WideMode, Description( "The window becomes a child of the wall and moves with it." )]
	public bool BindToWall { get; set; } = true;

	[Property, Group( "Element" ), Title( "Wall Thickness If Unknown" ), Range( 0.5f, 256, slider: false ), WideMode, Description( "Used when the back of the wall cannot be found." )]
	public float FallbackThickness { get; set; } = 8;

	[Property, Group( "Element" ), Title( "Size Preset" ), WideMode, Description( "Standard uses width, height and sill height. Tall, Storefront and Slit size the window from the wall height." )]
	public WindowKind SizePreset { get; set; } = WindowKind.Standard;

	[Property, Group( "Window Type" ), Title( "Opening Type" ), WideMode]
	public WindowType OpeningType { get; set; } = WindowType.Fixed;

	[Property, Group( "Window Type" ), Title( "Open" ), Range( 0, 1 ), WideMode]
	public float Open { get; set; }

	[Property, Group( "Window Type" ), WideMode]
	public WindowShape Shape { get; set; } = WindowShape.Rectangle;

	[Property, Group( "Window Type" ), WideMode]
	public ArchStyle Arch { get; set; } = ArchStyle.Flat;

	[Property, Group( "Window Type" ), Title( "Transom Light" ), Range( 0, 0.6f ), WideMode]
	public float Transom { get; set; }

	[Property, Group( "Window Type" ), Title( "Storm Sash" ), WideMode]
	public bool Storm { get; set; }

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

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

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

	[Property, Group( "Frame" ), WideMode]
	public WindowDetail Detail { get; set; } = WindowDetail.Standard;

	[Property, Group( "Frame" ), WideMode]
	public Glazing Glazing { get; set; } = Glazing.Double;

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

	[Property, Group( "Frame" ), Title( "Stands Out Of Wall" ), Range( 0, 16, slider: false ), WideMode]
	public float FrameOut { get; set; } = 1;

	[Property, Group( "Frame" ), Title( "Panes Across" ), Range( 1, 16, slider: false ), WideMode]
	public int Columns { get; set; } = 2;

	[Property, Group( "Frame" ), Title( "Panes Up" ), Range( 1, 16, slider: false ), WideMode]
	public int Rows { get; set; } = 2;

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

	[Property, Group( "Door" ), Title( "Door Type" ), WideMode, Description( "Hinged leaves, a sliding leaf on a rail inside, a sectional garage door, or barn leaves on a rail outside." )]
	public DoorKind DoorKind { get; set; } = DoorKind.Hinged;

	[Property, Group( "Door" ), Title( "Movable" ), WideMode, Description( "Hinged leaves become their own objects with a Door component that opens in the game." )]
	public bool Movable { get; set; }

	[Property, Group( "Door" ), Title( "Swing Angle" ), Range( -180, 180, slider: false ), WideMode, Description( "How far a movable door opens." )]
	public float SwingAngle { get; set; } = 90;

	[Property, Group( "Door" ), Title( "Door Style" ), WideMode]
	public DoorStyle DoorStyle { get; set; } = DoorStyle.Panelled;

	[Property, Group( "Door" ), Title( "Leaves" ), Range( 1, 2, slider: false ), WideMode]
	public int Leaves { get; set; } = 1;

	[Property, Group( "Door" ), Title( "Door Open" ), Range( 0, 1 ), WideMode]
	public float DoorOpen { get; set; }

	[Property, Group( "Extras" ), Title( "Iron Bars" ), Range( 0, 32, slider: false ), WideMode, Description( "Bars in front of the window. Windows opening outward stay shut behind them." )]
	public int IronBars { get; set; }

	[Property, Group( "Extras" ), Title( "Boarded Up" ), Range( 0, 16, slider: false ), WideMode, Description( "Planks nailed across the opening." )]
	public int BoardedUp { get; set; }

	[Property, Group( "Extras" ), WideMode, Description( "Two shutters on the wall beside the window." )]
	public bool Shutters { get; set; }

	[Property, Group( "Extras" ), Range( 0, 9999, slider: false ), WideMode]
	public int Seed { get; set; } = 1;

	[Property, Group( "Bay, Bow And Garden" ), Title( "Projection" ), Range( 4, 256, slider: false ), WideMode, Description( "How far the window stands out of the wall." )]
	public float Projection { get; set; } = 24;

	[Property, Group( "Bay, Bow And Garden" ), Title( "Bow Facets" ), Range( 3, 12, slider: false ), WideMode]
	public int BowFacets { get; set; } = 5;

	[Property, Group( "Bay, Bow And Garden" ), Title( "Base" ), WideMode, Description( "A floor under the projecting window." )]
	public bool Base { get; set; } = true;

	[Property, Group( "Bay, Bow And Garden" ), Title( "Roof" ), WideMode, Description( "A roof over it: a slab with overhang, or sloped glass for a garden window." )]
	public bool ProjectingRoof { get; set; } = true;
}

/// <summary>Options of the dormer cutter.</summary>
public sealed class DormerCutterSettings
{
	[Property, Group( "Element" ), Title( "Place" ), WideMode]
	public RoofElement Element { get; set; } = RoofElement.Dormer;

	[Property, Group( "Element" ), Title( "Snap To Grid" ), WideMode]
	public bool SnapToGrid { get; set; } = true;

	[Property, Group( "Element" ), Title( "Bind To Roof" ), WideMode, Description( "The dormer or window becomes a child of the roof and moves and turns with it." )]
	public bool BindToRoof { get; set; } = true;

	[Property, Group( "Element" ), Title( "Roof Thickness If Unknown" ), Range( 0.5f, 256, slider: false ), WideMode]
	public float FallbackThickness { get; set; } = 6;

	[Property, Group( "Dormer" ), Title( "Roof Shape" ), WideMode]
	public DormerShape Shape { get; set; } = DormerShape.Gable;

	[Property, Group( "Dormer" ), Range( 8, 512, slider: false ), WideMode]
	public float Width { get; set; } = 48;

	[Property, Group( "Dormer" ), Title( "Wall Height" ), Range( 4, 512, slider: false ), WideMode]
	public float WallHeight { get; set; } = 32;

	[Property, Group( "Dormer" ), Title( "Roof Rise" ), Range( 1, 256, slider: false ), WideMode]
	public float RoofRise { get; set; } = 16;

	[Property, Group( "Dormer" ), Range( 0, 48, slider: false ), WideMode]
	public float Overhang { get; set; } = 4;

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

	[Property, Group( "Dormer" ), Title( "Window Width" ), Range( 4, 512, slider: false ), WideMode]
	public float DormerWindowWidth { get; set; } = 28;

	[Property, Group( "Dormer" ), Title( "Window Height" ), Range( 4, 512, slider: false ), WideMode]
	public float DormerWindowHeight { get; set; } = 22;

	[Property, Group( "Roof Window" ), Title( "Width" ), Range( 8, 512, slider: false ), WideMode]
	public float WindowWidth { get; set; } = 40;

	[Property, Group( "Roof Window" ), Title( "Length Up The Slope" ), Range( 8, 512, slider: false ), WideMode]
	public float WindowLength { get; set; } = 48;

	[Property, Group( "Roof Window" ), Title( "Height Above Roof" ), Range( 1, 16, slider: false ), WideMode]
	public float Curb { get; set; } = 3;

	[Property, Group( "Roof Window" ), Title( "Panes Across" ), Range( 1, 6, slider: false ), WideMode]
	public int PanesAcross { get; set; } = 1;

	[Property, Group( "Roof Window" ), Title( "Panes Up" ), Range( 1, 6, slider: false ), WideMode]
	public int PanesUp { get; set; } = 1;

	[Property, Group( "Roof Window" ), Title( "Lining" ), WideMode, Description( "Boards in the cut through the roof." )]
	public bool Lining { get; set; } = true;

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

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

	[Property, Group( "Frame" ), WideMode]
	public WindowDetail Detail { get; set; } = WindowDetail.Standard;

	[Property, Group( "Frame" ), WideMode]
	public Glazing Glazing { get; set; } = Glazing.Double;

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

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

	[Property, Group( "Frame" ), Title( "Flashing Width" ), Range( 1, 16, slider: false ), WideMode]
	public float FlashingWidth { get; set; } = 3;
}

/// <summary>
/// Placing windows and doors into walls and dormers and roof windows onto roofs that already
/// exist: the frame of the element from the clicked face, the thickness from a trace through it,
/// the cut, and the element as a child of the wall or roof.
/// </summary>
public static class OpeningCutter
{
	static readonly Material DefaultGlass = Material.Load( "materials/glass/glass_a.vmat" );

	/// <summary>
	/// How thick a body is behind a point of its surface, along <paramref name="inward"/>: the
	/// distance to its first face seen from far behind. The fallback when nothing is found.
	/// </summary>
	public static float Thickness( GameObject target, Vector3 point, Vector3 inward, float fallback )
	{
		const float reach = 512.0f;
		if ( target?.Scene is not { } scene ) return fallback;
		var from = point + inward * reach;
		try
		{
			var hits = scene.Trace.Ray( from, point - inward * 0.5f ).UseRenderMeshes( true ).UsePhysicsWorld( false ).RunAll()
				.Where( r => r.Hit && r.GameObject == target && r.Distance < reach - 0.05f ).OrderBy( r => r.Distance );
			foreach ( var hit in hits )
				return Math.Clamp( reach - hit.Distance, 0.5f, reach );
		}
		catch ( Exception ) { }

		return fallback;
	}

	/// <summary>
	/// The frame of a window in a wall at a point of its face: X along the wall, Z up, Y into the
	/// wall, the origin at the foot of the wall below the point. Null when the face is not upright.
	/// </summary>
	public static Transform? WallFrame( Vector3 point, Vector3 normal, float bottom )
	{
		var n = normal.WithZ( 0.0f );
		if ( n.Length < 0.5f ) return null;
		n = n.Normal;
		var along = Vector3.Cross( Vector3.Up, n ).Normal;
		return new Transform( point.WithZ( bottom ), Rotation.LookAt( along, Vector3.Up ) );
	}

	/// <summary>The hole in wall coordinates: x0, x1, z0, z1.</summary>
	public static (float X0, float X1, float Z0, float Z1) WallHole( WindowCutterSettings s, float cursorZ, float wallHeight = 128 )
	{
		var w = Math.Clamp( s.Width, 4, 1024 );
		var h = Math.Clamp( s.Height, 4, 1024 );
		var wall = MathF.Max( wallHeight, 16 );
		if ( s.Element == CutterElement.Window && s.SizePreset != WindowKind.Standard )
		{
			// The same sizes as the Window tool's types.
			var (z, height) = s.SizePreset switch
			{
				WindowKind.Storefront => (0.5f, wall - 2.5f),
				WindowKind.Slit => (wall * 0.45f, MathF.Min( 18.0f, wall * 0.25f )),
				_ => (wall * 0.12f, wall * 0.62f)
			};
			return (-w * 0.5f, w * 0.5f, z, z + height);
		}

		// Doors stand on the foot of the wall; windows on their sill, or around the cursor.
		var z0 = s.FollowCursorHeight ? MathF.Max( cursorZ - h * 0.5f, 0 ) : s.Element == CutterElement.Door ? 0 : MathF.Max( s.SillHeight, 0 );
		return (-w * 0.5f, w * 0.5f, z0, z0 + h);
	}

	/// <summary>Windows that open outward cannot open behind bars or boards.</summary>
	static bool Blocked( WindowCutterSettings s ) => (s.IronBars > 0 || s.BoardedUp > 0) && s.OpeningType is WindowType.Casement or WindowType.DoubleCasement or WindowType.Awning or WindowType.Accordion or WindowType.PivotHorizontal or WindowType.PivotVertical;

	static WindowUnitSpec Unit( WindowCutterSettings s, float thickness, Material trim, Material glass ) => new()
	{
		Type = s.OpeningType,
		Open = Blocked( s ) ? 0 : s.Open,
		Transom = s.Transom,
		Storm = s.Storm,
		Hardware = s.Hardware,
		HardwareMaterial = trim,
		Look = new WindowSpec
		{
			Width = Math.Clamp( s.FrameWidth, 0.5f, 16 ),
			Depth = thickness + Math.Clamp( s.FrameOut, 0, 16 ) * 2.0f,
			Edge = MathF.Min( 0.75f, s.FrameWidth * 0.3f ),
			Corner = s.Corner,
			Profile = s.Profile,
			Detail = s.Detail,
			Glazing = s.Glazing,
			Columns = s.SizePreset == WindowKind.Storefront ? 3 : Math.Clamp( s.Columns, 1, 16 ),
			Rows = s.SizePreset is WindowKind.Storefront or WindowKind.Slit ? 1 : Math.Clamp( s.Rows, 1, 16 ),
			BarWidth = MathF.Max( s.FrameWidth * 0.5f, 0.5f ),
			Shape = s.Shape,
			Arch = s.Arch,
			FrameMaterial = trim,
			GlassMaterial = glass,
			DetailMaterial = trim
		}
	};

	/// <summary>The outline the wall is cut along, in wall coordinates.</summary>
	public static Vector2[] WallOutline( WindowCutterSettings s, (float X0, float X1, float Z0, float Z1) hole )
	{
		var unit = Unit( s, 8, null, null );
		if ( s.Element != CutterElement.Window )
			unit.Type = WindowType.Fixed;
		if ( s.Element is CutterElement.BayWindow or CutterElement.BowWindow or CutterElement.GardenWindow )
			return WindowKit.Rect( hole.X0, hole.X1, hole.Z0, hole.Z1 );
		if ( s.Element == CutterElement.Door )
		{
			// A door is square, and its opening reaches a little below the foot of the wall so no
			// sliver of wall stays under the threshold.
			unit.Look.Shape = WindowShape.Rectangle;
			unit.Look.Arch = ArchStyle.Flat;
			var z0 = hole.Z0 <= 0.01f ? -1.0f : hole.Z0;
			return WindowKit.Rect( hole.X0, hole.X1, z0, hole.Z1 );
		}

		return WindowUnit.Outline( hole.X0, hole.X1, hole.Z0, hole.Z1, unit );
	}

	/// <summary>
	/// Cuts the opening into the wall and builds the window or door in it. Returns the new object,
	/// a child of the wall when bound, or null for an opening only.
	/// </summary>
	public static GameObject PlaceInWall( MeshComponent wall, Vector3 point, Vector3 normal, float cursorZ, WindowCutterSettings s, Material trim, Material glass = null )
	{
		if ( !wall.IsValid() || wall.Mesh is null ) throw new InvalidOperationException( "Click a wall made of a mesh." );
		// The wall's own size, from its mesh: object bounds are not known yet in a scene that has not drawn.
		var bounds = wall.Mesh.CalculateBounds( wall.WorldTransform );
		var bottom = bounds.Mins.z;
		var frame = WallFrame( point, normal, bottom ) ?? throw new InvalidOperationException( "Click the side of a wall, not its top or bottom." );
		var thickness = Thickness( wall.GameObject, point, -normal.WithZ( 0 ).Normal, Math.Clamp( s.FallbackThickness, 0.5f, 256 ) );
		var hole = WallHole( s, cursorZ - bottom, bounds.Size.z );
		var outline = WallOutline( s, hole );

		// The cut: the outline straight through the wall.
		var cutter = new MeshKit { Material = wall.Mesh.FaceHandles.Select( f => wall.Mesh.GetFaceMaterial( f ) ).FirstOrDefault() };
		WindowKit.Loft( cutter, [.. outline.Select( p => new Vector3( p.x, -1.0f, p.y ) )], [.. outline.Select( p => new Vector3( p.x, thickness + 1.0f, p.y ) )] );
		var relative = wall.WorldTransform.ToLocal( frame );
		var reveal = cutter.Material;
		var result = MeshCutter.SubtractClean( wall.Mesh, cutter.Finish(), relative, reveal ) ?? throw new InvalidOperationException( "The opening did not cut through this wall." );
		wall.Mesh = result;
		wall.RebuildMesh();

		if ( s.Element == CutterElement.Opening )
			return null;

		var kit = new MeshKit { Material = trim };
		var front = -Math.Clamp( s.FrameOut, 0, 16 );
		var unit = Unit( s, thickness, trim, glass ?? DefaultGlass );
		List<(Transform Hinge, PolygonMesh Mesh, float Turn)> leaves = null;
		if ( s.Element == CutterElement.Door )
		{
			unit.Look.Shape = WindowShape.Rectangle;
			unit.Look.Arch = ArchStyle.Flat;
			if ( s.Movable && s.DoorKind == DoorKind.Hinged )
				leaves = [];
			WindowUnit.BuildDoor( kit, hole.X0, hole.X1, hole.Z0, hole.Z1, front, new DoorSpec { Look = unit.Look, Kind = s.DoorKind, Style = s.DoorStyle, Leaves = s.Leaves, Open = s.DoorOpen, Hardware = s.Hardware, LeafMaterial = trim, HardwareMaterial = trim, Mount = Math.Clamp( s.FrameOut, 0, 16 ), SeparateLeaves = leaves } );
		}
		else if ( s.Element is CutterElement.BayWindow or CutterElement.BowWindow or CutterElement.GardenWindow )
		{
			Projecting( kit, s, hole, unit.Look, trim, glass ?? DefaultGlass );
		}
		else
		{
			WindowUnit.Build( kit, hole.X0, hole.X1, hole.Z0, hole.Z1, front, unit );
			if ( s.Sill && hole.Z0 > 1.6f && s.Shape != WindowShape.Round )
			{
				kit.BeginPart();
				WindowKit.Sill( kit, hole.X0, hole.X1, hole.Z0, 0, Math.Clamp( s.FrameOut, 0, 16 ) + 2.5f, 1.5f, 2.0f );
			}

			Extras( kit, s, hole, front, unit.Look.Width, trim );
		}

		var name = s.Element switch { CutterElement.Door => "Door", CutterElement.BayWindow => "Bay Window", CutterElement.BowWindow => "Bow Window", CutterElement.GardenWindow => "Garden Window", _ => "Window" };
		var made = Attach( wall.GameObject, frame, kit.Finish(), name, s.BindToWall );
		if ( leaves is not null )
		{
			// Each hinged leaf its own object at its hinge, opening in the game.
			var doors = new List<Sandbox.Mapping.Door>();
			foreach ( var (hinge, mesh, turn) in leaves )
			{
				var leaf = new GameObject( made, true, "Door Leaf" );
				leaf.LocalTransform = hinge;
				leaf.IsStatic = false;
				var component = leaf.Components.Create<MeshComponent>( false );
				component.Mesh = mesh;
				component.SmoothingAngle = 40.0f;
				component.Enabled = true;
				var door = leaf.Components.Create<Sandbox.Mapping.Door>();
				door.TargetAngle = s.SwingAngle * turn;
				doors.Add( door );
			}

			if ( doors.Count == 2 )
			{
				doors[0].LinkedDoor = doors[1];
				doors[1].LinkedDoor = doors[0];
			}
		}

		return made;
	}

	/// <summary>
	/// Iron bars in front of the frame, boards in front of them, shutters on the wall beside the hole.
	/// </summary>
	static void Extras( MeshKit kit, WindowCutterSettings s, (float X0, float X1, float Z0, float Z1) hole, float front, float frame, Material trim )
	{
		kit.Material = trim;
		var outsideStorm = s.Storm && s.OpeningType is not (WindowType.Casement or WindowType.DoubleCasement or WindowType.Awning or WindowType.Accordion or WindowType.PivotHorizontal or WindowType.PivotVertical);
		var left = hole.X0 + frame;
		var right = hole.X1 - frame;
		var bars = Math.Clamp( s.IronBars, 0, 32 );
		for ( var b = 1; b <= bars; b++ )
		{
			var x = left + (right - left) * b / (bars + 1f);
			kit.BeginPart();
			kit.Box( new Vector3( x - 0.5f, front - 1.0f, hole.Z0 + 0.05f ), new Vector3( x + 0.5f, front, hole.Z1 ) );
		}

		var random = new Random( s.Seed );
		var boards = Math.Clamp( s.BoardedUp, 0, 16 );
		for ( var b = 0; b < boards; b++ )
		{
			var z = hole.Z0 + frame + (hole.Z1 - hole.Z0 - frame * 2.0f) * (b + 0.5f) / boards;
			var tilt = ((float)random.NextDouble() - 0.5f) * 8.0f;
			var y = front - (bars > 0 ? 2.0f : 1.0f) - (outsideStorm ? 3.0f : 0.0f);
			kit.BeginPart();
			kit.Beam( new Vector3( hole.X0 - 4.0f, y, z - tilt ), new Vector3( hole.X1 + 4.0f, y, z + tilt ), 1.5f, 5.0f );
		}

		if ( s.Shutters )
		{
			var half = (hole.X1 - hole.X0) * 0.5f;
			kit.BeginPart();
			kit.Box( new Vector3( hole.X0 - half, -1.0f, hole.Z0 ), new Vector3( hole.X0 - 0.01f, 0, hole.Z1 ) );
			kit.BeginPart();
			kit.Box( new Vector3( hole.X1 + 0.01f, -1.0f, hole.Z0 ), new Vector3( hole.X1 + half, 0, hole.Z1 ) );
		}
	}

	/// <summary>
	/// A window standing out of the wall: facets of glass in frames between corner posts, on a base,
	/// under a roof. Bay: three facets, the sides at 45 degrees; bow: a curve of facets; garden: a box
	/// with glass sides and a sloped glass top. Wall coordinates: the wall face at y = 0, outside -Y.
	/// </summary>
	static void Projecting( MeshKit kit, WindowCutterSettings s, (float X0, float X1, float Z0, float Z1) hole, WindowSpec look, Material trim, Material glass )
	{
		float x0 = hole.X0, x1 = hole.X1, z0 = hole.Z0, z1 = hole.Z1;
		var width = x1 - x0;
		var p = Math.Clamp( s.Projection, 4, 256 );
		var points = new List<Vector2>();
		switch ( s.Element )
		{
			case CutterElement.BowWindow:
			{
				var n = Math.Clamp( s.BowFacets, 3, 12 );
				for ( var k = 0; k <= n; k++ )
					points.Add( new Vector2( x0 + width * k / n, -p * MathF.Sin( MathF.PI * k / n ) ) );
				break;
			}
			case CutterElement.GardenWindow:
				points.AddRange( [new( x0, 0 ), new( x0, -p ), new( x1, -p ), new( x1, 0 )] );
				break;
			default:
			{
				var side = MathF.Min( p, width * 0.35f );
				points.AddRange( [new( x0, 0 ), new( x0 + side, -p ), new( x1 - side, -p ), new( x1, 0 )] );
				break;
			}
		}

		var depth = MathF.Max( look.Width + 1.0f, 3.0f );
		var facet = WindowUnit.Copy( look );
		facet.Depth = depth;
		facet.Shape = WindowShape.Rectangle;
		facet.Arch = ArchStyle.Flat;
		facet.Columns = 1;
		facet.FrameMaterial = trim;
		facet.GlassMaterial = glass;

		// Gaps at the joints so the frames meet the corner posts and not each other.
		Vector2 Dir( int i ) => (points[i + 1] - points[i]).Normal;
		var gaps = new float[points.Count];
		for ( var i = 1; i + 1 < points.Count; i++ )
		{
			var turn = MathF.Acos( Math.Clamp( Vector2.Dot( Dir( i - 1 ), Dir( i ) ), -1, 1 ) );
			gaps[i] = depth * MathF.Tan( turn * 0.5f ) + 0.3f;
		}

		var origin = kit.Frame;
		kit.Material = trim;
		for ( var i = 0; i + 1 < points.Count; i++ )
		{
			var d = Dir( i );
			var length = (points[i + 1] - points[i]).Length;
			var start = gaps[i];
			var end = length - gaps[i + 1];
			if ( end - start < 2.0f ) continue;
			kit.Frame = origin.ToWorld( new Transform( new Vector3( points[i].x, points[i].y, 0 ), Rotation.LookAt( new Vector3( d.x, d.y, 0 ), Vector3.Up ) ) );
			WindowKit.Build( kit, start, end, z0, z1, 0, facet );
			kit.Frame = origin;
		}

		// Corner posts fill the joints between the facets.
		for ( var i = 1; i + 1 < points.Count; i++ )
		{
			var j = points[i];
			var d0 = Dir( i - 1 );
			var d1 = Dir( i );
			var n0 = new Vector2( -d0.y, d0.x );
			var n1 = new Vector2( -d1.y, d1.x );
			var a = j - d0 * gaps[i];
			var b = j + d1 * gaps[i];
			Vector3 V( Vector2 q ) => new( q.x, q.y, 0 );
			kit.Material = trim;
			kit.Prism( [V( a ), V( j ), V( b ), V( b + n1 * depth ), V( a + n0 * depth )], z0, z1 );
		}

		var middle = new Vector2( (x0 + x1) * 0.5f, 0 );
		if ( s.Base )
		{
			kit.Material = trim;
			kit.Prism( [.. points.Select( q => new Vector3( q.x, q.y, 0 ) )], z0 - MathF.Min( 6.0f, MathF.Max( z0, 0.5f ) ), z0 );
		}

		if ( !s.ProjectingRoof ) return;
		if ( s.Element == CutterElement.GardenWindow )
		{
			// A sloped glass top, high at the wall.
			var rise = MathF.Min( p * 0.5f, 12.0f );
			kit.Material = glass;
			kit.Hex( new Vector3( x0, 0, z1 ), new Vector3( x0, -p, z1 ), new Vector3( x1, -p, z1 ), new Vector3( x1, 0, z1 ),
				new Vector3( x0, 0, z1 + rise + 0.5f ), new Vector3( x0, -p, z1 + 0.5f ), new Vector3( x1, -p, z1 + 0.5f ), new Vector3( x1, 0, z1 + rise + 0.5f ) );
			return;
		}

		// A roof slab reaching a little past the facets, never into the wall.
		var over = 2.0f;
		var roof = points.Select( q => q.y > -0.01f ? new Vector2( q.x + MathF.Sign( q.x - middle.x ) * over, 0 ) : q + (q - middle).Normal * over ).ToList();
		kit.Material = trim;
		kit.Prism( [.. roof.Select( q => new Vector3( q.x, q.y, 0 ) )], z1, z1 + 4.0f );
	}

	static GameObject Attach( GameObject host, Transform frame, PolygonMesh mesh, string name, bool bind )
	{
		var go = new GameObject( bind ? host : host.Parent, true, name );
		go.WorldTransform = frame;
		go.IsStatic = host.IsStatic;
		var component = go.Components.Create<MeshComponent>( false );
		component.Mesh = mesh;
		component.SmoothingAngle = 40.0f;
		component.Enabled = true;
		return go;
	}

	/// <summary>
	/// The frame of a dormer on a sloped face at a point: X along the eave, Y up the slope seen
	/// from above, Z up; the roof surface rises toward +Y with the returned pitch. Null when the face
	/// is too flat or too steep to be a roof.
	/// </summary>
	public static (Transform Frame, float Pitch)? RoofFrame( Vector3 point, Vector3 normal )
	{
		var n = normal.Normal;
		if ( n.z < 0.15f || n.z > 0.985f ) return null;
		var downhill = n.WithZ( 0.0f ).Normal;
		var along = Vector3.Cross( Vector3.Up, downhill ).Normal;
		return (new Transform( point, Rotation.LookAt( along, Vector3.Up ) ), MathF.Acos( Math.Clamp( n.z, -1, 1 ) ) * 180.0f / MathF.PI);
	}

	/// <summary>
	/// The frame of a roof window lying in the slope at a point: X along the eave, Z up the slope,
	/// -Y out of the roof.
	/// </summary>
	public static Transform SlopeFrame( Vector3 point, Vector3 normal )
	{
		var n = normal.Normal;
		var along = Vector3.Cross( Vector3.Up, n.WithZ( 0.0f ).Normal ).Normal;
		var up = Vector3.Cross( n, along ).Normal;
		if ( up.z < 0 ) up = -up;
		if ( Vector3.Dot( Vector3.Cross( along, up ), n ) < 0 ) along = -along;
		return new Transform( point, Rotation.LookAt( along, up ) );
	}

	/// <summary>
	/// Puts a dormer or a roof window onto a roof at a point of a sloped face, turned to that face.
	/// A roof window cuts a real opening through the roof.
	/// </summary>
	public static GameObject PlaceOnRoof( MeshComponent roof, Vector3 point, Vector3 normal, DormerCutterSettings s, Material surface, Material trim, Material glass = null )
	{
		if ( !roof.IsValid() || roof.Mesh is null ) throw new InvalidOperationException( "Click a roof made of a mesh." );
		var slope = RoofFrame( point, normal ) ?? throw new InvalidOperationException( "Click a sloped roof face." );
		glass ??= DefaultGlass;
		var kit = new MeshKit { Material = trim };
		var look = new WindowSpec { Width = Math.Clamp( s.FrameWidth, 0.5f, 16 ), Corner = s.Corner, Profile = s.Profile, Detail = s.Detail, Glazing = s.Glazing, FrameMaterial = trim, GlassMaterial = glass, DetailMaterial = trim };

		if ( s.Element == RoofElement.Dormer )
		{
			var spec = new DormerSpec
			{
				Shape = s.Shape,
				Width = s.Width,
				WallHeight = s.WallHeight,
				Pitch = slope.Pitch,
				RoofRise = s.RoofRise,
				Overhang = s.Overhang,
				Window = s.DormerWindow,
				WindowWidth = s.DormerWindowWidth,
				WindowHeight = s.DormerWindowHeight,
				Flashing = s.Flashing,
				FlashingWidth = s.FlashingWidth,
				BodyMaterial = trim,
				RoofMaterial = surface ?? trim,
				TrimMaterial = trim,
				Frame = new WindowSpec { Width = 2.0f, Depth = 2.5f, Corner = s.Corner, Profile = s.Profile, Detail = s.Detail, Glazing = s.Glazing, FrameMaterial = trim, GlassMaterial = glass, DetailMaterial = trim }
			};
			DormerKit.Build( kit, spec );
			return Attach( roof.GameObject, slope.Frame, kit.Finish(), "Dormer", s.BindToRoof );
		}

		// A roof window: the opening through the roof, a frame standing on it, flashing, lining.
		var frame = SlopeFrame( point, normal );
		var thickness = Thickness( roof.GameObject, point, -normal.Normal, Math.Clamp( s.FallbackThickness, 0.5f, 256 ) );
		var w = Math.Clamp( s.WindowWidth, 8, 512 );
		var l = Math.Clamp( s.WindowLength, 8, 512 );
		float x0 = -w * 0.5f, x1 = w * 0.5f, z0 = -l * 0.5f, z1 = l * 0.5f;
		var cutter = new MeshKit { Material = trim };
		cutter.Box( new Vector3( x0, -1.0f, z0 ), new Vector3( x1, thickness + 1.0f, z1 ) );
		var result = MeshCutter.SubtractClean( roof.Mesh, cutter.Finish(), roof.WorldTransform.ToLocal( frame ), trim ) ?? throw new InvalidOperationException( "The opening did not cut through this roof." );
		roof.Mesh = result;
		roof.RebuildMesh();

		var bar = MathF.Min( look.Width, MathF.Min( w, l ) * 0.2f );
		var lap = bar * 0.5f;
		var curb = Math.Clamp( s.Curb, 1, 16 );
		look.Width = bar;
		look.Depth = curb;
		look.Edge = MathF.Min( 0.75f, bar * 0.3f );
		look.Columns = Math.Clamp( s.PanesAcross, 1, 6 );
		look.Rows = Math.Clamp( s.PanesUp, 1, 6 );
		WindowKit.Build( kit, x0 - lap, x1 + lap, z0 - lap, z1 + lap, -curb, look );
		if ( s.Flashing )
		{
			kit.BeginPart();
			var sheet = Math.Clamp( s.FlashingWidth, 1, 16 );
			// A hair above the roof, so its thin edge never touches the roof faces.
			WindowKit.Flashing( kit, x0 - lap, x1 + lap, z0 - lap, z1 + lap, -0.05f, sheet, sheet, MathF.Min( curb * 0.6f, 2 ) );
		}

		if ( s.Lining && thickness > 0.6f )
		{
			kit.BeginPart();
			WindowKit.Lining( kit, x0, x1, z0, z1, 0, thickness, MathF.Min( 0.5f, MathF.Min( w, l ) * 0.05f ) );
		}

		return Attach( roof.GameObject, frame, kit.Finish(), "Roof Window", s.BindToRoof );
	}
}

/// <summary>
/// Puts windows, doors or plain openings into any wall: hover a wall to see where it goes, click to
/// cut the opening and place the element, bound to the wall.
/// </summary>
[ToolCategory( "Cutters" )]
[Title( "Window Cutter" ), Icon( "window" )]
public sealed class WindowCutterEditor( PrimitiveTool tool ) : BuilderEditor( tool )
{
	protected override int MinPoints => int.MaxValue;
	protected override IEnumerable<string> Hints => ["Hover a wall: the outline shows where it goes", "Click: cut the opening and place the window or door"];
	public WindowCutterSettings Settings { get; } = new();
	public override object ExtraSettings => Settings;
	protected override void BuildMesh( MeshKit kit ) { }

	public override void OnUpdate( SceneTrace trace )
	{
		var tr = trace.Run();
		var mesh = tr.GameObject?.GetComponent<MeshComponent>();
		if ( !tr.Hit || !mesh.IsValid() || mesh.Mesh is null ) return;

		var point = Settings.SnapToGrid ? Gizmo.Snap( tr.EndPosition, new Vector3( 1, 1, 0 ) ).WithZ( tr.EndPosition.z ) : tr.EndPosition;
		var n = tr.Normal.WithZ( 0 );
		if ( n.Length < 0.5f ) return;
		point -= n.Normal * Vector3.Dot( point - tr.EndPosition, n.Normal );
		var wallBounds = mesh.Mesh.CalculateBounds( mesh.WorldTransform );
		var bottom = wallBounds.Mins.z;
		if ( OpeningCutter.WallFrame( point, tr.Normal, bottom ) is not { } frame ) return;

		var hole = OpeningCutter.WallHole( Settings, point.z - bottom, wallBounds.Size.z );
		var outline = OpeningCutter.WallOutline( Settings, hole );
		var thickness = OpeningCutter.Thickness( mesh.GameObject, point, -n.Normal, Settings.FallbackThickness );
		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.LineThickness = 2;
		Gizmo.Draw.Color = Settings.Element == CutterElement.Door ? Color.Orange : Color.Cyan;
		for ( var i = 0; i < outline.Length; i++ )
		{
			var a = outline[i];
			var b = outline[(i + 1) % outline.Length];
			Gizmo.Draw.Line( frame.PointToWorld( new Vector3( a.x, 0, a.y ) ), frame.PointToWorld( new Vector3( b.x, 0, b.y ) ) );
			Gizmo.Draw.Line( frame.PointToWorld( new Vector3( a.x, thickness, a.y ) ), frame.PointToWorld( new Vector3( b.x, thickness, b.y ) ) );
			if ( i % Math.Max( 1, outline.Length / 4 ) == 0 )
				Gizmo.Draw.Line( frame.PointToWorld( new Vector3( a.x, 0, a.y ) ), frame.PointToWorld( new Vector3( a.x, thickness, a.y ) ) );
		}

		if ( !Gizmo.WasLeftMousePressed || Gizmo.HasHovered ) return;

		try
		{
			using var scope = SceneEditorSession.Scope();
			using ( SceneEditorSession.Active.UndoScope( $"Place {Settings.Element}" ).WithComponentChanges( mesh ).WithGameObjectCreations().Push() )
				OpeningCutter.PlaceInWall( mesh, point, tr.Normal, point.z, Settings, TrimMaterial );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"Window Cutter: {ex.Message}" );
		}
	}
}

/// <summary>
/// Puts dormers or roof windows onto any roof: hover a sloped face, the dormer turns to it; click to
/// place it, bound to the roof. Roof windows cut a real opening.
/// </summary>
[ToolCategory( "Cutters" )]
[Title( "Dormer Cutter" ), Icon( "roofing" )]
public sealed class DormerCutterEditor( PrimitiveTool tool ) : BuilderEditor( tool )
{
	protected override int MinPoints => int.MaxValue;
	protected override IEnumerable<string> Hints => ["Hover a sloped roof face: the dormer turns to it", "Click: place it on the roof"];
	public DormerCutterSettings Settings { get; } = new();
	public override object ExtraSettings => Settings;
	protected override void BuildMesh( MeshKit kit ) { }

	public override void OnUpdate( SceneTrace trace )
	{
		var tr = trace.Run();
		var mesh = tr.GameObject?.GetComponent<MeshComponent>();
		if ( !tr.Hit || !mesh.IsValid() || mesh.Mesh is null ) return;
		var point = tr.EndPosition;
		if ( Settings.SnapToGrid )
		{
			var snapped = Gizmo.Snap( point, new Vector3( 1, 1, 0 ) );
			// Back onto the face along the vertical.
			var n = tr.Normal.Normal;
			point = MathF.Abs( n.z ) > 0.01f ? snapped.WithZ( point.z - Vector3.Dot( snapped.WithZ( point.z ) - point, n ) / n.z ) : point;
		}

		if ( OpeningCutter.RoofFrame( point, tr.Normal ) is not { } slope ) return;
		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.LineThickness = 2;
		Gizmo.Draw.Color = Color.Cyan;
		var tan = MathF.Tan( slope.Pitch * MathF.PI / 180.0f );
		Vector3 On( Transform f, float x, float y, float z ) => f.PointToWorld( new Vector3( x, y, y * tan + z ) );
		if ( Settings.Element == RoofElement.Dormer )
		{
			var h = Settings.Width * 0.5f;
			var top = Settings.WallHeight + 3 + (Settings.Shape is DormerShape.Shed or DormerShape.Flat ? 0 : Settings.RoofRise);
			var back = top / MathF.Max( tan, 0.05f );
			var f = slope.Frame;
			Gizmo.Draw.Line( On( f, -h, 0, 0 ), On( f, h, 0, 0 ) );
			Gizmo.Draw.Line( On( f, h, 0, 0 ), On( f, h, back, 0 ) );
			Gizmo.Draw.Line( On( f, -h, 0, 0 ), On( f, -h, back, 0 ) );
			Gizmo.Draw.Line( f.PointToWorld( new Vector3( -h, 0, Settings.WallHeight ) ), f.PointToWorld( new Vector3( h, 0, Settings.WallHeight ) ) );
			Gizmo.Draw.Line( f.PointToWorld( new Vector3( -h, 0, 0 ) ), f.PointToWorld( new Vector3( -h, 0, Settings.WallHeight ) ) );
			Gizmo.Draw.Line( f.PointToWorld( new Vector3( h, 0, 0 ) ), f.PointToWorld( new Vector3( h, 0, Settings.WallHeight ) ) );
		}
		else
		{
			var f = OpeningCutter.SlopeFrame( point, tr.Normal );
			var w = Settings.WindowWidth * 0.5f;
			var l = Settings.WindowLength * 0.5f;
			Vector3[] corners = [f.PointToWorld( new Vector3( -w, 0, -l ) ), f.PointToWorld( new Vector3( w, 0, -l ) ), f.PointToWorld( new Vector3( w, 0, l ) ), f.PointToWorld( new Vector3( -w, 0, l ) )];
			for ( var i = 0; i < 4; i++ )
				Gizmo.Draw.Line( corners[i], corners[(i + 1) % 4] );
		}

		if ( !Gizmo.WasLeftMousePressed || Gizmo.HasHovered ) return;

		try
		{
			using var scope = SceneEditorSession.Scope();
			using ( SceneEditorSession.Active.UndoScope( $"Place {Settings.Element}" ).WithComponentChanges( mesh ).WithGameObjectCreations().Push() )
				OpeningCutter.PlaceOnRoof( mesh, point, tr.Normal, Settings, SurfaceMaterial, TrimMaterial );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"Dormer Cutter: {ex.Message}" );
		}
	}
}