Editor/Wall/ArchLadder.cs

Editor helper for placing and resolving architectural ladders. Defines ArchLadderShape with geometry helpers and static ArchLadder methods to place a ladder on a plan, compute its heights, rungs/brackets/hoops, and top/head/grade based on building, kit and plan data.

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

namespace Sunless.Architecture;

public readonly struct ArchLadderShape
{
	public Vector2 Anchor { get; init; }
	public Vector2 Facing { get; init; }
	public Vector2 Along { get; init; }
	public float Foot { get; init; }
	public float Head { get; init; }
	public float Bar { get; init; }
	public float Back { get; init; }
	public float Front { get; init; }
	public float HalfWidth { get; init; }
	public List<float> Rungs { get; init; }
	public List<float> Brackets { get; init; }
	public List<float> Hoops { get; init; }
	public float CageReach { get; init; }
	public float ReturnRise { get; init; }
	public float ReturnReach { get; init; }

	public Vector2 Stile( float side ) => Anchor + Along * (HalfWidth * side);

	public float Top => Head + ReturnRise;

	public List<Vector2> Hoop()
	{
		var left = Stile( -1f );
		var right = Stile( 1f );

		return new List<Vector2>
		{
			left + Facing * Front,
			left + Facing * CageReach,
			right + Facing * CageReach,
			right + Facing * Front
		};
	}
}

public static class ArchLadder
{
	// Every building, because ActiveBuilding falls back to the empty placeholder.
	public static ArchLadderPart Place( ArchPlan plan, ArchKit kit, Vector2 point, ArchLadderPart draft, out ArchBuilding host )
	{
		return Place( plan?.Buildings, plan, kit, point, draft, out host );
	}

	// Handed the buildings, because a recipe hangs a ladder on a building not yet filed in the plan.
	public static ArchLadderPart Place( IEnumerable<ArchBuilding> buildings, ArchPlan plan, ArchKit kit, Vector2 point, ArchLadderPart draft, out ArchBuilding host )
	{
		host = null;

		if ( buildings is null || plan is null || draft is null )
		{
			return null;
		}

		// Level null, unlike a balcony or a fire escape: a ladder climbs the whole elevation and resolves its
		// top from whatever deck, parapet or wall plate it reaches, so it is not scoped to one storey.
		var station = ArchFixtureAnchor.At( buildings, null, new ArchGridService(), point, draft.Width, plan );

		return Place( plan, kit, station, draft, out host );
	}

	public static ArchLadderPart Place( ArchPlan plan, ArchKit kit, ArchFixtureStation? at, ArchLadderPart draft, out ArchBuilding host )
	{
		host = null;

		if ( plan is null || draft is null || at is not { } station )
		{
			return null;
		}

		host = station.Building;

		var centre = station.Centre;
		var thickness = station.Wall.Thickness > 0f ? station.Wall.Thickness : kit.WallThickness;
		var inside = centre - station.Outward * (thickness * 0.5f + 1f);

		return new ArchLadderPart
		{
			Id = plan.AllocateId(),
			Name = $"Ladder{plan.CountFiled( ArchKind.Ladder, host ) + 1}",
			Anchor = centre + station.Outward * (thickness * 0.5f),
			Outward = station.Outward,
			BaseHeight = Grade( host, kit, plan ),
			TopHeight = Head( plan, host, station.Room, inside ),
			Width = station.Width,
			Standoff = draft.Standoff,
			RungSpacing = draft.RungSpacing,
			Cage = draft.Cage,
			CageFrom = draft.CageFrom,
			Return = draft.Return
		};
	}

	public static ArchLadderShape Resolve( ArchLadderPart ladder, ArchKit kit )
	{
		var bar = MathF.Max( 0.5f, kit.LadderBar );
		var back = MathF.Max( bar, ladder.Standoff );
		var rise = ladder.Rise;
		var cageFrom = Math.Clamp( ladder.CageFrom, 0f, rise );

		return new ArchLadderShape
		{
			Anchor = ladder.Anchor,
			Facing = ladder.Facing,
			Along = ladder.Along,
			Foot = ladder.BaseHeight,
			Head = ladder.TopHeight,
			Bar = bar,
			Back = back,
			Front = back + bar,
			HalfWidth = MathF.Max( bar * 2f, ladder.Width * 0.5f ),
			// Foot node dropped so the last rung lands on the head, not a part-pitch short.
			Rungs = Heights( ladder.BaseHeight, ArchDivide.AtMost( rise, MathF.Max( 6f, ladder.RungSpacing ) ).Nodes.Skip( 1 ) ),
			Brackets = Heights( ladder.BaseHeight, ArchDivide.AtMost( rise, kit.LadderBracketSpacing ).Nodes ),
			Hoops = ladder.Cage
				? Heights( ladder.BaseHeight + cageFrom, ArchDivide.AtMost( rise - cageFrom, kit.LadderCageSpacing ).Nodes )
				: new List<float>(),
			CageReach = MathF.Max( back + bar * 2f, kit.LadderCageReach ),
			ReturnRise = ladder.Return ? kit.HandrailHeight : 0f,
			ReturnReach = ladder.Return ? back + bar + kit.WallThickness + 12f : 0f
		};
	}

	static List<float> Heights( float from, IEnumerable<float> offsets )
	{
		return offsets.Select( offset => from + offset ).ToList();
	}

	// Built in the building's lifted space: grade sits a plinth below the ground floor.
	static float Grade( ArchBuilding building, ArchKit kit, ArchPlan plan )
	{
		var lowest = float.MaxValue;

		foreach ( var room in building.Rooms )
		{
			lowest = MathF.Min( lowest, room.BaseHeight );
		}

		return (lowest == float.MaxValue ? 0f : lowest) - ArchAsks.Lift( plan, building, kit );
	}

	static float Head( ArchPlan plan, ArchBuilding building, ArchRoom room, Vector2 inside )
	{
		var head = float.MinValue;

		foreach ( var roof in plan.Filed( ArchKind.Roof, building ).OfType<ArchRoofPart>() )
		{
			if ( !ArchFootprint.Encloses( new[] { (IReadOnlyList<Vector2>)roof.Outline() }, inside ) )
			{
				continue;
			}

			var deck = roof.BaseHeight + roof.Thickness + (roof.Parapet ? MathF.Max( 0f, roof.ParapetHeight ) : 0f);

			head = MathF.Max( head, deck );
		}

		return head > float.MinValue ? head : (room?.BaseHeight ?? 0f) + (room?.WallHeight ?? 0f);
	}
}