Code/Components/GaussianSplatCollider.cs
namespace Sandbox;

/// <summary>
/// How the voxel shell is sealed before mesh extraction.
/// Splat scans produce thin shells with holes, the fill mode determines how those holes are patched to create watertight collision.
/// </summary>
public enum CollisionFillMode
{
	/// <summary>
	/// No fill, only the raw voxel shell is used. Small holes remain.
	/// </summary>
	None,

	/// <summary>
	/// For outdoor/terrain scenes. Walks each vertical (XY) column upward from the bottom and fills everything below the first solid voxel,
	/// creating a solid ground volume. Holes in the floor disappear because the entire volume beneath the surface becomes solid.
	/// </summary>
	FloorFill,

	/// <summary>
	/// For interior/room scenes. Dilates the solid grid to bridge wall gaps, then flood-fills inward from the bounding-box boundary.
	/// Everything reachable from outside becomes solid, leaving only the enclosed interior as empty space. Produces thick watertight walls with no holes.
	/// </summary>
	ExternalFill,
}

/// <summary>
/// Generates a voxelized collision mesh from Gaussian splat positions.
/// Attach alongside <see cref="GaussianSplatRenderer"/>, it will automatically
/// pick up the loaded splat data and rebuild collision when it changes.
/// </summary>
[Title( "Gaussian Splat Collider" )]
[Category( "Physics" )]
[Icon( "blur_on" )]
public sealed class GaussianSplatCollider : Collider
{
	/// <summary>
	/// Mesh shapes are always concave (non-convex triangle soup), they can only be static.
	/// </summary>
	public override bool IsConcave => true;

	float _voxelSize = 4.0f;

	/// <summary>
	/// Size of each voxel in world units (inches). Smaller = more accurate but more triangles.
	/// </summary>
	[Property, Group( "Voxel" ), Range( 1f, 32f )]
	public float VoxelSize
	{
		get => _voxelSize;
		set
		{
			if ( _voxelSize == value ) return;
			_voxelSize = value;
			RebuildMesh();
		}
	}

	/// <summary>
	/// Minimum cumulative opacity a voxel must contain to be considered solid.
	/// Each splat contributes its opacity (0..1) to the voxel it occupies.
	/// Higher values filter out stray low-opacity splats in empty space.
	/// </summary>
	[Property, Group( "Voxel" ), Range( 0.1f, 50f )]
	public float MinOccupancy
	{
		get => _minOccupancy;
		set
		{
			if ( _minOccupancy == value ) return;
			_minOccupancy = value;
			RebuildMesh();
		}
	}

	float _minOccupancy = 3.0f;

	/// <summary>
	/// Minimum per-splat opacity required for a splat to contribute to collision.
	/// Splats below this are completely ignored by the voxelizer.
	/// This is separate from the render MinOpacity, collision typically needs a much higher threshold to avoid invisible floaters.
	/// </summary>
	[Property, Group( "Voxel" ), Range( 0f, 1f )]
	public float MinOpacity
	{
		get => _minOpacity;
		set
		{
			if ( _minOpacity == value ) return;
			_minOpacity = value;
			RebuildMesh();
		}
	}

	float _minOpacity = 0.4f;

	/// <summary>
	/// When enabled, only the single largest connected cluster of solid voxels is kept, all smaller floating islands are removed.
	/// This is the most effective way to eliminate stray collision in empty space.
	/// </summary>
	[Property, Group( "Voxel" )]
	public bool KeepLargestClusterOnly
	{
		get => _keepLargestClusterOnly;
		set
		{
			if ( _keepLargestClusterOnly == value ) return;
			_keepLargestClusterOnly = value;
			RebuildMesh();
		}
	}

	bool _keepLargestClusterOnly = true;

	/// <summary>
	/// How the thin voxel shell is sealed before mesh extraction.
	/// FloorFill for outdoor/terrain, ExternalFill for rooms, None to skip.
	/// </summary>
	[Property, Group( "Fill" )]
	public CollisionFillMode FillMode
	{
		get => _fillMode;
		set
		{
			if ( _fillMode == value ) return;
			_fillMode = value;
			RebuildMesh();
		}
	}

	CollisionFillMode _fillMode = CollisionFillMode.FloorFill;

	/// <summary>
	/// Dilation distance in voxels used by ExternalFill to bridge small wall gaps
	/// before the outside flood-fill. Increase if walls have noisy holes the fill
	/// leaks through. Only used when FillMode is ExternalFill.
	/// </summary>
	[Property, Group( "Fill" ), Range( 0, 8 )]
	public int DilationRadius
	{
		get => _dilationRadius;
		set
		{
			if ( _dilationRadius == value ) return;
			_dilationRadius = value;
			RebuildMesh();
		}
	}

	int _dilationRadius = 2;

	/// <summary>
	/// For FloorFill: minimum number of solid neighbors (out of 8 in XY plane)
	/// an XY column must have to be eligible for floor-filling.
	/// Prevents filling columns in large open exterior areas. Only used when FillMode is FloorFill.
	/// </summary>
	[Property, Group( "Fill" ), Range( 0, 8 )]
	public int FloorFillNeighborThreshold
	{
		get => _floorFillNeighborThreshold;
		set
		{
			if ( _floorFillNeighborThreshold == value ) return;
			_floorFillNeighborThreshold = value;
			RebuildMesh();
		}
	}

	int _floorFillNeighborThreshold = 1;

	/// <summary>
	/// Number of Laplacian smoothing iterations to apply to the collision mesh.
	/// 0 = axis-aligned greedy mesh ("faces" mode, fastest, staircase edges).
	/// Higher values produce smoother slopes and contours at the cost of more triangles and slight collision inaccuracy. 8-16 is a good starting range.
	/// </summary>
	[Property, Group( "Mesh" ), Range( 0, 64 )]
	public int SmoothIterations
	{
		get => _smoothIterations;
		set
		{
			if ( _smoothIterations == value ) return;
			_smoothIterations = value;
			RebuildMesh();
		}
	}

	int _smoothIterations = 0;

	// Cached source data for rebuilding when settings change
	SceneGaussianSplatObject.SplatPosition[] _cachedPositions;
	float[] _cachedOpacities;

	// Cached mesh data
	Vector3[] _meshVertices;
	int[] _meshIndices;

	/// <summary>
	/// Triangle count of the generated collision mesh (read-only).
	/// </summary>
	[Property, Group( "Info" ), ReadOnly]
	public int TriangleCount => (_meshIndices?.Length ?? 0) / 3;

	protected override IEnumerable<PhysicsShape> CreatePhysicsShapes( PhysicsBody targetBody, Transform local )
	{
		if ( _meshVertices is null || _meshIndices is null || _meshVertices.Length == 0 )
			yield break;

		// Apply the GameObject's world scale to the mesh vertices so the
		// collision matches the rendered splat cloud at any transform scale.
		var scale = WorldScale;
		var needsScale = scale != Vector3.One;
		var vertices = _meshVertices;

		if ( needsScale )
		{
			vertices = new Vector3[_meshVertices.Length];
			for ( int i = 0; i < vertices.Length; i++ )
				vertices[i] = _meshVertices[i] * scale;
		}

		var shape = targetBody.AddMeshShape( vertices, _meshIndices );
		if ( shape.IsValid() )
			yield return shape;
	}

	/// <summary>
	/// Rebuild the collision mesh from the given splat positions and per-splat opacities.
	/// Called by <see cref="GaussianSplatRenderer"/> when splat data is loaded.
	/// </summary>
	public void BuildFromPositions( SceneGaussianSplatObject.SplatPosition[] positions, float[] opacities )
	{
		_cachedPositions = positions;
		_cachedOpacities = opacities;
		RebuildMesh();
	}

	/// <summary>
	/// Clear collision data.
	/// </summary>
	public void Clear()
	{
		_cachedPositions = null;
		_cachedOpacities = null;
		_meshVertices = null;
		_meshIndices = null;
		Rebuild();
	}

	void RebuildMesh()
	{
		if ( _cachedPositions is null || _cachedPositions.Length == 0 )
		{
			_meshVertices = null;
			_meshIndices = null;
			Rebuild();
			return;
		}

		BuildVoxelMesh( _cachedPositions, _cachedOpacities, _voxelSize, _minOccupancy, _minOpacity,
			_keepLargestClusterOnly, _fillMode, _dilationRadius, _floorFillNeighborThreshold,
			_smoothIterations, out _meshVertices, out _meshIndices );
		Rebuild();
	}

	/// <summary>
	/// Re-generate the mesh when voxel settings change (called via property setters → Rebuild)
	/// The base Collider.Rebuild calls RebuildImmediately which calls CreatePhysicsShapes,
	/// so we just need the mesh data to be current before that happens.
	/// </summary>
	protected override void RebuildImmediately()
	{
		// If we have cached source data and the mesh needs regenerating,
		// do it before the base class tries to create physics shapes.
		if ( _cachedPositions is not null && _meshVertices is null )
		{
			BuildVoxelMesh( _cachedPositions, _cachedOpacities, _voxelSize, _minOccupancy, _minOpacity,
				_keepLargestClusterOnly, _fillMode, _dilationRadius, _floorFillNeighborThreshold,
				_smoothIterations, out _meshVertices, out _meshIndices );
		}

		base.RebuildImmediately();
	}

	/// <summary>
	/// Full voxel collision pipeline following the splat-transform approach:
	/// 1. Accumulate opacity per voxel, threshold to solid set
	/// 2. Keep largest connected cluster (remove floaters)
	/// 3. Shell fill (FloorFill or ExternalFill) to seal holes
	/// 4. Greedy mesh extraction
	/// </summary>
	static void BuildVoxelMesh(
		ReadOnlySpan<SceneGaussianSplatObject.SplatPosition> positions,
		ReadOnlySpan<float> opacities,
		float voxelSize,
		float minOccupancy,
		float minOpacity,
		bool keepLargestClusterOnly,
		CollisionFillMode fillMode,
		int dilationRadius,
		int floorFillNeighborThreshold,
		int smoothIterations,
		out Vector3[] outVertices,
		out int[] outIndices )
	{
		float invSize = 1f / voxelSize;
		bool hasOpacities = opacities.Length == positions.Length;

		// Step 1: Accumulate opacity per voxel cell, skipping splats below the opacity floor
		var voxelDensity = new Dictionary<(int x, int y, int z), float>( positions.Length / 4 );

		for ( int i = 0; i < positions.Length; i++ )
		{
			float opacity = hasOpacities ? opacities[i] : 1f;

			if ( opacity < minOpacity )
				continue;

			var p = positions[i].Position;
			var key = (
				(int)MathF.Floor( p.x * invSize ),
				(int)MathF.Floor( p.y * invSize ),
				(int)MathF.Floor( p.z * invSize )
			);

			if ( voxelDensity.TryGetValue( key, out float existing ) )
				voxelDensity[key] = existing + opacity;
			else
				voxelDensity[key] = opacity;
		}

		// Step 2: Build solid voxel set from density threshold
		var solidVoxels = new HashSet<(int x, int y, int z)>( voxelDensity.Count );
		foreach ( var (key, density) in voxelDensity )
		{
			if ( density >= minOccupancy )
				solidVoxels.Add( key );
		}

		if ( solidVoxels.Count == 0 )
		{
			outVertices = Array.Empty<Vector3>();
			outIndices = Array.Empty<int>();
			return;
		}

		// Step 3: Keep only the largest connected cluster to remove floaters
		if ( keepLargestClusterOnly )
		{
			KeepLargestCluster( solidVoxels );
		}

		if ( solidVoxels.Count == 0 )
		{
			outVertices = Array.Empty<Vector3>();
			outIndices = Array.Empty<int>();
			return;
		}

		// Step 4: Shell fill, seal holes in the thin voxel surface
		switch ( fillMode )
		{
			case CollisionFillMode.FloorFill:
				ApplyFloorFill( solidVoxels, floorFillNeighborThreshold );
				break;
			case CollisionFillMode.ExternalFill:
				ApplyExternalFill( solidVoxels, dilationRadius );
				break;
		}

		// Step 5: Mesh extraction, greedy (faces) or smoothed
		if ( smoothIterations > 0 )
		{
			ExtractSmoothedMesh( solidVoxels, voxelSize, smoothIterations, out outVertices, out outIndices );
		}
		else
		{
			ExtractSurfaceMesh( solidVoxels, voxelSize, out outVertices, out outIndices );
		}
	}

	/// <summary>
	/// Floor fill for outdoor/terrain scenes.
	/// For each (X,Y) column that has at least one solid voxel and enough solid neighbors, fills everything below
	/// the highest solid voxel in that column. This turns a thin ground shell into a solid volume with no holes.
	/// </summary>
	static void ApplyFloorFill( HashSet<(int x, int y, int z)> solidVoxels, int neighborThreshold )
	{
		// Compute bounds
		int minX = int.MaxValue, minY = int.MaxValue, minZ = int.MaxValue;
		int maxX = int.MinValue, maxY = int.MinValue, maxZ = int.MinValue;

		foreach ( var (vx, vy, vz) in solidVoxels )
		{
			if ( vx < minX ) minX = vx; if ( vx > maxX ) maxX = vx;
			if ( vy < minY ) minY = vy; if ( vy > maxY ) maxY = vy;
			if ( vz < minZ ) minZ = vz; if ( vz > maxZ ) maxZ = vz;
		}

		// Build a set of XY columns that contain at least one solid voxel,
		// tracking the highest Z in each column
		var columnMaxZ = new Dictionary<(int x, int y), int>();
		foreach ( var (vx, vy, vz) in solidVoxels )
		{
			var col = (vx, vy);
			if ( columnMaxZ.TryGetValue( col, out int existing ) )
				columnMaxZ[col] = Math.Max( existing, vz );
			else
				columnMaxZ[col] = vz;
		}

		// 8-connected XY neighbor offsets for the proximity check
		ReadOnlySpan<(int dx, int dy)> xyDirs =
		[
			(1, 0), (-1, 0), (0, 1), (0, -1),
			(1, 1), (1, -1), (-1, 1), (-1, -1)
		];

		// For each column, fill from minZ up to the highest solid voxel
		// only if the column has enough neighboring columns with solid voxels
		foreach ( var (col, topZ) in columnMaxZ )
		{
			if ( neighborThreshold > 0 )
			{
				int solidNeighborCols = 0;
				foreach ( var (dx, dy) in xyDirs )
				{
					if ( columnMaxZ.ContainsKey( (col.x + dx, col.y + dy) ) )
						solidNeighborCols++;
				}

				if ( solidNeighborCols < neighborThreshold )
					continue;
			}

			// Fill the entire column below the top solid voxel
			for ( int z = minZ; z <= topZ; z++ )
			{
				solidVoxels.Add( (col.x, col.y, z) );
			}
		}
	}

	/// <summary>
	/// External fill for interior/room scenes. Dilates the solid set to bridge
	/// small wall gaps, then flood-fills from the bounding-box boundary inward.
	/// Everything reachable from outside becomes solid, leaving only the enclosed
	/// interior as empty space. Produces thick watertight walls.
	/// </summary>
	static void ApplyExternalFill( HashSet<(int x, int y, int z)> solidVoxels, int dilationRadius )
	{
		// Compute bounds (with padding for flood boundary)
		int minX = int.MaxValue, minY = int.MaxValue, minZ = int.MaxValue;
		int maxX = int.MinValue, maxY = int.MinValue, maxZ = int.MinValue;

		foreach ( var (vx, vy, vz) in solidVoxels )
		{
			if ( vx < minX ) minX = vx; if ( vx > maxX ) maxX = vx;
			if ( vy < minY ) minY = vy; if ( vy > maxY ) maxY = vy;
			if ( vz < minZ ) minZ = vz; if ( vz > maxZ ) maxZ = vz;
		}

		// Step A: Dilate — expand solid set by dilationRadius voxels to bridge gaps
		if ( dilationRadius > 0 )
		{
			var dilated = new HashSet<(int x, int y, int z)>( solidVoxels );

			// Iterative 6-connected dilation, one voxel per pass
			ReadOnlySpan<(int dx, int dy, int dz)> dirs =
			[
				(1, 0, 0), (-1, 0, 0),
				(0, 1, 0), (0, -1, 0),
				(0, 0, 1), (0, 0, -1)
			];

			for ( int pass = 0; pass < dilationRadius; pass++ )
			{
				var frontier = new List<(int x, int y, int z)>();
				foreach ( var cell in dilated )
				{
					foreach ( var (dx, dy, dz) in dirs )
					{
						var n = (cell.x + dx, cell.y + dy, cell.z + dz);
						if ( !dilated.Contains( n ) )
							frontier.Add( n );
					}
				}

				foreach ( var n in frontier )
					dilated.Add( n );
			}

			// Use the dilated set for the flood fill (not the original)
			// but we'll mark voxels in the original solidVoxels set
			var dilatedForFlood = dilated;

			// Step B: Flood-fill from the bounding-box boundary, treating dilated voxels as walls.
			// Everything NOT reachable = interior void. Mark exterior as solid.
			int pad = 2; // padding around bounds for the flood seed layer
			int bMinX = minX - pad, bMinY = minY - pad, bMinZ = minZ - pad;
			int bMaxX = maxX + pad, bMaxY = maxY + pad, bMaxZ = maxZ + pad;

			var exterior = new HashSet<(int x, int y, int z)>();
			var queue = new Queue<(int x, int y, int z)>();

			// Seed all boundary cells that aren't dilated-solid
			for ( int x = bMinX; x <= bMaxX; x++ )
			{
				for ( int y = bMinY; y <= bMaxY; y++ )
				{
					for ( int z = bMinZ; z <= bMaxZ; z++ )
					{
						bool isBoundary = x == bMinX || x == bMaxX
							|| y == bMinY || y == bMaxY
							|| z == bMinZ || z == bMaxZ;

						if ( isBoundary && !dilatedForFlood.Contains( (x, y, z) ) )
						{
							if ( exterior.Add( (x, y, z) ) )
								queue.Enqueue( (x, y, z) );
						}
					}
				}
			}

			// Flood-fill exterior
			while ( queue.Count > 0 )
			{
				var current = queue.Dequeue();

				foreach ( var (dx, dy, dz) in dirs )
				{
					var n = (current.x + dx, current.y + dy, current.z + dz);

					// Stay within padded bounds
					if ( n.Item1 < bMinX || n.Item1 > bMaxX
						|| n.Item2 < bMinY || n.Item2 > bMaxY
						|| n.Item3 < bMinZ || n.Item3 > bMaxZ )
						continue;

					// Can't flood through dilated walls
					if ( dilatedForFlood.Contains( n ) )
						continue;

					if ( exterior.Add( n ) )
						queue.Enqueue( n );
				}
			}

			// Step C: Everything inside the padded bounds that is NOT exterior
			// and NOT already solid → mark as solid. This fills the void outside
			// the room, making walls infinitely thick.
			for ( int x = minX; x <= maxX; x++ )
			{
				for ( int y = minY; y <= maxY; y++ )
				{
					for ( int z = minZ; z <= maxZ; z++ )
					{
						if ( !exterior.Contains( (x, y, z) ) )
							solidVoxels.Add( (x, y, z) );
					}
				}
			}
		}
	}

	/// <summary>
	/// 6-connected flood fill to find all connected components, then keep only
	/// the single largest one. This is the nuclear option for floating collision —
	/// it guarantees only the main structure has collision, regardless of how many
	/// stray splats exist elsewhere.
	/// </summary>
	static void KeepLargestCluster( HashSet<(int x, int y, int z)> solidVoxels )
	{
		var remaining = new HashSet<(int x, int y, int z)>( solidVoxels );
		var queue = new Queue<(int x, int y, int z)>();
		List<(int x, int y, int z)> largestCluster = null;
		var currentCluster = new List<(int x, int y, int z)>();

		ReadOnlySpan<(int dx, int dy, int dz)> dirs =
		[
			(1, 0, 0), (-1, 0, 0),
			(0, 1, 0), (0, -1, 0),
			(0, 0, 1), (0, 0, -1)
		];

		while ( remaining.Count > 0 )
		{
			var seed = remaining.First();
			currentCluster.Clear();
			queue.Clear();
			queue.Enqueue( seed );
			remaining.Remove( seed );

			while ( queue.Count > 0 )
			{
				var current = queue.Dequeue();
				currentCluster.Add( current );

				foreach ( var (dx, dy, dz) in dirs )
				{
					var neighbor = (current.x + dx, current.y + dy, current.z + dz);
					if ( remaining.Remove( neighbor ) )
					{
						queue.Enqueue( neighbor );
					}
				}
			}

			if ( largestCluster is null || currentCluster.Count > largestCluster.Count )
			{
				largestCluster = new List<(int x, int y, int z)>( currentCluster );
			}
		}

		solidVoxels.Clear();

		if ( largestCluster is not null )
		{
			foreach ( var v in largestCluster )
				solidVoxels.Add( v );
		}
	}

	/// <summary>
	/// Greedy meshing: for each face direction, slice through the voxel grid one layer
	/// at a time. Within each slice, merge coplanar exposed faces into maximal rectangles.
	/// This dramatically reduces triangle count for flat surfaces (floors, walls, ceilings).
	/// </summary>
	static void ExtractSurfaceMesh(
		HashSet<(int x, int y, int z)> solidVoxels,
		float voxelSize,
		out Vector3[] outVertices,
		out int[] outIndices )
	{
		// Compute axis-aligned bounding box of the solid voxels
		int minX = int.MaxValue, minY = int.MaxValue, minZ = int.MaxValue;
		int maxX = int.MinValue, maxY = int.MinValue, maxZ = int.MinValue;

		foreach ( var (vx, vy, vz) in solidVoxels )
		{
			if ( vx < minX ) minX = vx; if ( vx > maxX ) maxX = vx;
			if ( vy < minY ) minY = vy; if ( vy > maxY ) maxY = vy;
			if ( vz < minZ ) minZ = vz; if ( vz > maxZ ) maxZ = vz;
		}

		int sizeX = maxX - minX + 1;
		int sizeY = maxY - minY + 1;
		int sizeZ = maxZ - minZ + 1;

		var vertices = new List<Vector3>( solidVoxels.Count );
		var indices = new List<int>( solidVoxels.Count );

		// Process each of the 6 face directions.
		// For each direction, we iterate layer-by-layer along the normal axis
		// and greedily merge exposed faces within each 2D slice.
		//
		// Face directions: 0=+X, 1=-X, 2=+Y, 3=-Y, 4=+Z, 5=-Z
		// For each face we define:
		//   axis: the normal axis (0=X, 1=Y, 2=Z)
		//   sign: +1 or -1 (which side of the voxel)
		//   u,v: the two tangent axes for the 2D slice

		for ( int face = 0; face < 6; face++ )
		{
			int axis = face / 2;      // 0,0,1,1,2,2
			int sign = (face % 2 == 0) ? 1 : -1;

			// Tangent axes for the 2D slice
			int uAxis = (axis + 1) % 3;
			int vAxis = (axis + 2) % 3;

			// Bounds along each axis
			int axisMin = axis switch { 0 => minX, 1 => minY, _ => minZ };
			int axisMax = axis switch { 0 => maxX, 1 => maxY, _ => maxZ };
			int uMin = uAxis switch { 0 => minX, 1 => minY, _ => minZ };
			int uMax = uAxis switch { 0 => maxX, 1 => maxY, _ => maxZ };
			int vMin = vAxis switch { 0 => minX, 1 => minY, _ => minZ };
			int vMax = vAxis switch { 0 => maxX, 1 => maxY, _ => maxZ };
			int uSize = uMax - uMin + 1;
			int vSize = vMax - vMin + 1;

			// Mask of exposed faces in this slice
			var mask = new bool[uSize * vSize];

			for ( int layer = axisMin; layer <= axisMax; layer++ )
			{
				// Build the mask for this slice
				int maskIdx = 0;
				for ( int v = vMin; v <= vMax; v++ )
				{
					for ( int u = uMin; u <= uMax; u++ )
					{
						// Reconstruct 3D coords from axis/u/v
						var coords = new int[3];
						coords[axis] = layer;
						coords[uAxis] = u;
						coords[vAxis] = v;

						var voxel = (coords[0], coords[1], coords[2]);
						bool isSolid = solidVoxels.Contains( voxel );

						// Check neighbor in the normal direction
						var neighborCoords = new int[3];
						neighborCoords[axis] = layer + sign;
						neighborCoords[uAxis] = u;
						neighborCoords[vAxis] = v;
						var neighbor = (neighborCoords[0], neighborCoords[1], neighborCoords[2]);

						mask[maskIdx++] = isSolid && !solidVoxels.Contains( neighbor );
					}
				}

				// Greedy merge: scan the 2D mask and find maximal rectangles
				for ( int v = 0; v < vSize; v++ )
				{
					for ( int u = 0; u < uSize; )
					{
						int idx = v * uSize + u;
						if ( !mask[idx] )
						{
							u++;
							continue;
						}

						// Expand width (along u)
						int w = 1;
						while ( u + w < uSize && mask[v * uSize + u + w] )
							w++;

						// Expand height (along v) as far as the full width remains exposed
						int h = 1;
						bool canExpand = true;
						while ( v + h < vSize && canExpand )
						{
							for ( int du = 0; du < w; du++ )
							{
								if ( !mask[(v + h) * uSize + u + du] )
								{
									canExpand = false;
									break;
								}
							}
							if ( canExpand ) h++;
						}

						// Clear the merged region from the mask
						for ( int dv = 0; dv < h; dv++ )
							for ( int du = 0; du < w; du++ )
								mask[(v + dv) * uSize + u + du] = false;

						// Emit a quad for this merged rectangle
						// The quad corners in 3D, placed on the correct face of the voxel
						float faceOffset = sign > 0 ? 1f : 0f;
						float layerF = (layer + faceOffset) * voxelSize;
						float u0 = (uMin + u) * voxelSize;
						float u1 = (uMin + u + w) * voxelSize;
						float v0 = (vMin + v) * voxelSize;
						float v1 = (vMin + v + h) * voxelSize;

						var c0 = new float[3];
						var c1 = new float[3];
						var c2 = new float[3];
						var c3 = new float[3];

						// All four corners share the same position along the normal axis
						c0[axis] = layerF; c1[axis] = layerF; c2[axis] = layerF; c3[axis] = layerF;

						// Winding order depends on face sign to keep outward-facing normals
						if ( sign > 0 )
						{
							c0[uAxis] = u0; c0[vAxis] = v0;
							c1[uAxis] = u1; c1[vAxis] = v0;
							c2[uAxis] = u1; c2[vAxis] = v1;
							c3[uAxis] = u0; c3[vAxis] = v1;
						}
						else
						{
							c0[uAxis] = u0; c0[vAxis] = v0;
							c1[uAxis] = u0; c1[vAxis] = v1;
							c2[uAxis] = u1; c2[vAxis] = v1;
							c3[uAxis] = u1; c3[vAxis] = v0;
						}

						int baseVertex = vertices.Count;
						vertices.Add( new Vector3( c0[0], c0[1], c0[2] ) );
						vertices.Add( new Vector3( c1[0], c1[1], c1[2] ) );
						vertices.Add( new Vector3( c2[0], c2[1], c2[2] ) );
						vertices.Add( new Vector3( c3[0], c3[1], c3[2] ) );

						indices.Add( baseVertex + 0 );
						indices.Add( baseVertex + 1 );
						indices.Add( baseVertex + 2 );
						indices.Add( baseVertex + 0 );
						indices.Add( baseVertex + 2 );
						indices.Add( baseVertex + 3 );

						u += w;
					}
				}
			}
		}

		outVertices = vertices.ToArray();
		outIndices = indices.ToArray();
	}

	/// <summary>
	/// Extract a surface mesh with shared vertices at voxel corners, then apply
	/// Laplacian smoothing to produce natural contours instead of staircase edges.
	/// Suitable for character collision on slopes and curved surfaces.
	/// </summary>
	static void ExtractSmoothedMesh(
		HashSet<(int x, int y, int z)> solidVoxels,
		float voxelSize,
		int iterations,
		out Vector3[] outVertices,
		out int[] outIndices )
	{
		// Face definitions: neighbor offset + 4 corner offsets (CCW winding for outward normal)
		ReadOnlySpan<int> faceNeighbor =
		[
			1, 0, 0,   // +X
			-1, 0, 0,  // -X
			0, 1, 0,   // +Y
			0, -1, 0,  // -Y
			0, 0, 1,   // +Z
			0, 0, -1,  // -Z
		];

		// 4 corner offsets per face (integer coords relative to voxel origin)
		ReadOnlySpan<int> faceCorners =
		[
			// +X face
			1,0,0,  1,1,0,  1,1,1,  1,0,1,
			// -X face
			0,1,0,  0,0,0,  0,0,1,  0,1,1,
			// +Y face
			1,1,0,  0,1,0,  0,1,1,  1,1,1,
			// -Y face
			0,0,0,  1,0,0,  1,0,1,  0,0,1,
			// +Z face
			0,0,1,  1,0,1,  1,1,1,  0,1,1,
			// -Z face
			1,0,0,  0,0,0,  0,1,0,  1,1,0,
		];

		// Step 1: Extract faces with welded vertices at voxel corners.
		// Vertex key is the integer corner position, shared between adjacent faces.
		var vertexMap = new Dictionary<(int x, int y, int z), int>();
		var vertexList = new List<Vector3>();
		var indexList = new List<int>();

		foreach ( var (vx, vy, vz) in solidVoxels )
		{
			for ( int face = 0; face < 6; face++ )
			{
				int ni = face * 3;
				var neighbor = (vx + faceNeighbor[ni], vy + faceNeighbor[ni + 1], vz + faceNeighbor[ni + 2]);
				if ( solidVoxels.Contains( neighbor ) )
					continue;

				// Get or create the 4 corner vertices for this face
				int ci = face * 12;
				var vi = new int[4];

				for ( int c = 0; c < 4; c++ )
				{
					int ci2 = ci + c * 3;
					var cornerKey = (vx + faceCorners[ci2], vy + faceCorners[ci2 + 1], vz + faceCorners[ci2 + 2]);

					if ( !vertexMap.TryGetValue( cornerKey, out int idx ) )
					{
						idx = vertexList.Count;
						vertexMap[cornerKey] = idx;
						vertexList.Add( new Vector3( cornerKey.Item1 * voxelSize, cornerKey.Item2 * voxelSize, cornerKey.Item3 * voxelSize ) );
					}

					vi[c] = idx;
				}

				// Two triangles per quad
				indexList.Add( vi[0] );
				indexList.Add( vi[1] );
				indexList.Add( vi[2] );
				indexList.Add( vi[0] );
				indexList.Add( vi[2] );
				indexList.Add( vi[3] );
			}
		}

		if ( vertexList.Count == 0 )
		{
			outVertices = Array.Empty<Vector3>();
			outIndices = Array.Empty<int>();
			return;
		}

		// Step 2: Build adjacency, for each vertex, which other vertices are connected by an edge
		var adjacency = new HashSet<int>[vertexList.Count];
		for ( int i = 0; i < adjacency.Length; i++ )
			adjacency[i] = new HashSet<int>();

		for ( int i = 0; i < indexList.Count; i += 3 )
		{
			int a = indexList[i], b = indexList[i + 1], c = indexList[i + 2];
			adjacency[a].Add( b ); adjacency[a].Add( c );
			adjacency[b].Add( a ); adjacency[b].Add( c );
			adjacency[c].Add( a ); adjacency[c].Add( b );
		}

		// Step 3: Laplacian smoothing, iteratively move each vertex toward
		// the average of its edge-connected neighbors. Uses Taubin smoothing
		// (alternating positive/negative lambda) to smooth without shrinkage.
		var positions = vertexList.ToArray();
		var temp = new Vector3[positions.Length];
		float lambda = 0.5f;
		float mu = -0.53f; // slightly larger magnitude than lambda to prevent shrinkage

		for ( int iter = 0; iter < iterations; iter++ )
		{
			float factor = (iter % 2 == 0) ? lambda : mu;

			for ( int v = 0; v < positions.Length; v++ )
			{
				var neighbors = adjacency[v];
				if ( neighbors.Count == 0 )
				{
					temp[v] = positions[v];
					continue;
				}

				var avg = Vector3.Zero;
				foreach ( int n in neighbors )
					avg += positions[n];
				avg /= neighbors.Count;

				temp[v] = positions[v] + factor * (avg - positions[v]);
			}

			// Swap
			(positions, temp) = (temp, positions);
		}

		outVertices = positions;
		outIndices = indexList.ToArray();
	}

	protected override void DrawGizmos()
	{
		if ( !Gizmo.IsSelected && !Gizmo.IsHovered )
			return;

		if ( _meshVertices is null || _meshIndices is null || _meshIndices.Length < 3 )
			return;

		Gizmo.Draw.Color = Gizmo.Colors.Green.WithAlpha( Gizmo.IsSelected ? 0.5f : 0.1f );

		int totalTriangles = _meshIndices.Length / 3;

		// Batch triangle drawing to stay within the gizmo system's internal
		// vertex buffer limits. A single huge LineTriangles call gets silently
		// dropped when the mesh is large (common with dense SOG/PLY files).
		const int batchSize = 16384;

		for ( int start = 0; start < totalTriangles; start += batchSize )
		{
			int count = Math.Min( batchSize, totalTriangles - start );
			var triangles = new Triangle[count];

			for ( int i = 0; i < count; i++ )
			{
				int bi = (start + i) * 3;
				triangles[i] = new Triangle(
					_meshVertices[_meshIndices[bi]],
					_meshVertices[_meshIndices[bi + 1]],
					_meshVertices[_meshIndices[bi + 2]] );
			}

			Gizmo.Draw.LineTriangles( triangles );
		}
	}
}