Editor/Tool/Subtools/ArchRoofSubtool.cs

Editor subtool for placing and editing roofs and roof lights in the architecture tool. Draws previews, builds roof or roof-light geometry on drag, validates placement, and exposes UI options for roof style, eave drop, ridge run, and light form.

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

[Title( "Roof" ), Icon( "roofing" ), Group( "08" )]
public sealed class ArchRoofSubtool( ArchTool owner ) : ArchSubtool( owner )
{
	protected override ArchKind? DraftKind => ArchKind.Roof;

	public override ArchSurface[] Surfaces => new[] { ArchSurface.Roof, ArchSurface.RoofEdge, ArchSurface.Soffit, ArchSurface.Gutter, ArchSurface.WallCap, ArchSurface.Plant };

	readonly ArchRoofPart draft = new() { Style = RoofStyle.Hip };
	readonly ArchRoofLightPart lightDraft = new();

	RidgeRun ridge = RidgeRun.Auto;
	float eaveDrop;

	// A fitting goes on a deck already there rather than standing a new section - explicit so a miss says so.
	bool fitting;

	protected override string Title() => fitting ? lightDraft.Form.Label() : "Roof";

	protected override string Advice()
	{
		return fitting
			? lightDraft.Form.Advice()
			: "Drag the roof footprint. Drop the eave to step a section down under a taller one.";
	}

	protected override void DrawPreview()
	{
		if ( fitting )
		{
			DrawLightPreview();
			return;
		}

		var min = Min( DragStart, DragCurrent );
		var max = Max( DragStart, DragCurrent );
		var eave = Owner.LevelHeight + MathF.Max( 32f, Owner.Kit.WallHeight - MathF.Max( 0f, eaveDrop ) );

		ArchGhost.Pitch( min, max, eave, Owner.Kit.RoofPitch, draft.Style, ArchBuild.Ridged( ridge, min, max ) );
		ArchGhost.Note( new Vector3( max.x, max.y, eave ), $"{draft.Style} eave {eave:0}" );
	}

	void DrawLightPreview()
	{
		var min = Min( DragStart, DragCurrent );
		var max = Max( DragStart, DragCurrent );
		var roof = ArchRoofLight.Under( Owner.Plan, Owner.Level, DragStart, lightDraft.Form );

		if ( roof is null )
		{
			var over = ArchRoofLight.Over( Owner.Plan, Owner.Level, DragStart );

			ArchGhost.Plate( min, max, Owner.LevelHeight, 8 );
			ArchGhost.Note( new Vector3( max.x, max.y, Owner.LevelHeight ),
				over is null
					? $"no deck here for a {lightDraft.Form.Label().ToLowerInvariant()}"
					: $"{over.Name} is a {over.Style} — {Carried()}" );

			return;
		}

		if ( Levelled( roof ) )
		{
			ArchGhost.Plate( min, max, Owner.LevelHeight, 8 );
			ArchGhost.Note( new Vector3( max.x, max.y, Owner.LevelHeight ),
				$"{roof.Name} is flat — a dormer needs a slope to break out of" );

			return;
		}

		// Off the deck plane under the drag, never the eave: a plant item on a pitch reads where it will stand.
		var deck = ArchRoofGen.Seat( roof, Owner.Kit, min, max, lightDraft.CurbWidth );
		var top = deck + ArchRoofGen.Stack( lightDraft, Owner.Kit ).Top;

		ArchGhost.Ring( roof.Outline(), deck );
		ArchGhost.Volume( min, max, deck, top );

		var note = $"{lightDraft.Form.Label()} {max.x - min.x:0} x {max.y - min.y:0} on {roof.Name} at deck {deck:0}";

		if ( roof.Style == RoofStyle.Gable )
		{
			note += " — across the ridge gives one per slope";
		}

		ArchGhost.Note( new Vector3( max.x, max.y, top ), note );
	}

	protected override void OnDrag( Vector2 from, Vector2 to )
	{
		if ( fitting )
		{
			CarveLight( from, to );
			return;
		}

		var min = Min( from, to );
		var max = Max( from, to );

		if ( (max - min).Length < 8f )
		{
			return;
		}

		var building = Owner.ActiveBuilding();
		var height = Owner.LevelHeight + MathF.Max( 32f, Owner.Kit.WallHeight - MathF.Max( 0f, eaveDrop ) );

		var roof = ArchBuild.Roof( Owner.Plan, Owner.Kit, Owner.Level, draft.Style, min, max, height, draft.Gutters, draft.Parapet, ridge );
		roof.Name = $"Roof{building.Roofs.Count + 1}";

		building.Roofs.Add( roof );
		FinishDraft( roof.Id );
		Owner.Commit();
	}

	void CarveLight( Vector2 from, Vector2 to )
	{
		var roof = ArchRoofLight.Under( Owner.Plan, Owner.Level, from, lightDraft.Form );

		if ( roof is null )
		{
			var over = ArchRoofLight.Over( Owner.Plan, Owner.Level, from );

			Log.Info( over is null
				? "Architecture: no roof section under that drag at all. A light is cut out of a deck already there, and plant stands on one."
				: $"Architecture: {over.Name} is a {over.Style}, and a {lightDraft.Form.Label().ToLowerInvariant()} is not carried by one. {Carried()}." );

			return;
		}

		if ( Levelled( roof ) )
		{
			Log.Info( $"Architecture: {roof.Name} is flat, and a dormer breaks OUT of a slope — a level deck has no"
				+ " downhill for it to face. Put one on a Shed or a Gable, or use a Monitor for a raised glazed box on the flat." );

			return;
		}

		if ( ArchRoofLight.Add( Owner.Plan, roof, from, to, lightDraft ) is not { } light )
		{
			Log.Info( $"Architecture: that drag is smaller than {ArchRoofLight.MinSize} inches on a side, so there is no light in it." );
			return;
		}

		FinishDraft( light.Id );
		Owner.Commit( $"Add {light.Form.Label()}" );
	}

	// A dormer is the one form with a direction, and it takes it from the fall of the deck it stands on.
	bool Levelled( ArchRoofPart roof ) => lightDraft.Form.Roofed() && roof.Style == RoofStyle.Flat;

	// What the draft's own form is carried by, so a refusal names the sections that would take it rather than
	// reciting the same list of three whatever was picked.
	string Carried()
	{
		return lightDraft.Form.Roofed()
			? "a dormer wants a slope, so Shed, Gable and Hip carry one and a Flat does not"
			: "Flat, Shed and Gable carry one; a Hip is a skeleton of triangles and trapezoids, which the carve grid cannot hold";
	}

	// Every form comes off its own stock - a kerb is a flashing upstand, a housing is a low wall, a flue and a tank
	// come off the kit's plant sizes - so switching form restocks it. An unchanged pick keeps whatever was typed.
	void Restock( RoofLightForm form )
	{
		if ( lightDraft.Form == form )
		{
			return;
		}

		lightDraft.Form = form;
		lightDraft.Curb = form.RiseStock( Owner.Kit );
		lightDraft.CurbWidth = form.ReachStock( Owner.Kit );
		lightDraft.PaneSpan = form.PaneStock();
	}

	protected override void BuildOptions( ToolSidebarWidget panel )
	{
		ArchSidebarSection.Show( panel, Scope( "kind" ), "Kind", group =>
		{
			using var kind = ArchIconGrid.In( group );

			kind.Pick( "Section — a roof over the footprint you drag", "roof_hip", "roofing", !fitting,
				() => { fitting = false; Refresh(); } );

			foreach ( var value in Enum.GetValues<RoofLightForm>() )
			{
				var form = value;

				kind.Pick( $"{form.Label()} — {form.Advice()}", form.Slug(), form.Glyph(), fitting && lightDraft.Form == form,
					() => { fitting = true; Restock( form ); Refresh(); } );
			}
		} );

		if ( fitting )
		{
			ArchRoofUi.Light( panel, lightDraft, Owner.Kit, Refresh, null );

			panel.Layout.Add( ArchSidebarLayout.Advice( "Stands on the deck plane under it, never a typed height, so it follows the pitch — and clips to the deck it lands on." ) );

			return;
		}

		ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", group =>
		{
			group.Add( ArchPartUi.Number( "Eave drop", eaveDrop, 0f, value => eaveDrop = value ) );

			// A hip or a flat has no ridge to run, so the row decides nothing on them.
			if ( !ArchRoofPlane.Ridged( draft.Style ) )
			{
				return;
			}

			using var grid = ArchIconGrid.In( group );

			foreach ( var value in Enum.GetValues<RidgeRun>() )
			{
				var captured = value;

				grid.Pick( ArchIcons.RidgeAdvice( captured ), ArchIcons.RidgeSlug( captured ),
					ArchIcons.RidgeGlyph( captured ), ridge == captured, () => ridge = captured );
			}
		} );

		ArchRoofUi.Build( panel, draft, Refresh, null );
	}
}