RoadComponent/RoadComponent.Lampposts.cs
using System;
using System.Linq;
using System.Collections.Generic;
using Sandbox;

namespace RedSnail.RoadTool;

public partial class RoadComponent
{
	private bool m_DoesLamppostsNeedRebuild = false;

	[Property, FeatureEnabled("Lampposts", Icon = "light_mode", Tint = EditorTint.Red), Change] private bool HasLampposts { get; set; } = false;
	[Property, Feature("Lampposts")] public GameObject LamppostPrefab { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } }
	[Property, Feature("Lampposts"), Range(50.0f, 2000.0f)] private float LamppostSpacing { get; set { field = value.Clamp(10.0f, 100000.0f); m_DoesLamppostsNeedRebuild = true; } } = 50.0f;
	[Property, Feature("Lampposts"), Range(-200.0f, 200.0f)] private float LamppostOffsetFromSidewalk { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 10.0f;
	[Property, Feature("Lampposts"), Range(0.0f, 10.0f)] private float LamppostHeightOffset { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 0.0f;
	[Property, Feature("Lampposts")] private LamppostSide LamppostPlacement { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = LamppostSide.Both;
	[Property, Feature("Lampposts")] private bool AlignToSplineRotation { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = true;
	[Property(Title = "Keep Vertical (World Up)"), Feature("Lampposts")] private bool KeepVertical { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = true;
	[Property, Feature("Lampposts"), Range(0.0f, 360.0f)] private float LamppostRotationOffset { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 0.0f;
	[Property, Feature("Lampposts"), Range(0.0f, 100.0f)] private float StartOffset { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 0.0f;
	[Property, Feature("Lampposts"), Range(0.0f, 100.0f)] private float EndOffset { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 0.0f;

	public enum LamppostSide
	{
		Left,
		Right,
		Both,
		Alternating
	}



	private void OnHasLamppostsChanged(bool _OldValue, bool _NewValue)
	{
		m_DoesLamppostsNeedRebuild = true;
	}



	private void CreateLampposts()
	{
		RemoveLampposts();

		if (!HasLampposts || !LamppostPrefab.IsValid())
			return;

		BuildLampposts();
	}



	private void RemoveLampposts()
	{
		if (SandboxUtility.IsInPlayMode)
			return;

		GameObject containerObject = GameObject.Children.FirstOrDefault(x => x.Name == "Lampposts");

		if (containerObject.IsValid())
		{
			containerObject.Destroy();
		}
	}



	private void UpdateLampposts()
	{
		if (m_DoesLamppostsNeedRebuild)
		{
			CreateLampposts();

			m_DoesLamppostsNeedRebuild = false;
		}
	}



	private void BuildLampposts()
	{
		if (SandboxUtility.IsInPlayMode)
			return;

		GameObject containerObject = new GameObject(GameObject, true, "Lampposts");
		containerObject.Tags.Add("road_props");

		float splineLength = Spline.Length;
		float effectiveLength = splineLength - StartOffset - EndOffset;

		if (effectiveLength <= 0)
			return;

		GetSplineFrameData(out var frames, out var segmentsToKeep);

		var simplifiedPositions = new List<(Transform _Frame, float _Distance)>();

		foreach (int index in segmentsToKeep)
		{
			float t = (float)index / (frames.Length - 1);
			float distance = t * splineLength;

			simplifiedPositions.Add((frames[index], distance));
		}

		int lamppostCount = Math.Max(1, (int)MathF.Ceiling(effectiveLength / LamppostSpacing));
		int frameCount = lamppostCount + 1;

		float roadEdgeOffset = RoadWidth * 0.5f;
		float sidewalkOffset = HasSidewalk ? SidewalkWidth : 0.0f;
		float totalOffset = roadEdgeOffset + sidewalkOffset + LamppostOffsetFromSidewalk;

		for (int i = 0; i < frameCount; i++)
		{
			float t = (float)i / (frameCount - 1);
			float distance = t * splineLength;

			// Skip if outside the start/end offset range
			if (distance < StartOffset || distance > splineLength - EndOffset)
				continue;

			// Interpolate frame at this distance along the simplified spline
			Transform frame = InterpolateFrameAtDistance(simplifiedPositions, distance);

			Vector3 basePosition = frame.Position;
			Vector3 forward = frame.Rotation.Forward;
			Vector3 up = frame.Rotation.Up;
			Vector3 right = frame.Rotation.Right;

			bool placeLeft = false;
			bool placeRight = false;

			switch (LamppostPlacement)
			{
				case LamppostSide.Left:
					placeLeft = true;
					break;
				case LamppostSide.Right:
					placeRight = true;
					break;
				case LamppostSide.Both:
					placeLeft = true;
					placeRight = true;
					break;
				case LamppostSide.Alternating:
					if (i % 2 == 0)
						placeLeft = true;
					else
						placeRight = true;
					break;
				default:
					placeLeft = true;
					placeRight = true;
					break;
			}

			if (placeLeft)
			{
				Vector3 leftPosition = basePosition - right * totalOffset + up * (LamppostHeightOffset + (HasSidewalk ? SidewalkHeight : 0.0f));
				Rotation leftRotation = CalculateLamppostRotation(forward, up, LamppostRotationOffset);

				CreateLamppost(containerObject, leftPosition, leftRotation);
			}

			if (placeRight)
			{
				Vector3 rightPosition = basePosition + right * totalOffset + up * (LamppostHeightOffset + (HasSidewalk ? SidewalkHeight : 0.0f));
				Rotation rightRotation = CalculateLamppostRotation(forward, up, LamppostRotationOffset + 180.0f);

				CreateLamppost(containerObject, rightPosition, rightRotation);
			}
		}
	}



	private void CreateLamppost(GameObject _Parent, Vector3 _Position, Rotation _Rotation)
	{
		if (!LamppostPrefab.IsValid())
			return;

		GameObject lamppostObject = LamppostPrefab.Clone(_Parent, _Position, _Rotation, Vector3.One);

		if (!lamppostObject.IsValid())
			return;

		lamppostObject.LocalPosition = _Position;
		lamppostObject.LocalRotation = _Rotation;
	}



	private Rotation CalculateLamppostRotation(Vector3 _Forward, Vector3 _SplineUp, float _YawOffset)
	{
		if (!AlignToSplineRotation)
			return Rotation.FromYaw(_YawOffset);

		Rotation finalRotation;

		if (KeepVertical)
		{
			Vector3 flatForward = _Forward.WithZ(0).Normal;

			if (flatForward.Length > 0.001f)
			{
				finalRotation = Rotation.LookAt(flatForward, Vector3.Up);
			}
			else
			{
				finalRotation = Rotation.FromYaw(_YawOffset);
			}
		}
		else
		{
			finalRotation = Rotation.LookAt(_Forward, _SplineUp);
		}

		return finalRotation * Rotation.FromYaw(_YawOffset);
	}
}