Editor/ForestGenerator.cs
using Sandbox;
using System.Collections.Generic;
using System.Diagnostics;
using Editor;
using Sandbox.Utility;
using System.Linq;
using FileSystem = Sandbox.FileSystem;
namespace Doubled.ForestGenerator.Editor
{
[Dock( "Editor", "Forest Generator", "park" )]
public class ForestGenerator : Widget
{
[Property] public BBox AreaBounds { get; set; } = new BBox( new Vector3( -2000, -2000, 0 ), new Vector3( 2000, 2000, 1000 ) );
[Property] public List<ForestPrefabConfig> Prefabs { get; set; } = new();
[Property, Range( 10, 500 )] public float Spacing { get; set; } = 100f;
[Property, Range( 0.001f, 0.1f )] public float NoiseScale { get; set; } = 0.01f;
[Property, Range( 0f, 1f )] public float NoiseThreshold { get; set; } = 0.4f;
[Property] public float Seed { get; set; } = 0f;
[Property, Range( 0.1f, 5f )] public float MinScale { get; set; } = 0.8f;
[Property, Range( 0.1f, 5f )] public float MaxScale { get; set; } = 1.2f;
[Property] public bool AlignToNormal { get; set; } = false;
private const string PresetsDir = "forest_generator/presets";
public ForestGenerator( Widget parent ) : base( parent, false )
{
Layout = Layout.Column();
Layout.Margin = 10;
Layout.Spacing = 5;
var scroll = new ScrollArea( this );
scroll.Canvas = new Widget( scroll );
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Margin = 16;
scroll.Canvas.Layout.Spacing = 8;
Layout.Add( scroll );
var body = scroll.Canvas.Layout;
var so = this.GetSerialized();
// ── Preset buttons ──────────────────────────────────────────
var presetRow = body.AddRow();
var loadPresetBtn = new Button( "Load Preset", "folder_open" );
loadPresetBtn.Pressed = LoadPreset;
presetRow.Add( loadPresetBtn );
var savePresetBtn = new Button( "Save Preset", "save" );
savePresetBtn.Pressed = SavePreset;
presetRow.Add( savePresetBtn );
body.AddSpacingCell( 12 );
// ── Settings ─────────────────────────────────────────────────
body.Add( new Label( "Area Bounds" ) );
body.Add( new BBoxControlWidget( so.GetProperty( nameof( AreaBounds ) ) ) );
body.Add( new Label( "Prefabs & Weights" ) );
body.Add( new ListControlWidget( so.GetProperty( nameof( Prefabs ) ) ) );
body.AddSpacingCell( 8 );
body.Add( new Label( "Spacing" ) );
body.Add( new FloatControlWidget( so.GetProperty( nameof( Spacing ) ) ) );
body.Add( new Label( "Noise Scale" ) );
body.Add( new FloatControlWidget( so.GetProperty( nameof( NoiseScale ) ) ) );
body.Add( new Label( "Noise Threshold" ) );
body.Add( new FloatControlWidget( so.GetProperty( nameof( NoiseThreshold ) ) ) );
body.Add( new Label( "Seed" ) );
body.Add( new FloatControlWidget( so.GetProperty( nameof( Seed ) ) ) );
body.AddSpacingCell( 8 );
body.Add( new Label( "Min/Max Scale" ) );
body.Add( new FloatControlWidget( so.GetProperty( nameof( MinScale ) ) ) );
body.Add( new FloatControlWidget( so.GetProperty( nameof( MaxScale ) ) ) );
body.Add( new Label( "Align To Normal" ) );
body.Add( new BoolControlWidget( so.GetProperty( nameof( AlignToNormal ) ) ) );
body.AddSpacingCell( 16 );
// ── Actions ──────────────────────────────────────────────────
var generateBtn = new Button( "Generate Forest", "park" );
generateBtn.Pressed = GenerateForest;
body.Add( generateBtn );
var clearBtn = new Button( "Clear Forest", "delete" );
clearBtn.Pressed = ClearForest;
body.Add( clearBtn );
}
private static string PresetsFolder => "forest_generator";
private void LoadPreset()
{
// List existing presets
FileSystem.Data.CreateDirectory( PresetsFolder );
var files = FileSystem.Data.FindFile( PresetsFolder, "*.json" ).ToArray();
if ( files.Length == 0 )
{
Log.Warning( "No saved presets found in FileSystem.Data/forest_generator/" );
return;
}
// Show a popup menu with available presets
var menu = new Menu( this );
foreach ( var file in files )
{
var name = System.IO.Path.GetFileNameWithoutExtension( file );
var path = $"{PresetsFolder}/{file}";
menu.AddOption( name, "folder_open", () =>
{
var data = FileSystem.Data.ReadJson<ForestPresetData>( path );
if ( data != null ) { ApplyPreset( data ); Log.Info( $"Loaded preset: {name}" ); }
else Log.Warning( $"Could not read preset: {path}" );
} );
}
menu.OpenAtCursor();
}
private void SavePreset()
{
var popup = new PopupWidget( this );
popup.Layout = Layout.Column();
popup.Layout.Margin = 12;
popup.Layout.Spacing = 8;
popup.Layout.Add( new Label( "Preset name:" ) );
var input = new LineEdit( popup );
input.PlaceholderText = "my_preset";
popup.Layout.Add( input );
var confirmBtn = new Button( "Save", "save" );
confirmBtn.Pressed = () =>
{
var name = input.Text.Trim();
if ( string.IsNullOrEmpty( name ) ) return;
FileSystem.Data.CreateDirectory( PresetsFolder );
var path = $"{PresetsFolder}/{name}.json";
FileSystem.Data.WriteJson( path, BuildPresetData() );
Log.Info( $"Saved preset: {path}" );
popup.Visible = false;
};
popup.Layout.Add( confirmBtn );
popup.OpenAtCursor();
}
private ForestPresetData BuildPresetData()
{
var data = new ForestPresetData
{
AreaBounds = AreaBounds,
Spacing = Spacing,
NoiseScale = NoiseScale,
NoiseThreshold = NoiseThreshold,
Seed = Seed,
MinScale = MinScale,
MaxScale = MaxScale,
AlignToNormal = AlignToNormal,
};
// Store prefab resource paths
foreach ( var cfg in Prefabs )
{
data.PrefabPaths.Add( cfg.Prefab?.ResourcePath ?? "" );
data.PrefabWeights.Add( cfg.Weight );
}
return data;
}
private void ApplyPreset( ForestPresetData data )
{
AreaBounds = data.AreaBounds;
Spacing = data.Spacing;
NoiseScale = data.NoiseScale;
NoiseThreshold = data.NoiseThreshold;
Seed = data.Seed;
MinScale = data.MinScale;
MaxScale = data.MaxScale;
AlignToNormal = data.AlignToNormal;
// Restore prefabs from resource paths
Prefabs = new List<ForestPrefabConfig>();
for ( int i = 0; i < data.PrefabPaths.Count; i++ )
{
var path = data.PrefabPaths[i];
var weight = i < data.PrefabWeights.Count ? data.PrefabWeights[i] : 1f;
var prefab = string.IsNullOrEmpty( path ) ? null : ResourceLibrary.Get<PrefabFile>( path );
Prefabs.Add( new ForestPrefabConfig { Prefab = prefab, Weight = weight } );
}
}
private void GenerateForest()
{
if ( Prefabs == null || Prefabs.Count == 0 ) return;
var scene = SceneEditorSession.Active.Scene;
if ( scene == null ) return;
ClearForest();
var container = scene.CreateObject();
container.Name = "Generated Forest";
float totalWeight = Prefabs.Sum( x => x.Weight );
if ( totalWeight <= 0 ) totalWeight = 1;
int spawnCount = 0;
using ( scene.Push() )
{
for ( float x = AreaBounds.Mins.x; x <= AreaBounds.Maxs.x; x += Spacing )
{
for ( float y = AreaBounds.Mins.y; y <= AreaBounds.Maxs.y; y += Spacing )
{
float noiseVal = Noise.Perlin( (x + Seed) * NoiseScale, (y + Seed) * NoiseScale );
if ( noiseVal < NoiseThreshold ) continue;
var rayStart = new Vector3( x + Game.Random.Float( -Spacing, Spacing ) * 0.4f, y + Game.Random.Float( -Spacing, Spacing ) * 0.4f, AreaBounds.Maxs.z );
var rayEnd = new Vector3( rayStart.x, rayStart.y, AreaBounds.Mins.z );
var trace = scene.Trace.Ray( rayStart, rayEnd ).HitTriggers().Run();
if ( trace.Hit )
{
if ( !trace.Tags.Contains( "forest" ) ) continue;
var config = GetRandomPrefab( totalWeight );
if ( config.Prefab == null ) continue;
var rotation = Rotation.FromYaw( Game.Random.Float( 0, 360 ) );
if ( AlignToNormal ) rotation = Rotation.LookAt( rotation.Forward, trace.Normal );
var go = SceneUtility.GetPrefabScene( config.Prefab ).Clone( new CloneConfig
{
Parent = container,
StartEnabled = true
} );
go.WorldPosition = trace.HitPosition;
go.WorldRotation = rotation;
go.WorldScale = Game.Random.Float( MinScale, MaxScale );
spawnCount++;
}
}
}
}
Log.Info( $"Generated {spawnCount} trees." );
}
private ForestPrefabConfig GetRandomPrefab( float totalWeight )
{
float roll = Game.Random.Float( 0, totalWeight );
float cumulative = 0;
foreach ( var cfg in Prefabs )
{
cumulative += cfg.Weight;
if ( roll <= cumulative ) return cfg;
}
return Prefabs[0];
}
private void ClearForest()
{
var scene = SceneEditorSession.Active.Scene;
var existing = scene?.GetAllObjects( false ).FirstOrDefault( x => x.Name == "Generated Forest" );
existing?.Destroy();
}
}
}