Editor/Tool/ArchElevation.cs

Editor-side elevation cutter and representation for architectural pieces. Computes a 2D elevation (front/side) slice through an ArchBuilding, producing pieces (walls, openings, floors, roofs, platforms, cuts) with sampled profiles for raked surfaces; provides methods to test intersections, sample profiles, and query which piece is at a point.

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

namespace Sunless.Architecture;

// The role colours the piece; Item is what the pick highlights against.
public enum ArchElevationRole
{
	Wall,
	Opening,
	Floor,
	Platform,
	Cut,
	Roof
}

public sealed class ArchElevationPiece
{
	public ArchElevationRole Role { get; init; }
	public object Item { get; init; }
	public int Floor { get; init; }
	// Along the drawn axis - y in Front, x in Side.
	public float From { get; init; }
	public float To { get; init; }
	public float Foot { get; init; }
	public float Head { get; init; }
	// Raking samples of the two surfaces, station for station; both null when the piece is a plain band, which is
	// nearly everything. Carried rather than derived from one and a thickness, because a WEDGE has no thickness:
	// a ramp's underside is level while its top climbs, and a section drawn off an envelope box says slab.
	public List<Vector2> Profile { get; init; }
	public List<Vector2> Soffit { get; init; }

	public bool Rakes => Profile is { Count: >= 2 } && Soffit is { Count: >= 2 };
}

// Front cuts at x and draws in y; Side the reverse. Every plan-to-drawn conversion goes through here - backwards is plausible and wrong.
public readonly struct ArchElevationPlane
{
	public ArchViewAxis Axis { get; init; }
	public float Depth { get; init; }

	public bool IsElevation => Axis is ArchViewAxis.Front or ArchViewAxis.Side;

	public float Across( Vector2 point ) => Axis == ArchViewAxis.Front ? point.y : point.x;

	public float Along( Vector2 point ) => Axis == ArchViewAxis.Front ? point.x : point.y;

	public Vector2 Plan( float across ) => Axis == ArchViewAxis.Front ? new Vector2( Depth, across ) : new Vector2( across, Depth );

	public Vector3 At( float across, float height )
	{
		var plan = Plan( across );

		return new Vector3( plan.x, plan.y, height );
	}
}

// Resolves ONE building, because that is what the overlay draws, and EVERY storey, because a section exists to show the floors stack.
public static class ArchElevation
{
	const float Grain = ArchCarve.Grain;

	// Fine enough that a deck break lands near a sample, coarse enough that a long roof stays cheap.
	const int Samples = 48;

	// The plan and the kit, not the tool: the whole section resolve is answerable without a viewport, and asking
	// for one is what kept it out of the harness.
	public static ArchElevationCut Cut( ArchPlan plan, ArchKit kit, ArchBuilding building, ArchViewAxis axis, float depth )
	{
		var plane = new ArchElevationPlane { Axis = axis, Depth = depth };
		var pieces = new List<ArchElevationPiece>();

		if ( building is not null && plane.IsElevation )
		{
			var lift = ArchAsks.Lift( plan, building, kit );

			foreach ( var room in building.Rooms )
			{
				Rooms( pieces, plane, room, lift, kit );
			}

			foreach ( var roof in building.Roofs )
			{
				Roofs( pieces, plane, roof, lift );
			}

			foreach ( var platform in building.Platforms )
			{
				Solid( pieces, plane, platform.Outline(), ArchElevationRole.Platform, platform, platform.Level,
					ArchCarvePlane.Level( platform.GradeHeight ), ArchRamp.Deck( platform ) );
			}

			foreach ( var cut in building.Cuts )
			{
				foreach ( var segment in cut.Legs() )
				{
					Solid( pieces, plane, segment.Outline(), ArchElevationRole.Cut, cut, cut.Level,
						ArchCut.Floor( cut, segment ), ArchCarvePlane.Level( segment.TopHeight ) );
				}
			}
		}

		return new ArchElevationCut
		{
			Building = building,
			Axis = axis,
			Depth = depth,
			Pieces = pieces
		};
	}

	static void Rooms( List<ArchElevationPiece> pieces, ArchElevationPlane plane, ArchRoom room, float lift, ArchKit kit )
	{
		var floor = room.BaseHeight + lift;

		if ( room.HasFloor )
		{
			Add( pieces, plane, ArchFloorGen.Footprint( room ), ArchElevationRole.Floor, room, room.Floor,
				floor - MathF.Max( 1f, kit.FloorThickness ), floor );
		}

		foreach ( var wall in room.Walls )
		{
			if ( wall.Length < 0.5f )
			{
				continue;
			}

			var head = floor + ArchWallSection.Height( wall, room, kit );

			if ( !Add( pieces, plane, ArchWallSection.Band( wall, kit ), ArchElevationRole.Wall, wall, room.Floor, floor, head ) )
			{
				continue;
			}

			Openings( pieces, plane, wall, room, floor, kit );
		}
	}

	// Only the plane through the HOLE shows the opening - an inch over shows solid wall.
	static void Openings( List<ArchElevationPiece> pieces, ArchElevationPlane plane, ArchWall wall, ArchRoom room, float floor, ArchKit kit )
	{
		if ( !Meets( wall, plane, out var along ) )
		{
			return;
		}

		foreach ( var opening in wall.Openings.Where( entry => along >= entry.Left && along <= entry.Right ) )
		{
			Add( pieces, plane, ArchWallSection.Band( wall, kit ), ArchElevationRole.Opening, opening, room.Floor,
				floor + opening.SillHeight, floor + opening.Top );
		}
	}

	// A wall parallel to the plane never crosses it - the case a straight interpolation would divide by zero on.
	static bool Meets( ArchWall wall, ArchElevationPlane plane, out float along )
	{
		along = 0f;

		var start = plane.Along( wall.Start ) - plane.Depth;
		var end = plane.Along( wall.End ) - plane.Depth;

		if ( MathF.Abs( start - end ) < Grain )
		{
			return false;
		}

		var fraction = start / (start - end);

		if ( fraction < 0f || fraction > 1f )
		{
			return false;
		}

		along = fraction * wall.Length;

		return true;
	}

	static void Roofs( List<ArchElevationPiece> pieces, ArchElevationPlane plane, ArchRoofPart roof, float lift )
	{
		var outline = roof.Outline();

		if ( !Crosses( outline, plane, out var from, out var to ) )
		{
			return;
		}

		var thickness = MathF.Max( 1f, roof.Thickness );
		var profile = Sampled( plane, from, to, across => ArchRoofPlane.At( roof, plane.Plan( across ) ) + lift );
		var soffit = profile.Select( sample => sample.WithY( sample.y - thickness ) ).ToList();

		Raking( pieces, ArchElevationRole.Roof, roof, roof.Level, from, to, soffit, profile );
	}

	// A body between two SURFACES. Level on both, it is the plain band every other piece is; raked on either, it
	// carries both traces so a section draws the wedge rather than the box it fits inside.
	static void Solid(
		List<ArchElevationPiece> pieces,
		ArchElevationPlane plane,
		IReadOnlyList<Vector2> loop,
		ArchElevationRole role,
		object item,
		int floor,
		ArchCarvePlane under,
		ArchCarvePlane over )
	{
		if ( !under.Rakes && !over.Rakes )
		{
			Add( pieces, plane, loop, role, item, floor, under.Datum, over.Datum );

			return;
		}

		if ( !Crosses( loop, plane, out var from, out var to ) )
		{
			return;
		}

		Raking( pieces, role, item, floor, from, to,
			Sampled( plane, from, to, across => under.At( plane.Plan( across ) ) ),
			Sampled( plane, from, to, across => over.At( plane.Plan( across ) ) ) );
	}

	static void Raking(
		List<ArchElevationPiece> pieces,
		ArchElevationRole role,
		object item,
		int floor,
		float from,
		float to,
		List<Vector2> soffit,
		List<Vector2> profile )
	{
		pieces.Add( new ArchElevationPiece
		{
			Role = role,
			Item = item,
			Floor = floor,
			From = from,
			To = to,
			Foot = soffit.Min( point => point.y ),
			Head = profile.Max( point => point.y ),
			Profile = profile,
			Soffit = soffit
		} );
	}

	static List<Vector2> Sampled( ArchElevationPlane plane, float from, float to, Func<float, float> height )
	{
		var samples = new List<Vector2>();

		for ( var step = 0; step <= Samples; step++ )
		{
			var across = MathX.Lerp( from, to, step / (float)Samples );

			samples.Add( new Vector2( across, height( across ) ) );
		}

		return samples;
	}

	static bool Add(
		List<ArchElevationPiece> pieces,
		ArchElevationPlane plane,
		IReadOnlyList<Vector2> loop,
		ArchElevationRole role,
		object item,
		int floor,
		float foot,
		float head )
	{
		if ( !Crosses( loop, plane, out var from, out var to ) )
		{
			return false;
		}

		pieces.Add( new ArchElevationPiece
		{
			Role = role,
			Item = item,
			Floor = floor,
			From = from,
			To = to,
			Foot = MathF.Min( foot, head ),
			Head = MathF.Max( foot, head )
		} );

		return true;
	}

	// An edge lying ON the plane contributes both ends, or a wall exactly at the section depth vanishes.
	public static bool Crosses( IReadOnlyList<Vector2> loop, ArchElevationPlane plane, out float from, out float to )
	{
		from = float.MaxValue;
		to = float.MinValue;

		if ( loop is not { Count: >= 2 } )
		{
			return false;
		}

		for ( var index = 0; index < loop.Count; index++ )
		{
			var a = loop[index];
			var b = loop[(index + 1) % loop.Count];
			var here = plane.Along( a ) - plane.Depth;
			var next = plane.Along( b ) - plane.Depth;

			if ( MathF.Abs( here ) < Grain && MathF.Abs( next ) < Grain )
			{
				Take( ref from, ref to, plane.Across( a ) );
				Take( ref from, ref to, plane.Across( b ) );

				continue;
			}

			if ( here > 0f == next > 0f )
			{
				continue;
			}

			Take( ref from, ref to, MathX.Lerp( plane.Across( a ), plane.Across( b ), here / (here - next) ) );
		}

		return to - from > Grain;
	}

	static void Take( ref float from, ref float to, float value )
	{
		from = MathF.Min( from, value );
		to = MathF.Max( to, value );
	}
}

// Held between frames - re-cutting the whole house sixty times a second is the resolve the overlay refuses.
public sealed class ArchElevationCut
{
	public ArchBuilding Building { get; init; }
	public ArchViewAxis Axis { get; init; }
	public float Depth { get; init; }
	public List<ArchElevationPiece> Pieces { get; init; } = new();

	public ArchElevationPlane Plane => new() { Axis = Axis, Depth = Depth };

	// Smallest area first, so a door beats its wall - the more specific thing wins.
	public ArchElevationPiece At( float across, float height )
	{
		return Pieces
			.Where( piece => across >= piece.From && across <= piece.To && height >= piece.Foot && height <= piece.Head )
			.OrderBy( piece => (piece.To - piece.From) * (piece.Head - piece.Foot) )
			.FirstOrDefault();
	}

	public bool Matches( ArchBuilding building, ArchViewAxis axis, float depth )
	{
		return ReferenceEquals( Building, building ) && Axis == axis && MathF.Abs( Depth - depth ) < 0.01f;
	}
}