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 )
};
}
}