editor/AdvancedMappingTools/Procedural/PartPlan.cs

Editor-side data model for procedural part generation. Defines PartPlan (a tree of parts with mesh, transform, seed and hashes), PartOverride for hand-edited values, PartHash for mesh/material fingerprints, and GenContext which builds parts and resolves seeds, variants and materials.

namespace AdvancedMappingTools;
/// <summary>
/// Values one part was given by hand. They survive regeneration and win over the generator.
/// </summary>
public sealed class PartOverride
{
	public int? Seed { get; set; }
	public string Variant { get; set; }
}

/// <summary>
/// What a generator wants to exist: a tree of parts that touches no scene.
/// Comparing a plan with the scene decides what has to change.
/// </summary>
public sealed class PartPlan
{
	public string Id { get; init; }
	public string Name { get; init; }
	public string Kind { get; init; }
	public string Category { get; init; }
	public int Seed { get; init; }
	public bool UsesSeed { get; set; }
	public Transform Local { get; set; } = new( Vector3.Zero, Rotation.Identity );
	public PolygonMesh Mesh { get; init; }
	public Vector3 Dimensions { get; init; }
	public int GeometryHash { get; init; }
	public int MaterialHash { get; init; }

	int? _faceMaterialHash;

	public int FaceMaterialHash => _faceMaterialHash ??= PartHash.FaceMaterials( Mesh );

	/// <summary>
	/// Faces meeting at less than this many degrees shade as one surface.
	/// </summary>
	public float Smoothing { get; init; } = MeshKit.DefaultSmoothing;

	/// <summary>
	/// The colour this part is painted in, on top of its material. Nothing leaves the colour as it is.
	/// </summary>
	public Color? Tint { get; set; }

	public List<PartPlan> Children { get; } = [];

	public PartPlan Add( PartPlan child )
	{
		Children.Add( child );
		return child;
	}

	public PartPlan At( Vector3 position, float yaw = 0.0f )
	{
		Local = new Transform( position, Rotation.FromYaw( yaw ) );
		return this;
	}

	public IEnumerable<PartPlan> All()
	{
		yield return this;

		foreach ( var child in Children )
			foreach ( var part in child.All() )
				yield return part;
	}

	/// <summary>
	/// All parts joined into one mesh. Only for previews, parts stay separate in the scene.
	/// </summary>
	public PolygonMesh Flatten( Transform world )
	{
		var result = new PolygonMesh();

		void Visit( PartPlan part, Transform parent )
		{
			var transform = parent.ToWorld( part.Local );

			if ( part.Mesh is not null )
				result.MergeMesh( part.Mesh, transform, out _, out _, out _ );

			foreach ( var child in part.Children )
				Visit( child, transform );
		}

		Visit( this, world );

		return result;
	}
}

/// <summary>
/// Fingerprints of a mesh. A part whose mesh no longer matches was edited by hand.
/// </summary>
public static class PartHash
{
	const float Precision = 32.0f;

	public static int Geometry( PolygonMesh mesh )
	{
		if ( mesh is null ) return 0;

		// A sum does not care about vertex order, which may change when a scene is reloaded.
		var sum = 0u;
		var count = 0;

		foreach ( var p in mesh.GetVertexPositions() )
		{
			sum += Point( p );
			count++;
		}

		// The direction every face looks in, so a flipped face counts as an edit too.
		foreach ( var face in mesh.FaceHandles )
		{
			var ring = mesh.GetFaceVertices( face ).Select( mesh.GetVertexPosition ).ToArray();
			var normal = Vector3.Zero;

			for ( var i = 0; i < ring.Length; i++ )
				normal += Vector3.Cross( ring[i], ring[(i + 1) % ring.Length] );

			sum += Point( normal.Normal * 8.0f + mesh.GetFaceCenter( face ) );
		}

		return StableSeed.Of( (int)sum, $"{count}/{mesh.FaceHandles.Count()}" ) | 1;
	}

	static uint Point( Vector3 p )
	{
		// Through int first: casting a negative float straight to uint gives zero.
		var hash = (uint)(int)MathF.Round( p.x * Precision ) * 73856093u
			^ (uint)(int)MathF.Round( p.y * Precision ) * 19349663u
			^ (uint)(int)MathF.Round( p.z * Precision ) * 83492791u;

		hash ^= hash >> 13;

		return hash * 0x5bd1e995u;
	}

	/// <summary>Assignment per geometric face, independent of face/vertex enumeration order.</summary>
	public static int FaceMaterials( PolygonMesh mesh )
	{
		if ( mesh is null )
			return 0;
		var sum = 0u;
		var count = 0;
		foreach ( var face in mesh.FaceHandles )
		{
			var vertices = mesh.GetFaceVertices( face ).Select( mesh.GetVertexPosition ).Select( p => $"{(int)MathF.Round( p.x * Precision )},{(int)MathF.Round( p.y * Precision )},{(int)MathF.Round( p.z * Precision )}" ).OrderBy( p => p, StringComparer.Ordinal ).ToArray();
			var material = mesh.GetFaceMaterial( face )?.ResourcePath ?? "";
			sum += (uint)StableSeed.Of( 0, $"{vertices.Length}/{string.Join( ",", vertices )}/{material}" );
			count++;
		}

		return StableSeed.Of( (int)sum, $"faces/{count}" ) | 1;
	}

	public static int Materials( PolygonMesh mesh )
	{
		if ( mesh is null ) return 0;

		var names = mesh.FaceHandles
			.Select( x => mesh.GetFaceMaterial( x )?.ResourcePath ?? "" )
			.Distinct()
			.OrderBy( x => x, StringComparer.Ordinal );

		return StableSeed.Of( 0, string.Join( "|", names ) ) | 1;
	}
}

/// <summary>
/// Everything a generator may use: seeds, hand-made values and part construction.
/// </summary>
public sealed class GenContext( SeedSet seeds, IReadOnlyDictionary<string, PartOverride> overrides = null )
{
	public const string DefaultMaterial = "materials/default.vmat";

	readonly Dictionary<string, Material> _materials = [];

	public SeedSet Seeds { get; } = seeds;

	public SurfaceBuildContext Surface { get; init; }

	public int Seed( string category, string id ) =>
		overrides is not null && overrides.TryGetValue( id, out var part ) && part.Seed is int seed
			? seed
			: StableSeed.Of( Seeds.For( category ), id );

	public SeedStream Stream( string category, string id )
	{
		SeedUsers.Add( id );
		return new SeedStream( Seed( category, id ) );
	}

	/// <summary>
	/// The parts whose look comes from their seed.
	/// </summary>
	public HashSet<string> SeedUsers { get; } = [];

	public string Variant( string id ) =>
		overrides is not null && overrides.TryGetValue( id, out var part ) ? part.Variant : null;

	public Material Material( string path )
	{
		path = string.IsNullOrEmpty( path ) ? DefaultMaterial : path;

		if ( !_materials.TryGetValue( path, out var material ) )
			_materials[path] = material = Sandbox.Material.Load( path );

		return material;
	}

	public PartPlan Group( string id, string name, string kind, string category ) => new()
	{
		Id = id,
		Name = name,
		Kind = kind,
		Category = category,
		Seed = Seed( category, id )
	};

	/// <summary>
	/// A part with its own mesh. The shape is drawn in the coordinates of the parent,
	/// the part then sits at its centre so it moves and rotates around itself.
	/// </summary>
	public PartPlan Solid( string id, string name, string kind, string category, Material material, Action<MeshKit> build, Func<PolygonMesh, PolygonMesh> finish = null )
	{
		var kit = new MeshKit { Material = material };
		build( kit );

		var mesh = kit.Finish();

		if ( kit.FailedFaces > 0 )
			Log.Warning( $"Davelix Construct: {kit.FailedFaces} faces of {id} could not be added." );

		if ( mesh is null )
			return Group( id, name, kind, category );

		if ( finish is not null )
		{
			// A mesh that was measured keeps the triangles it worked out, and they stay behind when it moves.
			var fresh = new PolygonMesh();

			fresh.MergeMesh( finish( mesh ), Transform.Zero, out _, out _, out _ );
			fresh.SetSmoothingAngle( kit.SmoothingAngle );
			fresh.TextureAlignToGrid( Transform.Zero );

			mesh = fresh;
		}

		var bounds = mesh.CalculateBounds();
		mesh.ApplyTransform( new Transform( -bounds.Center ) );

		return new PartPlan
		{
			Id = id,
			Name = name,
			Kind = kind,
			Category = category,
			Seed = Seed( category, id ),
			Local = new Transform( bounds.Center, Rotation.Identity ),
			Mesh = mesh,
			Dimensions = bounds.Size,
			Smoothing = kit.SmoothingAngle,
			GeometryHash = PartHash.Geometry( mesh ),
			MaterialHash = PartHash.Materials( mesh )
		};
	}
}