Rides/TrackRides/TrackMesh.cs
using HC3.Terrain;
using System;
using System.Collections.Immutable;

namespace HC3.Rides;

#nullable enable

public sealed class TrackMesh : Component, Component.ExecuteInEditor, ITrackEvent
{
	[RequireComponent] public TrackSection Track { get; set; } = null!;
	[RequireComponent] public ModelRenderer Renderer { get; set; } = null!;

	[field: ThreadStatic]
	private static TrackMeshWriter? Writer { get; set; }

	private Model? _model;
	private Mesh? _mesh;

	void ITrackEvent.Changed( TrackSection changedTrack )
	{
		var thisTrack = Track;

		// We want to update the mesh for changes to this track, or overlapping tracks

		if ( changedTrack != thisTrack && !changedTrack.WorldBounds.Overlaps( thisTrack.WorldBounds ) ) return;

		// Might be getting removed

		if ( !thisTrack.IsValid ) return;

		// IMPORTANT: need to find overlapping track BEFORE accessing Writer, to avoid it being reused on the same thread
		// because accessing ITrackSection.WorldBounds can lead to a ITrackEvent.Changed call.

		var overlaps = Scene.GetAllComponents<TrackSection>()
			.Where( x => x != thisTrack )
			.Where( x => x.WorldBounds.Overlaps( thisTrack.WorldBounds ) )
			.Select( x => new OverlappingTrack( x, thisTrack.WorldTransform.ToLocal( x.WorldTransform ) ) )
			.ToImmutableArray();

		var writer = Writer ??= new TrackMeshWriter();

		writer.Clear();

		// TODO: support multiple materials

		var material = thisTrack.TrackDefinition?.Rails?.Materials.FirstOrDefault();

		var grid = GridManager.Instance;
		var terrain = grid?.Terrain ?? Scene.Get<ParkTerrain>();

		if ( thisTrack.TrackDefinition is { } trackDef )
		{
			writer.Write( thisTrack, WorldTransform, trackDef, grid, terrain, overlaps );
		}

		if ( material is null || writer.IsEmpty )
		{
			Renderer.Enabled = false;
			return;
		}

		if ( !_mesh.IsValid() )
		{
			_mesh = new Mesh( material );
		}

		var bounds = writer.CopyTo( _mesh );

		if ( !_model.IsValid() )
		{
			_model = new ModelBuilder()
				.AddMesh( _mesh )
				.Create();
		}

		Renderer.Model = _model;
		Renderer.Enabled = true;

		if ( Renderer.SceneObject.IsValid() )
		{
			// Workaround for SceneObject.LocalBounds not handling rotations for some reason

			Renderer.SceneObject.LocalBounds = bounds.Transform( new Transform( default, WorldTransform.Rotation ) );
		}
	}
}

/// <summary>
/// A potentially overlapping section of track, used by <see cref="TrackMeshWriter"/> when generating supports.
/// </summary>
/// <param name="Track">Overlapping track.</param>
/// <param name="Transform">Transform from the track being meshed to the overlapping track.</param>
public readonly record struct OverlappingTrack( ITrackSection Track, Transform Transform );

public sealed class TrackMeshWriter : MeshWriter<Vertex>
{
	public void Write( ITrackSection track, Transform worldTransform, TrackDefinition trackDef, GridManager? grid = null, ParkTerrain? terrain = null, IReadOnlyList<OverlappingTrack>? overlaps = null )
	{
		if ( track.Nodes.Count < 2 ) return;

		var prevDist = 0f;
		var prevNodeTransform = track.SampleAtNode( 0 );

		Bounds = Bounds.AddBBox( new BBox( prevNodeTransform.Position - 32f, prevNodeTransform.Position + 32f ) );

		var sinceSupport = 0;
		var selfOverlap = new OverlappingTrack( track, Transform.Zero );

		overlaps = overlaps?.Union( [selfOverlap] ).ToArray() ?? new[] { selfOverlap };

		var nodes = new HashSet<TrackNode>();

		for ( var i = 0; i < track.Elements.Count; ++i )
		{
			var element = track.Elements[i];

			var nextNodeTransform = track.SampleAtNode( i + 1 );
			var nextDist = track.GetDistanceAtNode( i + 1 );

			nodes.Clear();
			nodes.Add( track.Nodes[i + 0] );
			nodes.Add( track.Nodes[i + 1] );

			var elementLength = element.Length;

			// TODO: stitch rail segments together?

			var steps = !IsTrivial( prevNodeTransform, nextNodeTransform, elementLength )
				? (int)MathF.Round( elementLength * 4 / GridManager.GridSize )
				: 2;

			var prevStepTransform = prevNodeTransform;
			var feature = element.Definition.Feature;

			for ( var j = 1; j <= steps; ++j )
			{
				var t = (float)j / steps;
				var nextStepTransform = track.SampleAtDistance( prevDist + t * elementLength );

				trackDef.WriteRail( this, prevStepTransform, nextStepTransform, feature );

				prevStepTransform = nextStepTransform;

				Bounds = Bounds.AddBBox( new BBox( nextStepTransform.Position - 32f, nextStepTransform.Position + 32f ) );
			}

			var strutCount = (int)MathF.Round( elementLength * trackDef.StrutFrequency / GridManager.GridSize );

			for ( var j = 0; j < strutCount; ++j )
			{
				var t = (j + 0.5f) / strutCount;
				var transform = track.SampleAtDistance( prevDist + t * elementLength );

				trackDef.WriteStrut( this, transform );
			}

			var parameters = new SupportParameters( track, worldTransform, trackDef, grid, terrain, prevDist, element, nodes, overlaps );

			switch ( trackDef.SupportStyle )
			{
				case SupportStyle.Column:
					WriteColumnSupports( parameters, ref sinceSupport );
					break;

				case SupportStyle.Scaffold:
					WriteScaffoldSupports( parameters );
					break;
			}

			prevDist = nextDist;
			prevNodeTransform = nextNodeTransform;
		}
	}

	private readonly record struct SupportParameters(
		ITrackSection Track,
		Transform WorldTransform,
		TrackDefinition TrackDef,
		GridManager? Grid,
		ParkTerrain? Terrain,
		float PrevDist,
		TrackElement Element,
		IReadOnlySet<TrackNode> Nodes,
		IReadOnlyList<OverlappingTrack> Overlaps );

	private void WriteColumnSupports( in SupportParameters parameters, ref int sinceSupport )
	{
		var (track, worldTransform, trackDef, grid, terrain, prevDist, element, nodes, overlaps) = parameters;

		var elementLength2d = element.Length2D;
		var tileCount = (int)MathF.Round( elementLength2d / GridManager.GridSize );

		for ( var i = 0; i < tileCount; ++i )
		{
			var t = (i + 0.5f) / tileCount;
			var transform = track.SampleAtDistance( prevDist + t * elementLength2d );

			++sinceSupport;

			if ( sinceSupport < trackDef.SupportSpacing )
			{
				continue;
			}

			if ( transform.Up.z <= 0f )
			{
				// Track is too tilted for a support
				continue;
			}

			var baseZ = 0f;

			var connectionOffset = MathX.Lerp( MathX.Lerp( 8f, 32f, transform.Position.z / 32f ), 8f, transform.Up.z );
			var connectionTop = transform.Position + transform.Down * connectionOffset;

			if ( terrain is not null )
			{
				var gridPos = (Vector2)terrain.WorldToGrid( worldTransform.PointToWorld( connectionTop ) );
				var gridHeight = terrain.GetHeight( gridPos ) * GridManager.HeightStep;

				baseZ = gridHeight - worldTransform.Position.z;

				if ( baseZ >= transform.Position.z - 16f )
				{
					// On the ground, no need for a support
					sinceSupport = trackDef.SupportSpacing / 2;
					continue;
				}
			}

			// Check grid manager for obstructions

			if ( GetMinUnobstructedHeight( ref baseZ, connectionTop, worldTransform, grid ) ) continue;

			// Test other tracks for obstructions

			if ( GetMinUnobstructedHeight( ref baseZ, overlaps, connectionTop, nodes ) ) continue;

			var canPlaceSupport = true;

			if ( !canPlaceSupport ) continue;

			sinceSupport = 0;
			trackDef.WriteSupportColumn( this, transform, connectionOffset, baseZ );
		}
	}

	private void WriteScaffoldSupports( in SupportParameters parameters )
	{
		var (track, worldTransform, trackDef, grid, terrain, prevDist, element, nodes, overlaps) = parameters;
		var prevTransform = track.SampleAtDistance( prevDist );

		foreach ( var t in element.TileBoundaries.Skip( 1 ) )
		{
			var nextTransform = track.SampleAtDistance( prevDist + t );

			if ( prevTransform.Up.z <= 0f || nextTransform.Up.z <= 0f )
			{
				prevTransform = nextTransform;

				// Track is too tilted for a support
				continue;
			}

			var minZ = 0f;
			var center = Vector3.Lerp( prevTransform.Position, nextTransform.Position, 0.5f );

			if ( terrain is not null )
			{
				var gridPos = (Vector2)terrain.WorldToGrid( worldTransform.PointToWorld( center ) );
				var gridHeight = terrain[terrain.GetTileIndex( gridPos )].MinHeight * GridManager.HeightStep;

				minZ = gridHeight - worldTransform.Position.z - TrackDefinition.UnitSupportHeight;
			}

			var baseZ = minZ;

			// Check grid manager for obstructions
			// Sample 10% and 90% of the way into the tile so we have two chances at hitting something,
			// while avoiding testing outside the tile because of float imprecision

			var a = Vector3.Lerp( prevTransform.Position, nextTransform.Position, 0.1f );
			var b = Vector3.Lerp( prevTransform.Position, nextTransform.Position, 0.9f );

			// Check grid manager for obstructions

			GetMinUnobstructedHeight( ref baseZ, a, worldTransform, grid );
			GetMinUnobstructedHeight( ref baseZ, b, worldTransform, grid );

			// Test other tracks for obstructions

			GetMinUnobstructedHeight( ref baseZ, overlaps, a, nodes );
			GetMinUnobstructedHeight( ref baseZ, overlaps, b, nodes );

			var maxBaseZ = Math.Min( prevTransform.Position.z, nextTransform.Position.z );

			if ( baseZ >= maxBaseZ )
			{
				prevTransform = nextTransform;
				continue;
			}

			if ( baseZ > minZ )
			{
				baseZ = Math.Max( baseZ, maxBaseZ - TrackDefinition.UnitSupportHeight );
			}

			trackDef.WriteSupportScaffold( this, prevTransform, nextTransform, baseZ );
			prevTransform = nextTransform;
		}
	}

	/// <summary>
	/// Test for <see cref="GridObject"/>s that could obstruct support placement.
	/// </summary>
	private bool GetMinUnobstructedHeight( ref float baseZ, Vector3 pos, Transform worldTransform, GridManager? grid )
	{
		var worldPos = worldTransform.PointToWorld( pos );

		if ( grid?.GetCell( GridManager.WorldToGridPosition( worldPos ) ) is not { } cell ) return false;

		var oldBaseZ = baseZ;

		foreach ( var obj in cell )
		{
			if ( !obj.BlocksConstruction ) continue;
			if ( obj.WorldPosition.z > worldPos.z ) continue;

			baseZ = Math.Max( baseZ, obj.WorldPosition.z + obj.Height * GridManager.HeightStep - worldTransform.Position.z );
		}

		return baseZ > oldBaseZ;
	}

	/// <summary>
	/// Test for <see cref="OverlappingTrack"/>s that could obstruct support placement.
	/// </summary>
	private bool GetMinUnobstructedHeight( ref float baseZ, IReadOnlyList<OverlappingTrack> overlaps, Vector3 pos, IReadOnlySet<TrackNode>? nodes )
	{
		for ( var z = pos.z; z >= baseZ; z -= TrackSection.GridSize )
		{
			foreach ( var overlap in overlaps )
			{
				var localPos = (overlap.Transform.PointToLocal( pos.WithZ( z ) ) / TrackSection.GridSize).FloorToInt();
				var ignoreNodes = overlap.Transform == Transform.Zero ? nodes : null;

				if ( !overlap.Track.HasObstruction( localPos, ignoreNodes ) ) continue;

				baseZ = z + TrackSection.GridSize;
				return true;
			}
		}

		return false;
	}

	/// <summary>
	/// Can we just use a straight track segment between these points?
	/// </summary>
	private static bool IsTrivial( in Transform prev, in Transform next, float length ) =>
		prev.Rotation.AlmostEqual( next.Rotation ) &&
		(next.Position - prev.Position).Length - length < 1f &&
		prev.Forward.Dot( (next.Position - prev.Position).Normal ) > 0.999f;
}