GolfHoleCollision.cs

Component that builds a single welded physics collider for a golf hole by collecting KitShape and ModelCollider pieces under the GameObject, snapping/welding nearby vertices into a grid, turning off the pieces' own colliders, and creating one static ModelCollider with the green surface.

File AccessNetworking
using Sandbox;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

/// <summary>
/// One welded collision mesh for a whole hole. Lives on the "Hole N" group (and the Kit Test Hole):
/// collects every enabled Kit Shape piece under the group, takes their collision geometry in hole
/// space, welds shared vertices across the pieces and builds a single static mesh collider with the
/// golf surface. The pieces' own colliders are switched off, their visible meshes stay as they are.
/// A ball rolling over a seam between two pieces then never meets an edge, so it never hops.
///
/// Runs in the editor too and rebuilds itself whenever a piece is added, moved, changed or removed,
/// so a hole is built with pieces and this never needs touching. Nothing is networked: every machine
/// builds the same mesh from the same scene (the piece set and their transforms come from the scene
/// file, and welding is position based, so the result does not depend on order or on who is host).
/// </summary>
public sealed class GolfHoleCollision : Component, Component.ExecuteInEditor
{
	/// <summary>Physics surface for the whole hole (Assets/surface/golf_green.surface).</summary>
	public const string SurfaceName = "golf_green";

	/// <summary>Vertices closer than 1/16 unit are the same vertex. Rotated pieces (walls, corners,
	/// bumpers) land a hair off the grid, this snaps them back onto their neighbours.</summary>
	const float WeldGrid = 16f;

	/// <summary>How often the pieces are checked for changes, in seconds.</summary>
	const float CheckInterval = 0.25f;

	[Property, ReadOnly] public int Pieces { get; private set; }
	[Property, ReadOnly] public int Vertices { get; private set; }
	[Property, ReadOnly] public int Triangles { get; private set; }

	/// <summary>How many hole colliders have been rebuilt since load (see golf_perf).</summary>
	public static int RebuildCount;

	bool _dirty = true;
	int _lastHash;
	TimeSince _sinceCheck;
	ModelCollider _collider;

	/// <summary>A piece under this hole changed: rebuild on the next update.</summary>
	public void MarkDirty() => _dirty = true;

	protected override void OnEnabled()
	{
		_dirty = true;
	}

	protected override void OnDisabled()
	{
		if ( _collider.IsValid() ) _collider.Enabled = false;
	}

	protected override void OnUpdate()
	{
		if ( !_dirty )
		{
			// Polling for moved or edited pieces is for the editor. In play nothing moves a piece
			// except code, and that calls MarkDirty; every rebuild there cooks a physics mesh.
			if ( Game.IsPlaying ) return;
			if ( _sinceCheck < CheckInterval ) return;
			_sinceCheck = 0;
			if ( ComputeHash() == _lastHash ) return;
		}

		Rebuild();
	}

	IEnumerable<KitShape> PiecesBelow() => Components.GetAll<KitShape>( FindMode.EnabledInSelfAndDescendants );

	/// <summary>Model pieces below (a Model Collider with a model, on an object that is not a Kit Shape): the Blender-made kit pieces.</summary>
	// Disabled colliders count too: the merge switches a piece's own collider off, and a saved scene
	// keeps it off, so the piece must still be found on the next load.
	IEnumerable<ModelCollider> ModelPiecesBelow() => Components.GetAll<ModelCollider>( FindMode.EverythingInSelfAndDescendants )
		.Where( c => c.IsValid() && c.GameObject != GameObject && c.GameObject.Enabled && c.Model is not null && c.GetComponent<KitShape>() is null );

	/// <summary>Cheap fingerprint of every piece's transform and settings, for the change check.</summary>
	int ComputeHash()
	{
		var hash = new HashCode();
		hash.Add( 2 ); // bump to force a rebuild after a code change
		var hole = WorldTransform;
		foreach ( var piece in PiecesBelow() )
		{
			// Pieces without collision (flags, tee pads) never change the collider: the swaying flag
			// must not count as a change.
			hash.Add( piece.Collision );
			if ( !piece.Collision ) continue;
			var t = hole.ToLocal( piece.WorldTransform );
			hash.Add( piece.Kind );
			hash.Add( (int)MathF.Round( piece.Length * WeldGrid ) );
			hash.Add( (int)MathF.Round( piece.Rise * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Position.x * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Position.y * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Position.z * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Rotation.x * 10000f ) );
			hash.Add( (int)MathF.Round( t.Rotation.y * 10000f ) );
			hash.Add( (int)MathF.Round( t.Rotation.z * 10000f ) );
			hash.Add( (int)MathF.Round( t.Rotation.w * 10000f ) );
			hash.Add( (int)MathF.Round( t.Scale.x * 1000f ) );
			hash.Add( (int)MathF.Round( t.Scale.y * 1000f ) );
			hash.Add( (int)MathF.Round( t.Scale.z * 1000f ) );
			hash.Add( piece.CollisionIndices.Count );
		}
		foreach ( var piece in ModelPiecesBelow() )
		{
			var t = hole.ToLocal( piece.WorldTransform );
			hash.Add( piece.Model.Name );
			hash.Add( (int)MathF.Round( piece.Model.Bounds.Mins.z * WeldGrid ) );
			hash.Add( (int)MathF.Round( piece.Model.Bounds.Maxs.x * WeldGrid ) );
			hash.Add( (int)MathF.Round( piece.Model.Bounds.Maxs.y * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Position.x * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Position.y * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Position.z * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Rotation.x * 10000f ) );
			hash.Add( (int)MathF.Round( t.Rotation.y * 10000f ) );
			hash.Add( (int)MathF.Round( t.Rotation.z * 10000f ) );
			hash.Add( (int)MathF.Round( t.Rotation.w * 10000f ) );
			hash.Add( (int)MathF.Round( t.Scale.x * 1000f ) );
		}
		return hash.ToHashCode();
	}

	void Rebuild()
	{
		var watch = Stopwatch.StartNew();
		RebuildCount++;
		_dirty = false;
		_lastHash = ComputeHash();

		var hole = WorldTransform;
		var positions = new List<Vector3>();
		var indices = new List<int>();
		var lookup = new Dictionary<(int, int, int), int>();
		var pieces = 0;
		var found = 0;

		foreach ( var piece in PiecesBelow() )
		{
			found++;
			// The piece keeps its own collider only when it is not part of a hole.
			var own = piece.GetComponent<ModelCollider>();
			if ( own.IsValid() && own.Enabled ) own.Enabled = false;

			piece.EnsureBuilt();
			if ( !piece.Collision || piece.CollisionIndices.Count == 0 ) continue;
			pieces++;

			var local = hole.ToLocal( piece.WorldTransform );
			var source = piece.CollisionPositions;
			var map = new int[source.Count];
			for ( var i = 0; i < source.Count; i++ )
			{
				var p = local.PointToWorld( source[i] );
				var key = ((int)MathF.Round( p.x * WeldGrid ), (int)MathF.Round( p.y * WeldGrid ), (int)MathF.Round( p.z * WeldGrid ));
				if ( !lookup.TryGetValue( key, out var index ) )
				{
					index = positions.Count;
					positions.Add( new Vector3( key.Item1 / WeldGrid, key.Item2 / WeldGrid, key.Item3 / WeldGrid ) );
					lookup[key] = index;
				}
				map[i] = index;
			}

			var tris = piece.CollisionIndices;
			for ( var i = 0; i + 2 < tris.Count; i += 3 )
			{
				int a = map[tris[i]], b = map[tris[i + 1]], c = map[tris[i + 2]];
				if ( a == b || b == c || a == c ) continue; // collapsed by the weld
				indices.Add( a ); indices.Add( b ); indices.Add( c );
			}
		}

		// Blender-made pieces: their model's mesh, welded in the same way; their own collider goes off.
		var modelPieces = 0;
		foreach ( var piece in ModelPiecesBelow().ToList() )
		{
			found++;
			var model = piece.Model;
			var source = model.GetVertices();
			var tris = model.GetIndices();
			if ( source is null || tris is null || source.Length == 0 || tris.Length < 3 ) continue;
			if ( piece.Enabled ) piece.Enabled = false;
			modelPieces++;
			pieces++;

			var local = hole.ToLocal( piece.WorldTransform );
			var map = new int[source.Length];
			for ( var i = 0; i < source.Length; i++ )
			{
				var p = local.PointToWorld( source[i].Position );
				var key = ((int)MathF.Round( p.x * WeldGrid ), (int)MathF.Round( p.y * WeldGrid ), (int)MathF.Round( p.z * WeldGrid ));
				if ( !lookup.TryGetValue( key, out var index ) )
				{
					index = positions.Count;
					positions.Add( new Vector3( key.Item1 / WeldGrid, key.Item2 / WeldGrid, key.Item3 / WeldGrid ) );
					lookup[key] = index;
				}
				map[i] = index;
			}
			for ( var i = 0; i + 2 < tris.Length; i += 3 )
			{
				int a = map[tris[i]], b = map[tris[i + 1]], c = map[tris[i + 2]];
				if ( a == b || b == c || a == c ) continue;
				indices.Add( a ); indices.Add( b ); indices.Add( c );
			}
		}

		Pieces = pieces;
		Vertices = positions.Count;
		Triangles = indices.Count / 3;

		if ( !_collider.IsValid() )
		{
			_collider = Components.GetOrCreate<ModelCollider>( FindMode.EverythingInSelf );
			_collider.Flags |= ComponentFlags.NotSaved; // rebuilt on load, never written to the scene file
		}

		if ( Triangles == 0 )
		{
			_collider.Enabled = false;
			Log.Info( $"GolfHoleCollision: {GameObject.Name} has no collision: {found} pieces found, {Pieces} with collision data" );
			return;
		}

		_collider.Model = Model.Builder.AddCollisionMesh( positions, indices ).Create();
		_collider.Static = true;
		_collider.Surface = Surface.FindByName( SurfaceName ) ?? _collider.Surface;
		_collider.Enabled = true;

		Log.Info( $"GolfHoleCollision: {GameObject.Name} built {Triangles} triangles from {Pieces} of {found} pieces, {modelPieces} of them models ({Vertices} welded vertices) in {watch.ElapsedMilliseconds} ms" );
	}
}