Editor/Stair/ArchStairVoid.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

// A boolean cut through a flight — surviving edges become guarded borders. Not ArchStairCarve (steps from a platform).
public readonly struct ArchStairVoiding {
	public ArchPlan Plan { get; init; }
	public ArchKit Kit { get; init; }
	public int Level { get; init; }
	public int HostId { get; init; }
	// Fast-path: skip the cut walk entirely when no voids exist.
	public bool Any { get; init; }
}

public static class ArchStairVoid {
	public static ArchStairVoiding Open( ArchPlan plan, ArchKit kit, ArchStairPart stair, ArchStairShape shape, int level ) {
		var voiding = new ArchStairVoiding {
			Plan = plan,
			Kit = kit,
			Level = level,
			HostId = stair?.Id ?? 0
		};

		if ( plan is null || stair is null || shape.Runs.Count == 0 ) {
			return voiding;
		}

		var reaches = ArchCut
			.Volumes( plan, level, kit, shape.BaseHeight, shape.TopHeight, stair.Id, ArchCutAffects.Stairs, stair.Id )
			.Any();

		return new ArchStairVoiding {
			Plan = plan,
			Kit = kit,
			Level = level,
			HostId = stair.Id,
			Any = reaches
		};
	}

	// The across spans that survive at one station along a run. Always yields the whole span when nothing cuts
	// here, so a caller loops the same way whether the flight is cut or not.
	public static IEnumerable<(float From, float To)> Spans(
		ArchStairVoiding voiding, ArchStairAxes axes, float along, float from, float to, float bottom, float top ) {
		if ( !voiding.Any || to - from < 0.05f ) {
			yield return (from, to);

			yield break;
		}

		var near = axes.Flat( along, from );
		var far = axes.Flat( along, to );
		var reach = to - from;

		foreach ( var span in ArchCut.Outside( voiding.Plan, voiding.Kit, voiding.Level, voiding.HostId,
			near, far, bottom, top, ArchCutAffects.Stairs, voiding.HostId ) ) {
			yield return (from + span.From * reach, from + span.To * reach);
		}
	}

	// A landing is a plate rather than a strip, so it is clipped in BOTH directions and comes back as the cells
	// that survive. Two one-dimensional clips crossed is exact for the rectangular shaft this is nearly always
	// used for, and a cell whose middle is inside the cut is dropped, so an L-shaped bite comes out right too.
	public static IEnumerable<(float AlongFrom, float AlongTo, float AcrossFrom, float AcrossTo)> Cells(
		ArchStairVoiding voiding, ArchStairAxes axes,
		float alongFrom, float alongTo, float acrossFrom, float acrossTo, float bottom, float top ) {
		if ( !voiding.Any ) {
			yield return (alongFrom, alongTo, acrossFrom, acrossTo);

			yield break;
		}

		var across = Spans( voiding, axes, (alongFrom + alongTo) * 0.5f, acrossFrom, acrossTo, bottom, top ).ToList();
		var along = Crossing( voiding, axes, alongFrom, alongTo, (acrossFrom + acrossTo) * 0.5f, bottom, top ).ToList();

		foreach ( var lane in across ) {
			foreach ( var band in along ) {
				if ( band.To - band.From < 0.05f || lane.To - lane.From < 0.05f ) {
					continue;
				}

				if ( Taken( voiding, axes, (band.From + band.To) * 0.5f, (lane.From + lane.To) * 0.5f, bottom, top ) ) {
					continue;
				}

				yield return (band.From, band.To, lane.From, lane.To);
			}
		}
	}

	// The same question along the run rather than across it.
	static IEnumerable<(float From, float To)> Crossing(
		ArchStairVoiding voiding, ArchStairAxes axes, float from, float to, float across, float bottom, float top ) {
		var near = axes.Flat( from, across );
		var far = axes.Flat( to, across );
		var reach = to - from;

		if ( reach < 0.05f ) {
			yield break;
		}

		foreach ( var span in ArchCut.Outside( voiding.Plan, voiding.Kit, voiding.Level, voiding.HostId,
			near, far, bottom, top, ArchCutAffects.Stairs, voiding.HostId ) ) {
			yield return (from + span.From * reach, from + span.To * reach);
		}
	}

	static bool Taken( ArchStairVoiding voiding, ArchStairAxes axes, float along, float across, float bottom, float top ) {
		var point = axes.Flat( along, across );

		foreach ( var volume in ArchCut.Volumes( voiding.Plan, voiding.Level, voiding.Kit, bottom, top,
			voiding.HostId, ArchCutAffects.Stairs, voiding.HostId ) ) {
			if ( ArchFootprint.Contains( volume.Footprint, point ) ) {
				return true;
			}
		}

		return false;
	}

	// Every edge a cut opened up a run, as the STRETCH it actually opened: sampled per tread, because the
	// spans are already quantised to the treads the bite falls across, and merged into one border while the
	// edge holds its station. The run's own two sides are excluded - those are already guarded - and a cut
	// authored with GuardsOpenedEdges off keeps its edges bare, which is what lets a split stair carry a
	// pillar line instead of a rail.
	public static IEnumerable<ArchStairBorder> Borders( ArchStairVoiding voiding, ArchStairRun run ) {
		if ( !voiding.Any ) {
			yield break;
		}

		var openers = Openers( voiding, run );
		var tracked = new List<TrackedBorder>();

		for ( var step = 0; step < run.Steps; step++ ) {
			var along = (step + 0.5f) * run.Going;
			var height = run.Rake( along );

			foreach ( var live in tracked.Where( border => border.Open ) ) {
				live.Continued = false;
			}

			foreach ( var span in Spans( voiding, run.Axes, along, 0f, run.Width, height - 1f, height + 1f ) ) {
				Track( tracked, openers, run, step, along, height, span.From, 1f, span.From > 0.5f );
				Track( tracked, openers, run, step, along, height, span.To, -1f, span.To < run.Width - 0.5f );
			}

			foreach ( var live in tracked.Where( border => border.Open && !border.Continued ) ) {
				live.Open = false;
			}
		}

		foreach ( var border in tracked ) {
			yield return new ArchStairBorder { Lane = border.Lane, Inward = border.Inward, From = border.From, To = border.To };
		}
	}

	static void Track( List<TrackedBorder> tracked, List<(ArchCutPart Cut, List<ArchCarveVolume> Volumes)> openers,
		ArchStairRun run, int step, float along, float height, float lane, float inward, bool inside ) {
		if ( !inside || !Guarded( openers, run.Axes, along, lane - inward, height ) ) {
			return;
		}

		var reached = (step + 1) * run.Going;
		var live = tracked.FirstOrDefault( border =>
			border.Open && border.Inward == inward && MathF.Abs( border.Lane - lane ) < 0.75f );

		if ( live is null ) {
			tracked.Add( new TrackedBorder { Lane = lane, Inward = inward, From = step * run.Going, To = reached, Open = true, Continued = true } );

			return;
		}

		live.To = reached;
		live.Continued = true;
	}

	// The cut standing just beyond the edge is the one that opened it, and it says whether the drop is guarded.
	static bool Guarded( List<(ArchCutPart Cut, List<ArchCarveVolume> Volumes)> openers, ArchStairAxes axes, float along, float across, float height ) {
		var point = axes.Flat( along, across );

		foreach ( var opener in openers ) {
			if ( opener.Volumes.Any( volume => ArchFootprint.Contains( volume.Footprint, point )
				&& ArchCut.Reaches( volume, height - 1f, height + 1f ) ) ) {
				return opener.Cut.GuardsOpenedEdges;
			}
		}

		return true;
	}

	static List<(ArchCutPart Cut, List<ArchCarveVolume> Volumes)> Openers( ArchStairVoiding voiding, ArchStairRun run ) {
		return ArchCut.Over( voiding.Plan, voiding.Level, run.Loop(), voiding.HostId, ArchCutAffects.Stairs, voiding.HostId )
			.Select( cut => (cut, ArchCut.Resolve( cut, voiding.Kit ).ToList()) )
			.ToList();
	}

	sealed class TrackedBorder {
		public float Lane;
		public float Inward;
		public float From;
		public float To;
		public bool Open;
		public bool Continued;
	}
}

// One opened edge up a run: where it stands across the flight, which side survives, and the stretch it opened.
public readonly struct ArchStairBorder {
	public float Lane { get; init; }
	// +1 when the surviving treads lie on the increasing-across side of the edge.
	public float Inward { get; init; }
	public float From { get; init; }
	public float To { get; init; }
}