Editor/Output/ArchCollision.cs

Editor utility for resolving and applying collision modes for architectural parts. It decides a part's collision mode from explicit settings, inherited layer/kit defaults, or canvas data, writes engine MeshComponent collision types, and creates/removes HullCollider primitives for "Solids" mode.

Native Interop
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

public enum ArchCollisionMode
{
	// Nothing to collide with at all. A silhouette on the skyline, a fitting no player reaches.
	None,
	// The generator's own convex pieces, as engine primitives. The cheapest answer that is still correct - and the
	// only one that keeps a colonnade walkable, because each column is its own shape.
	Solids,
	// One convex hull over the whole part. Fills every hole in it, which is right for a lump and wrong for anything
	// with a doorway or a gap the player goes through.
	Convex,
	// The exact concave mesh, triangle for triangle. Correct everywhere and the most expensive thing the solver has.
	Complex
}

// A part that carries its own answer, overriding the kit. Authored per part because ONE wall in a plan can need
// exact collision without dragging every other part in the scene onto a concave mesh.
public interface IArchCollides
{
	ArchCollisionMode? Collision { get; set; }
}

// WHAT A PART COLLIDES AS - asked here and nowhere else. The mode is resolved once, folded into the part's key so a
// toggle actually re-cooks, and written as engine components in one place.
//
// The engine constrains this more than it looks: PolygonMesh.Rebuild always cooks a hull, a collision mesh and a
// trace mesh whatever the setting, so MeshComponent.Collision buys nothing at BUILD time - it only decides which
// shapes reach the physics body. What Solids buys is the runtime: convex shapes instead of a concave mesh.
public static class ArchCollision
{
	// A CHILD FOLLOWS ITS PARENT until it is given an answer of its own. The chain is the layer stack the author sees:
	// the part, then every layer above it, then the unit or room a piece with no layer of its own was drawn under,
	// then the kit. Overriding a layer sets that layer and everything under it still following.
	public static ArchCollisionMode Resolve( ArchBuiltPart part, ArchKit kit, ArchLayerTree layers )
	{
		var wanted = part.Asked ?? Inherited( part.Source, layers ) ?? part.Scope ?? kit?.Physics ?? ArchCollisionMode.Solids;

		if ( wanted != ArchCollisionMode.Solids )
		{
			return wanted;
		}

		// Asked for solids and the generator described the whole part with them - the only case where dropping the
		// concave mesh cannot lose collision.
		if ( part.Canvas.Describes && part.Canvas.Solids.Any( solid => solid.IsUsable ) )
		{
			return ArchCollisionMode.Solids;
		}

		// Described in part or not at all, so the solids are not the piece. Convex would fill a doorway and Solids
		// would leave a hole to fall through; the honest fallback is the mesh the part already cooked.
		return ArchCollisionMode.Complex;
	}

	static ArchCollisionMode? Inherited( object source, ArchLayerTree layers )
	{
		return source is not null && layers?.Find( source ) is { } node ? Above( node ) : null;
	}

	// The nearest ancestor in the stack that has been given an answer. Nothing set anywhere above means "not answered
	// here" rather than a default, so the caller carries on down its own chain.
	public static ArchCollisionMode? Above( ArchLayerNode node )
	{
		for ( var above = node?.Parent; above is not null; above = above.Parent )
		{
			if ( above.Payload is IArchCollides { Collision: { } inherited } )
			{
				return inherited;
			}
		}

		return null;
	}

	// What a row shows and what its menu says it is following - one answer, so the badge and the menu cannot disagree
	// about whether this layer owns its mode.
	public static ArchCollisionMode Showing( ArchLayerNode node, ArchKit kit, out bool own )
	{
		var mine = (node?.Payload as IArchCollides)?.Collision;

		own = mine.HasValue;

		return mine ?? Above( node ) ?? kit?.Physics ?? ArchCollisionMode.Solids;
	}

	public static void Write( GameObject node, MeshComponent renderer, ArchMesh canvas, ArchCollisionMode mode )
	{
		renderer.Collision = mode switch
		{
			ArchCollisionMode.Complex => MeshComponent.CollisionType.Mesh,
			ArchCollisionMode.Convex => MeshComponent.CollisionType.Hull,
			_ => MeshComponent.CollisionType.None
		};

		Clear( node );

		if ( mode != ArchCollisionMode.Solids )
		{
			return;
		}

		foreach ( var solid in canvas.Solids.Where( solid => solid.IsUsable ) )
		{
			Shape( node, solid );
		}
	}

	// Rebuilt, never appended to: a part that came back with fewer pieces than last time would otherwise keep the
	// shapes of the ones that are gone, and the player would walk into a column that is no longer there.
	static void Clear( GameObject node )
	{
		foreach ( var collider in node.Components.GetAll<HullCollider>( FindMode.EverythingInSelf ).ToList() )
		{
			collider.Destroy();
		}
	}

	static void Shape( GameObject node, ArchSolid solid )
	{
		var collider = node.Components.Create<HullCollider>();

		switch ( solid.Form )
		{
			case ArchSolidForm.Box:
				collider.Type = HullCollider.PrimitiveType.Box;
				collider.Center = solid.Centre;
				collider.BoxSize = solid.Size;
				break;

			case ArchSolidForm.Cylinder:
				collider.Type = HullCollider.PrimitiveType.Cylinder;
				collider.Center = solid.Centre;
				collider.Radius = solid.Radius;
				collider.Height = solid.Height;
				collider.Slices = solid.Slices;
				break;

			default:
				collider.Type = HullCollider.PrimitiveType.Points;
				collider.Points = solid.Points.ToList();
				break;
		}
	}

	public static string Describe( ArchCollisionMode mode ) => mode switch
	{
		ArchCollisionMode.None => "No collision — nothing to walk into",
		ArchCollisionMode.Solids => "Convex pieces — one shape per solid the generator made",
		ArchCollisionMode.Convex => "One hull round the whole part — fills its holes",
		_ => "Exact mesh — every triangle, the expensive one"
	};

	// Proven names only - the editor ships an older icon set than the web font, and one it does not know draws nothing
	// at all rather than falling back. Many boxes for solids, one box round the lot for a hull.
	public static string Glyph( ArchCollisionMode mode ) => mode switch
	{
		ArchCollisionMode.None => "block",
		ArchCollisionMode.Solids => "view_module",
		ArchCollisionMode.Convex => "crop_square",
		_ => "grain"
	};
}