Code/Tilemap/TileMapCollider.cs
using Sandbox;
using Sandbox.Helper;
using System.Collections.Generic;

namespace Saandy.Tilemapper;

public sealed class TileMapCollider : Collider, ITilemapSceneEvent
{
	[Property, Order( -3 )] public TileMap Tilemap { get; set; }
	[Property, Order( -1 )] public bool DebugChanges { get; set; } = false;
	[Change][Property, Order( -2 )] public float Depth { get; set; } = 1;

	//
	// CreatePhysicsShapes can be called repeatedly while editing. Reuse the
	// collision mask instead of creating a LINQ Select().ToList() allocation
	// for every layer/rebuild.
	//
	private readonly List<int> CollisionMap = new();

	void OnDepthChanged( float oldValue, float newValue )
	{
		RebuildImmediately();
	}

	protected override IEnumerable<PhysicsShape> CreatePhysicsShapes( PhysicsBody targetBody, Transform local )
	{
		//
		// Always clear stale shapes first. If the tilemap disappeared, returning
		// before ClearShapes() would leave the old tilemap collision on this body.
		//
		targetBody.ClearShapes();

		if ( Tilemap == null || !Tilemap.IsValid() )
		{
			return targetBody.Shapes;
		}

		for ( int layerIndex = 0; layerIndex < Tilemap.LayerCount; layerIndex++ )
		{
			var layer = Tilemap.GetLayer( layerIndex );

			if ( layer == null || !layer.IsVisible || !layer.CollisionsEnabled ) { continue; }
			if ( layer.Tiles == null || layer.Tiles.Count == 0 || layer.Tiles.Count != layer.Width * layer.Height ) { continue; }

			//
			// Important:
			// Do not use TilesetId directly here.
			// A tile can be mask-present while still being a visual empty/non-colliding
			// placeholder for a brush, for example Bitmask2x2Edge tile 15.
			//
			CollisionMap.Clear();

			if ( CollisionMap.Capacity < layer.Tiles.Count )
			{
				CollisionMap.Capacity = layer.Tiles.Count;
			}

			for ( int i = 0; i < layer.Tiles.Count; i++ )
			{
				CollisionMap.Add( Tilemap.IsCollisionTile( layer.Tiles[i] ) ? 1 : 0 );
			}

			foreach ( BBox box in Histogram.GetOptimalQuadding( CollisionMap, layer.Width, layer.Height ) )
			{
				// Histogram boxes are in local layer array coordinates. Add the layer origin
				// to get map coordinates, then let TileMap convert those coordinates onto
				// the selected world plane. Collision shapes are not layer-offset because
				// these layers are for 2D gameplay.
				float mapX = (box.Center.x + layer.Origin.x) * Tilemap.TileSize;
				float mapY = (box.Center.y + layer.Origin.y) * Tilemap.TileSize;

				Vector3 pos = Tilemap.MapToWorld( mapX, mapY );
				Vector3 size = Tilemap.MapSizeToWorld(
					box.Size.x * Tilemap.TileSize,
					box.Size.y * Tilemap.TileSize,
					1
				);

#if DEBUG
				if ( DebugChanges )
				{
					Vector3 normal = Tilemap.GetPlaneNormal();
					Vector3 debugScale = Vector3.One - normal + normal * Depth;
					DebugOverlay.Box( pos, size * debugScale, Color.Red, 1 );
				}
#endif

				targetBody.AddBoxShape( pos, Rotation.Identity, size * 0.5f, rebuildMass: false );
			}
		}

		targetBody.BodyType = PhysicsBodyType.Static;
		targetBody.Surface = Surface;

		return targetBody.Shapes;
	}

	void ITilemapSceneEvent.OnTilemapStable()
	{
		if ( Game.IsPlaying ) { return; }
		RebuildImmediately();
	}
}