Code/FloraDefinition.cs

Defines data classes for flora painting: FloraEntry stores per-model options (model, weight, scale, alignment, physics, rendering). FloraDefinition is a GameResource asset that holds a list of entries and painting rules (spacing, per-stroke, slope limit, collision radius) and provides methods to pick a weighted random entry and find an entry by model path.

File Access
using System;
using System.Collections.Generic;
using Sandbox;

namespace RedSnail.FloraTool;

/// <summary>
/// One kind of flora the brush can plant. Weight decides how often it comes up relative to the
/// other entries in the definition.
/// </summary>
public sealed class FloraEntry
{
	[Property]
	public Model Model { get; set; }

	/// <summary>Relative chance of this entry being picked. Zero excludes it without deleting it.</summary>
	[Property, Range( 0, 10 )]
	public float Weight { get; set; } = 1.0f;

	[Property]
	public RangedFloat Scale { get; set; } = new( 0.85f, 1.25f );

	/// <summary>Random spin about the vertical axis, so repeated instances don't read as clones.</summary>
	[Property]
	public bool RandomYaw { get; set; } = true;

	/// <summary>
	/// Tilts the instance toward the surface normal. Right for rocks and bushes, usually wrong for
	/// trees - a trunk growing perpendicular to a hillside looks broken.
	/// </summary>
	[Property, Range( 0, 1 )]
	public float AlignToNormal { get; set; } = 0.0f;

	/// <summary>Random lean away from vertical, in degrees. A little goes a long way on trees.</summary>
	[Property, Range( 0, 45 )]
	public float RandomTilt { get; set; } = 0.0f;

	/// <summary>Sinks the instance into the ground, hiding the seam where the base meets the surface.</summary>
	[Property, Range( 0, 64 )]
	public float SinkDepth { get; set; } = 0.0f;

	/// <summary>
	/// Gives this entry real collision. Colliders are only created near the player, so this is about
	/// whether the flora is solid at all - not about paying for every painted instance at once.
	/// </summary>
	[Property, Group( "Physics" )]
	public bool EnablePhysics { get; set; } = true;

	[Property, Group( "Rendering" )]
	public bool CastShadows { get; set; } = true;

	public bool HasModel => Model is not null && !string.IsNullOrEmpty( Model.ResourcePath );
}

/// <summary>
/// A palette of flora plus the rules used when painting it. Shared by every
/// <see cref="FloraRenderer"/> that references it, so a whole world can be retuned from one asset.
/// </summary>
[AssetType( Name = "Flora Definition", Extension = "floradef", Category = "Flora" )]
public sealed class FloraDefinition : GameResource
{
	[Property]
	public List<FloraEntry> Entries { get; set; } = [];

	/// <summary>Minimum gap between instances. Stops the brush stacking trees on one spot.</summary>
	[Property, Group( "Painting" ), Range( 8, 1024 )]
	public float Spacing { get; set; } = 128.0f;

	/// <summary>Instances attempted per brush stroke. Spacing still governs how many actually land.</summary>
	[Property, Group( "Painting" ), Range( 1, 64 )]
	public int PerStroke { get; set; } = 8;

	/// <summary>Minimum ground normal Z. Steeper than this and nothing plants, so cliffs stay bare.</summary>
	[Property, Group( "Painting" ), Range( 0, 1 )]
	public float SlopeLimit { get; set; } = 0.6f;

	/// <summary>
	/// Radius around the viewer within which entries flagged <see cref="FloraEntry.EnablePhysics"/>
	/// get real colliders. Keep it just past where the player can reach.
	/// </summary>
	[Property, Group( "Physics" ), Range( 256, 20000 )]
	public float CollisionRadius { get; set; } = 4000.0f;

	/// <summary>Picks an entry at random, biased by weight. Null when nothing is usable.</summary>
	public FloraEntry PickEntry( Random random )
	{
		if ( Entries is null || Entries.Count == 0 )
			return null;

		var total = 0.0f;
		foreach ( var entry in Entries )
		{
			if ( entry?.HasModel is true && entry.Weight > 0.0f )
				total += entry.Weight;
		}

		if ( total <= 0.0f )
			return null;

		var pick = random.NextSingle() * total;

		foreach ( var entry in Entries )
		{
			if ( entry?.HasModel is not true || entry.Weight <= 0.0f )
				continue;

			pick -= entry.Weight;
			if ( pick <= 0.0f )
				return entry;
		}

		return null;
	}

	/// <summary>
	/// The entry that owns a model path. Instances are stored per model, so this is how the renderer
	/// recovers the authored settings - shadows, physics, LOD distances - for a batch.
	/// </summary>
	public FloraEntry FindEntry( string modelPath )
	{
		if ( Entries is null || string.IsNullOrEmpty( modelPath ) )
			return null;

		foreach ( var entry in Entries )
		{
			if ( entry?.HasModel is true && entry.Model.ResourcePath == modelPath )
				return entry;
		}

		return null;
	}
}