Track/SplineTerrainCarver.cs

Component for editor/runtime that carves or flattens terrain along a SplineComponent path. It samples the spline, projects sample points into terrain UV space, adjusts the terrain heightmap values with a blend falloff, then syncs GPU texture and updates collision for the modified region.

File Access
using System;

namespace Sandbox;

public enum CarveMode
{
	/// <summary>
	/// Flatten terrain to match the spline height (both raises and lowers).
	/// </summary>
	Flatten,

	/// <summary>
	/// Only carve down — terrain below the spline is untouched.
	/// </summary>
	CarveDown
}

/// <summary>
/// Carves (flattens/lowers) terrain along a spline path, useful for embedding track geometry into terrain.
/// Attach this to the same GameObject as a SplineComponent.
/// </summary>
[Title( "Spline Terrain Carver" )]
[Category( "Track" )]
[Icon( "terrain" )]
public sealed class SplineTerrainCarver : Component, Component.ExecuteInEditor
{
	/// <summary>
	/// The terrain to carve into. If not set, will try to find one in the scene.
	/// </summary>
	[Property] public Terrain Terrain { get; set; }

	/// <summary>
	/// The spline to follow for carving.
	/// </summary>
	[Property] public SplineComponent Spline { get; set; }

	/// <summary>
	/// How the terrain is modified along the spline.
	/// </summary>
	[Property] public CarveMode Mode { get; set; } = CarveMode.Flatten;

	/// <summary>
	/// Width of the carved path (in units) on each side of the spline center.
	/// </summary>
	[Property, Range( 32f, 1024f )] public float CarveWidth { get; set; } = 128f;

	/// <summary>
	/// How far below the spline point the terrain should be carved (depth offset).
	/// </summary>
	[Property, Range( 0f, 128f )] public float CarveDepth { get; set; } = 8f;

	/// <summary>
	/// Width of the blend/falloff zone at the edges of the carve path.
	/// </summary>
	[Property, Range( 0f, 256f )] public float BlendWidth { get; set; } = 64f;

	/// <summary>
	/// Number of sample points along the spline to use for carving.
	/// Higher = more accurate but slower.
	/// </summary>
	[Property, Range( 10, 500 )] public int SampleCount { get; set; } = 100;

	/// <summary>
	/// Number of lateral samples across the width of the carve path.
	/// </summary>
	[Property, Range( 4, 64 )] public int WidthSamples { get; set; } = 16;

	/// <summary>
	/// If true, applies the carve operation on start. Otherwise call ApplyCarve() manually.
	/// </summary>
	[Property] public bool CarveOnStart { get; set; } = false;

	protected override void OnStart()
	{
		if ( CarveOnStart )
			ApplyCarve();
	}

	private SplineComponent ResolveSpline()
	{
		if ( Spline.IsValid() ) return Spline;
		Spline = GetComponent<SplineComponent>();
		return Spline;
	}

	private Terrain ResolveTerrain()
	{
		if ( Terrain.IsValid() ) return Terrain;
		Terrain = Scene.GetAllComponents<Terrain>().FirstOrDefault();
		return Terrain;
	}

	/// <summary>
	/// Applies the terrain carve along the spline. Call this from editor tools or at runtime.
	/// </summary>
	[Button( "Apply Carve" )]
	public void ApplyCarve()
	{
		var splineComp = ResolveSpline();
		var terrain = ResolveTerrain();

		if ( !splineComp.IsValid() || !terrain.IsValid() )
		{
			Log.Warning( "SplineTerrainCarver: Missing Spline or Terrain reference." );
			return;
		}

		var storage = terrain.Storage;
		if ( storage is null )
		{
			Log.Warning( "SplineTerrainCarver: Terrain has no storage." );
			return;
		}

		var spline = splineComp.Spline;
		var splineLength = spline.Length;
		if ( splineLength <= 0f )
		{
			Log.Warning( "SplineTerrainCarver: Spline length is zero." );
			return;
		}

		var splineTransform = splineComp.WorldTransform;
		var terrainTransform = terrain.WorldTransform;

		int resolution = storage.Resolution;
		float terrainSize = storage.TerrainSize;
		float terrainHeight = storage.TerrainHeight;
		var heightMap = storage.HeightMap;

		int minDirtyX = resolution;
		int minDirtyY = resolution;
		int maxDirtyX = 0;
		int maxDirtyY = 0;
		int modifiedPixels = 0;
		int skippedOutOfBounds = 0;

		// Sample points along the spline
		for ( int i = 0; i <= SampleCount; i++ )
		{
			float distance = (float)i / SampleCount * splineLength;
			var sample = spline.SampleAtDistance( distance );

			// Convert spline local position to world
			var worldPos = splineTransform.PointToWorld( sample.Position );
			var worldTangent = (splineTransform.Rotation * sample.Tangent).Normal;

			// Get the right vector (perpendicular to tangent, on the horizontal plane)
			var right = Vector3.Cross( worldTangent, Vector3.Up ).Normal;
			if ( right.LengthSquared < 0.001f )
				right = Vector3.Cross( worldTangent, Vector3.Forward ).Normal;

			// Target height: the spline world Z minus carve depth, relative to terrain origin
			float targetLocalHeight = (worldPos.z - terrainTransform.Position.z) - CarveDepth;
			float targetNormalized = targetLocalHeight / terrainHeight;
			targetNormalized = Math.Clamp( targetNormalized, 0f, 1f );

			// Log first sample for debugging
			if ( i == 0 )
			{
				var debugLocal = terrainTransform.PointToLocal( worldPos );
				var debugUV = new Vector2( debugLocal.x, debugLocal.y ) / terrainSize;
				Log.Info( $"SplineTerrainCarver: First sample worldPos={worldPos}, terrainLocal={debugLocal}, uv={debugUV}, targetNorm={targetNormalized:F4}" );
			}

			// Carve across the width
			float totalWidth = CarveWidth + BlendWidth;
			for ( int w = 0; w <= WidthSamples; w++ )
			{
				float wt = (float)w / WidthSamples;
				float lateralOffset = MathX.Lerp( -totalWidth, totalWidth, wt );
				var sampleWorldPos = worldPos + right * lateralOffset;

				// Calculate blend factor (1.0 = full carve, 0.0 = no carve)
				float distFromCenter = MathF.Abs( lateralOffset );
				float blend = 1f;

				if ( distFromCenter > CarveWidth )
				{
					blend = 1f - ((distFromCenter - CarveWidth) / BlendWidth);
					blend = Math.Clamp( blend, 0f, 1f );
					// Smoothstep
					blend = blend * blend * (3f - 2f * blend);
				}

				if ( blend <= 0f )
					continue;

				// Convert world position to terrain local space
				var localPos = terrainTransform.PointToLocal( sampleWorldPos );

				// Compute UV on the terrain (x/y are horizontal, z is height)
				var uv = new Vector2( localPos.x, localPos.y ) / terrainSize;
				if ( uv.x < 0f || uv.x > 1f || uv.y < 0f || uv.y > 1f )
				{
					skippedOutOfBounds++;
					continue;
				}

				// Map UV to heightmap pixel coordinates
				int px = (int)MathF.Floor( resolution * uv.x );
				int py = (int)MathF.Floor( resolution * uv.y );
				px = Math.Clamp( px, 0, resolution - 1 );
				py = Math.Clamp( py, 0, resolution - 1 );

				int idx = px + py * resolution;

				// Current height as normalized value (0-1)
				float currentNormalized = heightMap[idx] / 65535f;

				bool shouldModify = Mode switch
				{
					CarveMode.Flatten => MathF.Abs( currentNormalized - targetNormalized ) > 0.0001f,
					CarveMode.CarveDown => currentNormalized > targetNormalized,
					_ => false
				};

				if ( shouldModify )
				{
					float newNormalized = MathX.Lerp( currentNormalized, targetNormalized, blend );
					heightMap[idx] = (ushort)(Math.Clamp( newNormalized, 0f, 1f ) * 65535f);

					// Expand dirty region
					minDirtyX = Math.Min( minDirtyX, px );
					minDirtyY = Math.Min( minDirtyY, py );
					maxDirtyX = Math.Max( maxDirtyX, px + 1 );
					maxDirtyY = Math.Max( maxDirtyY, py + 1 );
					modifiedPixels++;
				}
			}
		}

		// Sync changes to the GPU and update collision
		if ( modifiedPixels > 0 )
		{
			var dirtyRegion = new RectInt( minDirtyX, minDirtyY, maxDirtyX - minDirtyX, maxDirtyY - minDirtyY );
			terrain.SyncGPUTexture();
			terrain.UpdateCollision( Terrain.SyncFlags.Height, dirtyRegion );
			Log.Info( $"SplineTerrainCarver: Carved {modifiedPixels} pixels in region ({minDirtyX},{minDirtyY})-({maxDirtyX},{maxDirtyY})." );
		}
		else
		{
			Log.Warning( $"SplineTerrainCarver: No pixels modified. Skipped {skippedOutOfBounds} out-of-bounds samples. Mode={Mode}" );
			Log.Warning( $"  Terrain pos={terrainTransform.Position}, size={terrainSize}, height={terrainHeight}, resolution={resolution}" );
			Log.Warning( $"  Spline pos={splineTransform.Position}, length={splineLength:F1}" );
		}
	}

	/// <summary>
	/// Resets the terrain to its original state in the carved area.
	/// </summary>
	[Button( "Reset Carve" )]
	public void ResetCarve()
	{
		Log.Info( "SplineTerrainCarver: Reset not yet implemented. Undo via editor." );
	}
}