Editor/Bool/ArchBoolUi.cs

Editor UI code for architectural boolean tools. It builds sidebar controls for shaping boolean operations, platforms, ramps, beams, damage/cut presets and which building parts are affected, wiring UI elements to model properties and change/refresh callbacks.

File AccessReflection
using System;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

// Read by the Boolean Modifiers subtool and by Select, off the same records - a draft and a placed shape
// carry identical controls because they ARE the same records. Neither a platform nor a cut had a property
// sheet before this: selecting one showed an empty shelf.
public static class ArchBoolUi
{
	static bool AffectsExpanded
	{
		get => EditorCookie.Get( "arch.bool.affects.expanded", false );
		set => EditorCookie.Set( "arch.bool.affects.expanded", value );
	}

	public static void Shape( ToolSidebarWidget panel, ArchBoolForm form, int segments, Action<ArchBoolForm> reform, Action<int> resegment )
	{
		using ( var grid = ArchIconGrid.In( panel.AddGroup( "Shape" ) ) )
		{
			grid.Pick( "Box — four corners on the grid", "bool_box", "crop_square", form == ArchBoolForm.Box,
				() => reform( ArchBoolForm.Box ) );

			grid.Pick( "Circle — an n-gon inscribed in the drag", "bool_circle", "circle", form == ArchBoolForm.Circle,
				() => reform( ArchBoolForm.Circle ) );
		}

		if ( form != ArchBoolForm.Circle )
		{
			return;
		}

		var sides = panel.AddGroup( "Circle" );

		sides.Add( ArchPartUi.Integer( "Sides", segments, ArchFootprint.LeastSegments,
			value => resegment( Math.Max( ArchFootprint.LeastSegments, value ) ) ) );
	}

	// A tick that GATES the fields under it has to rebuild the sheet as well as commit, or the fields it reveals
	// never arrive and the ones it hides stay put. Handing these only `changed` is why ticking the edge dressing
	// did nothing you could see, and why the coping numbers could not be reached at all.
	static Action Both( Action changed, Action refresh )
	{
		return () =>
		{
			changed?.Invoke();
			refresh?.Invoke();
		};
	}

	public static void Platform( ToolSidebarWidget panel, ArchPlatformPart platform, Action refresh, Action changed )
	{
		var slab = panel.AddGroup( "Solid" );

		slab.Add( ArchPartUi.Number( platform.Ramp ? "Head" : "Rise", platform.Rise, 24f,
			value => platform.TopHeight = platform.GradeHeight + MathF.Max( 4f, value ), changed ) );

		slab.Add( ArchPartUi.Check( "Ramp the deck", platform.Ramp, value => platform.Ramp = value, Both( changed, refresh ) ) );

		if ( platform.Ramp )
		{
			Ramp( panel, platform, "rise", changed );

			return;
		}

		var guard = panel.AddGroup( "Guardrail" );

		guard.Add( ArchPartUi.Check( "Guard rail round the edge", platform.Guardrail, value => platform.Guardrail = value, Both( changed, refresh ) ) );

		if ( platform.Guardrail )
		{
			guard.Add( ArchPartUi.Number( "Height", platform.GuardHeight, 42f,
				value => platform.GuardHeight = MathF.Max( 12f, value ), changed ) );
		}

		var coping = panel.AddGroup( "Coping" );

		coping.Add( ArchPartUi.Check( "Coping band", platform.Coping, value => platform.Coping = value, Both( changed, refresh ) ) );

		if ( !platform.Coping )
		{
			return;
		}

		coping.Add( ArchPartUi.Number( "Height", platform.CopingHeight, 4f, value => platform.CopingHeight = value, changed ) );
		coping.Add( ArchPartUi.Number( "Width", platform.CopingWidth, 8f, value => platform.CopingWidth = value, changed ) );
		coping.Add( ArchPartUi.Number( "Oversail", platform.CopingOversail, 1.5f, value => platform.CopingOversail = value, changed ) );
		coping.Add( ArchPartUi.Check( "Round the inside edges too", platform.CopingInside, value => platform.CopingInside = value, changed ) );
	}

	// Which way it climbs and how far it drops getting to its foot. The gradient is never authored - it falls
	// out of the run the footprint has - so a ramp dragged longer is a gentler one with no second edit. Shared
	// by the platform that rakes its top and the cut that rakes the floor it leaves: same three numbers, one form.
	// A coping is not offered: a run is level, and a raked deck has no one height to walk a band round.
	public static void Ramp( ToolSidebarWidget panel, IArchRamped part, string shelf, Action changed )
	{
		var ramp = panel.AddGroup( "Ramp" );

		ramp.Add( ArchPartUi.Number( "Climb angle", part.RampYaw, 0f,
			value => part.RampYaw = ArchShapeHandles.Facing( value ), changed ) );

		ramp.Add( ArchPartUi.Number( "Fall", part.RampFall, 0f,
			value => part.RampFall = MathF.Max( 0f, value ), changed ) );

		ramp.Add( ArchPartUi.Wrapped( $"Zero fall is the whole {shelf}, so the foot meets the bottom of the band. Type one to leave a level shelf." ) );
	}

	// One member or a field of them, and the field's own numbers. The drop is measured DOWN from the plane
	// it hangs on, so a deeper beam grows into the room rather than up through the ceiling.
	public static void Beam( ToolSidebarWidget panel, ArchBeamPart beam, Action refresh, Action changed )
	{
		using ( var grid = ArchIconGrid.In( panel.AddGroup( "Members" ) ) )
		{
			grid.Pick( "One member — exactly the shape that was dragged", "beam_one", "horizontal_rule", beam.Members == BeamMembers.One,
				() => { beam.Members = BeamMembers.One; changed?.Invoke(); refresh?.Invoke(); } );

			grid.Pick( "Filled — that same shape repeated as slats", "beam_field", "view_stream", beam.Members == BeamMembers.Field,
				() => { beam.Members = BeamMembers.Field; changed?.Invoke(); refresh?.Invoke(); } );
		}

		Section( panel, beam, beam.Members == BeamMembers.Field, changed );
	}

	// Read by the placement tool too, where the Members picker would only repeat the kind already chosen.
	public static void Section( ToolSidebarWidget panel, ArchBeamPart beam, bool field, Action changed )
	{
		var section = panel.AddGroup( "Section" );

		section.Add( ArchPartUi.Number( "Drop", beam.Drop, 12f, value => beam.Drop = MathF.Max( 1f, value ), changed ) );

		if ( !field )
		{
			return;
		}

		section.Add( ArchPartUi.Number( "Member width", beam.MemberWidth, 6f, value => beam.MemberWidth = MathF.Max( 1f, value ), changed ) );
		section.Add( ArchPartUi.Number( "Gap", beam.MemberGap, 10f, value => beam.MemberGap = MathF.Max( 0f, value ), changed ) );
		section.Add( ArchPartUi.Number( "Angle", beam.Yaw, 0f, value => beam.Yaw = value, changed ) );
		section.Add( ArchPartUi.Integer( "Sides", beam.Sides, 0, value => beam.Sides = Math.Max( 0, value ), changed ) );
	}

	public static void Damage( ToolSidebarWidget panel, ArchCutPart cut, Action refresh, Action changed )
	{
		using ( var grid = ArchIconGrid.In( panel.AddGroup( "Damage type" ) ) )
		{
			Damage( grid, cut, ArchDamageKind.Masonry, "Masonry — staggered exposed bricks at wall and pillar corners", "damage_masonry", "view_module", refresh, changed );
			Damage( grid, cut, ArchDamageKind.SurfaceSpall, "Surface spall — a shallow chipped region", "damage_spall", "texture", refresh, changed );
			Damage( grid, cut, ArchDamageKind.MissingPanels, "Missing panels — selected ceiling cells are absent", "damage_missing_panels", "grid_off", refresh, changed );
			Damage( grid, cut, ArchDamageKind.DisplacedPanels, "Displaced panels — selected ceiling cells hang dropped and tilted", "damage_displaced_panels", "view_quilt", refresh, changed );
			Damage( grid, cut, ArchDamageKind.MixedPanels, "Mixed panels — selected ceiling cells are missing or displaced", "damage_mixed_panels", "dashboard", refresh, changed );
		}

		var pattern = panel.AddGroup( "Pattern" );

		if ( cut.ResolvedDamage == ArchDamageKind.Masonry )
		{
			MasonryPresets( pattern, cut, refresh, changed );

			ArchSidebarLayout.Form( pattern, nested =>
				ArchSidebarSection.Disclosure( nested, "Damage.masonry.advanced", "Advanced", true, advanced =>
				{
					advanced.Add( ArchPartUi.Number( "Course coverage", cut.DamageAmount, 1f, value => cut.DamageAmount = Math.Clamp( value, 0f, 1f ), changed ) );
					advanced.Add( ArchPartUi.Number( "Brick width", cut.DamageCellWidth, 16f, value => cut.DamageCellWidth = MathF.Max( 2f, value ), changed ) );
					advanced.Add( ArchPartUi.Number( "Course height", cut.DamageCellLength, 8f, value => cut.DamageCellLength = MathF.Max( 2f, value ), changed ) );
					advanced.Add( ArchPartUi.Number( "Reveal depth", cut.DamageDepth, 6f, value => cut.DamageDepth = MathF.Max( 0.25f, value ), changed ) );
					advanced.Add( ArchPartUi.Check( "Carve wall finish", cut.MasonryCarves, value => cut.MasonryCarves = value, changed ) );
				} ) );
		}
		else if ( ArchDamage.Panels( cut.ResolvedDamage ) )
		{
			pattern.Add( ArchPartUi.Number( "Amount", cut.DamageAmount, 0.55f, value => cut.DamageAmount = Math.Clamp( value, 0f, 1f ), changed ) );
			pattern.Add( ArchPartUi.Number( "Panel width", cut.DamageCellWidth, 24f, value => cut.DamageCellWidth = MathF.Max( 4f, value ), changed ) );
			pattern.Add( ArchPartUi.Number( "Panel length", cut.DamageCellLength, 48f, value => cut.DamageCellLength = MathF.Max( 4f, value ), changed ) );

			if ( cut.ResolvedDamage is ArchDamageKind.DisplacedPanels or ArchDamageKind.MixedPanels )
			{
				pattern.Add( ArchPartUi.Number( "Drop", cut.DamageDrop, 4f, value => cut.DamageDrop = MathF.Max( 0f, value ), changed ) );
				pattern.Add( ArchPartUi.Number( "Tilt", cut.DamageTilt, 12f, value => cut.DamageTilt = MathF.Max( 0f, value ), changed ) );
			}
		}
		else
		{
			pattern.Add( ArchPartUi.Number( "Amount", cut.DamageAmount, 0.55f, value => cut.DamageAmount = Math.Clamp( value, 0f, 1f ), changed ) );
			pattern.Add( ArchPartUi.Number( "Damage depth", cut.DamageDepth, 4f, value => cut.DamageDepth = MathF.Max( 0.5f, value ), changed ) );
		}

		Affects( panel, cut, refresh, changed, ArchDamage.Targets( cut.ResolvedDamage ) );
	}

	static void Damage( ArchIconGrid grid, ArchCutPart cut, ArchDamageKind kind, string tooltip, string slug, string glyph, Action refresh, Action changed )
	{
		grid.Pick( tooltip, slug, glyph, cut.ResolvedDamage == kind, () =>
		{
			cut.Damage = kind;
			cut.BreakEdges = kind == ArchDamageKind.Masonry;
			cut.Affects = ArchDamage.Targets( kind );
			cut.DamageAmount = kind == ArchDamageKind.Masonry ? 1f : cut.DamageAmount;
			cut.DamageCellWidth = kind == ArchDamageKind.Masonry ? 16f : cut.DamageCellWidth;
			cut.DamageCellLength = kind == ArchDamageKind.Masonry ? 8f : cut.DamageCellLength;
			cut.DamageDepth = kind == ArchDamageKind.Masonry ? 6f : cut.DamageDepth;
			cut.MasonryPattern = kind == ArchDamageKind.Masonry ? ArchMasonryPattern.ExposedEdge : cut.MasonryPattern;
			cut.MasonryCarves = kind == ArchDamageKind.Masonry ? false : cut.MasonryCarves;
			changed?.Invoke();
			refresh?.Invoke();
		} );
	}

	static void MasonryPresets( Layout pattern, ArchCutPart cut, Action refresh, Action changed )
	{
		using var presets = ArchIconGrid.In( pattern );

		presets.Pick( "Bricks - simple one-brick boxes extruded from an intact corner", "masonry_bricks", "view_in_ar",
			MasonryMatches( cut, ArchMasonryPattern.ExposedEdge, 16f, 8f, 6f, false ), () => ApplyMasonry( cut, ArchMasonryPattern.ExposedEdge, 16f, 8f, 6f, false, refresh, changed ) );

		presets.Pick( "Edge — two bricks, a header and intact courses at a broken wall edge", "masonry_edge", "view_module",
			MasonryMatches( cut, ArchMasonryPattern.ExposedEdge, 12f, 4f, 1.25f, true ), () => ApplyMasonry( cut, ArchMasonryPattern.ExposedEdge, 12f, 4f, 1.25f, true, refresh, changed ) );
		presets.Pick( "Worn — a fuller broken bond with fewer intact courses", "masonry_worn", "texture",
			MasonryMatches( cut, ArchMasonryPattern.WornCorner, 12f, 4f, 1.75f, true ), () => ApplyMasonry( cut, ArchMasonryPattern.WornCorner, 12f, 4f, 1.75f, true, refresh, changed ) );
		presets.Pick( "Pier — dense alternating long and header courses around square pillars", "masonry_pier", "view_column",
			MasonryMatches( cut, ArchMasonryPattern.BrickPier, 12f, 4f, 0.75f, false ), () => ApplyMasonry( cut, ArchMasonryPattern.BrickPier, 12f, 4f, 0.75f, false, refresh, changed ) );
	}

	static void ApplyMasonry( ArchCutPart cut, ArchMasonryPattern pattern, float width, float course, float depth, bool carves, Action refresh, Action changed )
	{
		cut.MasonryPattern = pattern;
		cut.DamageAmount = 1f;
		cut.DamageCellWidth = width;
		cut.DamageCellLength = course;
		cut.DamageDepth = depth;
		cut.MasonryCarves = carves;

		changed?.Invoke();
		refresh?.Invoke();
	}

	static bool MasonryMatches( ArchCutPart cut, ArchMasonryPattern pattern, float width, float course, float depth, bool carves )
	{
		return cut.MasonryPattern == pattern
			&& cut.MasonryCarves == carves
			&& MathF.Abs( cut.DamageAmount - 1f ) < 0.001f
			&& MathF.Abs( cut.DamageCellWidth - width ) < 0.001f
			&& MathF.Abs( cut.DamageCellLength - course ) < 0.001f
			&& MathF.Abs( cut.DamageDepth - depth ) < 0.001f;
	}

	// A cut that only carves builds nothing, so what it WEARS is the whole form: the walls it carries past
	// the solid it started in, and whatever caps it where it comes out.
	public static void Cut( ToolSidebarWidget panel, ArchCutPart cut, Action refresh, Action changed )
	{
		Affects( panel, cut, refresh, changed );

		var floor = panel.AddGroup( "Floor" );

		floor.Add( ArchPartUi.Check( "Ramp the floor it leaves", cut.Ramp, value => cut.Ramp = value, Both( changed, refresh ) ) );

		if ( cut.Ramp )
		{
			Ramp( panel, cut, "depth", changed );
		}

		var edge = panel.AddGroup( "Opening edge" );
		edge.Add( ArchPartUi.Check( "Dress the cut edge", cut.Edge, value => cut.Edge = value, Both( changed, refresh ) ) );

		if ( cut.Edge )
		{
			edge.Add( ArchPartUi.Number( "Edge width", cut.EdgeWidth, 0f,
				value => cut.EdgeWidth = MathF.Max( 0f, value ), changed ) );
		}

		edge.Add( ArchPartUi.Check( "Break the cut edge", cut.BreakEdges, value => cut.BreakEdges = value, changed ) );
		edge.Add( ArchPartUi.Check( "Rail the stair edges it opens", cut.GuardsOpenedEdges,
			value => cut.GuardsOpenedEdges = value, changed ) );

		using ( var grid = ArchIconGrid.In( panel.AddGroup( "Enclosure" ) ) )
		{
			grid.Pick( "Open — the shaft is the absence in what it passed through", "cut_open", "crop_free",
				cut.Enclosure == CutEnclosure.None, () => { cut.Enclosure = CutEnclosure.None; changed?.Invoke(); refresh?.Invoke(); } );

			grid.Pick( "Walled — it carries its own walls up past the solid it started in", "cut_walled", "crop_square",
				cut.Enclosure == CutEnclosure.Walls, () => { cut.Enclosure = CutEnclosure.Walls; changed?.Invoke(); refresh?.Invoke(); } );
		}

		if ( cut.Enclosure == CutEnclosure.Walls )
		{
			var shell = panel.AddGroup( "Walls" );

			shell.Add( ArchPartUi.Number( "Thickness", cut.WallThickness, 8f, value => cut.WallThickness = value, changed ) );
		}

		using ( var grid = ArchIconGrid.In( panel.AddGroup( "Head" ) ) )
		{
			grid.Pick( "Open to the sky", "cut_head_none", "crop_free",
				cut.Head == CutHead.None, () => { cut.Head = CutHead.None; changed?.Invoke(); refresh?.Invoke(); } );

			grid.Pick( "Capped — a lid over the top", "cut_head_cap", "horizontal_rule",
				cut.Head == CutHead.Cap, () => { cut.Head = CutHead.Cap; changed?.Invoke(); refresh?.Invoke(); } );

			grid.Pick( "Coped — a band round the mouth, finished like an outside edge", "cut_head_coping", "border_top",
				cut.Head == CutHead.Coping, () => { cut.Head = CutHead.Coping; changed?.Invoke(); refresh?.Invoke(); } );

			grid.Pick( "Housing — a little walled box with its own roof", "cut_head_housing", "home",
				cut.Head == CutHead.Housing, () => { cut.Head = CutHead.Housing; changed?.Invoke(); refresh?.Invoke(); } );
		}

		if ( cut.Head == CutHead.Coping )
		{
			var coping = panel.AddGroup( "Coping" );

			coping.Add( ArchPartUi.Number( "Height", cut.CopingHeight, 4f, value => cut.CopingHeight = value, changed ) );
			coping.Add( ArchPartUi.Number( "Oversail", cut.CopingOversail, 1.5f, value => cut.CopingOversail = value, changed ) );
			coping.Add( ArchPartUi.Number( "Width", cut.WallThickness, 8f, value => cut.WallThickness = value, changed ) );

			return;
		}

		if ( cut.Head != CutHead.Housing )
		{
			return;
		}

		var housing = panel.AddGroup( "Housing" );

		housing.Add( ArchPartUi.Number( "Height", cut.HeadHeight, 96f, value => cut.HeadHeight = value, changed ) );
		housing.Add( ArchPartUi.Check( "Roof over it", cut.HeadRoof, value => cut.HeadRoof = value, changed ) );
	}

	static void Affects( ToolSidebarWidget panel, ArchCutPart cut, Action refresh, Action changed, ArchCutAffects allowed = ArchCutAffects.All )
	{
		var open = AffectsExpanded;
		var enabled = Enum.GetValues<ArchCutAffects>()
			.Count( target => target is not (ArchCutAffects.None or ArchCutAffects.All) && (allowed & target) == target && ArchCut.Affects( cut, target ) );
		var targets = Enum.GetValues<ArchCutAffects>().Count( target => target is not (ArchCutAffects.None or ArchCutAffects.All) && (allowed & target) == target );
		var toggle = new Button( $"Affects ({enabled}/{targets})", open ? "expand_less" : "expand_more" )
		{
			Clicked = () => { AffectsExpanded = !open; refresh?.Invoke(); }
		};

		panel.Layout.Add( toggle );

		if ( !open )
		{
			return;
		}

		var affects = panel.AddGroup( "Affects" );

		Affect( affects, cut, allowed, "Floors", ArchCutAffects.Floors, changed );
		Affect( affects, cut, allowed, "Ceilings", ArchCutAffects.Ceilings, changed );
		Affect( affects, cut, allowed, "Foundations", ArchCutAffects.Foundations, changed );
		Affect( affects, cut, allowed, "Walls and corners", ArchCutAffects.Walls, changed );
		Affect( affects, cut, allowed, "Wall fittings", ArchCutAffects.WallFittings, changed );
		Affect( affects, cut, allowed, "Windows and doors", ArchCutAffects.Windows, changed );
		Affect( affects, cut, allowed, "Roofs", ArchCutAffects.Roofs, changed );
		Affect( affects, cut, allowed, "Platforms", ArchCutAffects.Platforms, changed );
		Affect( affects, cut, allowed, "Pillars", ArchCutAffects.Pillars, changed );
		Affect( affects, cut, allowed, "Beams", ArchCutAffects.Beams, changed );
		Affect( affects, cut, allowed, "Stairs", ArchCutAffects.Stairs, changed );
		Affect( affects, cut, allowed, "Trims", ArchCutAffects.Trims, changed );
		Affect( affects, cut, allowed, "Gutters", ArchCutAffects.Gutters, changed );
		Affect( affects, cut, allowed, "Road strips", ArchCutAffects.Roadway, changed );
		Affect( affects, cut, allowed, "Tunnel lining", ArchCutAffects.Lining, changed );
	}

	static void Affect( Layout group, ArchCutPart cut, ArchCutAffects allowed, string label, ArchCutAffects target, Action changed )
	{
		if ( (allowed & target) == target )
		{
			Affect( group, cut, label, target, changed );
		}
	}

	static void Affect( Layout group, ArchCutPart cut, string label, ArchCutAffects target, Action changed )
	{
		group.Add( ArchPartUi.Check( label, ArchCut.Affects( cut, target ), enabled =>
		{
			cut.Affects = enabled ? cut.Affects | target : cut.Affects & ~target;
		}, changed ) );
	}
}