Spawners/DetailsSpawner.cs
using System;
using System.Collections.Generic;
using System.Linq;
using ApexWorld;
using Sandbox.Mask;
using Sandbox.Spawns;

namespace Sandbox.Spawners;

public class DetailsSpawner : BaseSpawner
{
	
	// ── Settings ────────────────────────────────────────────────────────────
	[Property, Group( "Spawn Rules" )]
	public bool ShowGizmos { get; set; } = true;
	// Spawner-level masks applied globally before any rule mask
	[Property, Group( "Spawner Masks" )]
	[Editor( "MaskListPropertyEditor" )]
	public List<MaskModifier> SpawnerMasks { get; set; } = new();

	// Individual spawn rules
	[Property, Group( "Spawn Rules" )]
	public List<SpawnRule> SpawnRules { get; set; } = new();

	// ── Spawn Root ──────────────────────────────────────────────────────────

	/// <summary>
	/// Hierarchy: DetailsSpawner -> SpawnRoot -> spawned objects.
	/// Clearing is simply destroying SpawnRoot's children.
	/// </summary>
	private GameObject SpawnRoot => GetOrCreateSpawnRoot();

	private GameObject GetOrCreateSpawnRoot()
	{
		foreach ( var child in GameObject.Children )
			if ( child.Name == "SpawnRoot" )
				return child;

		var root = Scene.CreateObject();
		root.Name   = "SpawnRoot";
		root.Parent = GameObject;
		return root;
	}

	// ── BaseSpawner overrides ───────────────────────────────────────────────

	public override void OnBeforeGenerate()
	{
		_isFinished = false;
		ClearSpawns();
		base.OnBeforeGenerate();
	}

	public override void Generate()
	{
		base.Generate();
		GenerateDetails();
	}

	// ── Public buttons ──────────────────────────────────────────────────────

	[Button]
	public void GenerateDetails()
	{
		ClearSpawns();

		var bounds   = GenerateSpawnerBounds();
		var terrains = GetTerrainsInBounds( bounds );

		if ( terrains == null || terrains.Count == 0 )
		{
			Log.Warning( $"{this}: no terrains in bounds" );
			_isFinished = true;
			return;
		}

		foreach ( var terrain in terrains )
			ProcessTerrain( terrain, bounds );

		_isFinished = true;
		Log.Info( $"{this}: generation done" );
	}

	[Button]
	public void ClearSpawns()
	{
		foreach ( var child in SpawnRoot.Children.ToList() )
			child.Destroy();
	}

	// ── Core ────────────────────────────────────────────────────────────────

	private void ProcessTerrain( Terrain terrain, BBox bounds )
	{
		if ( terrain?.Storage == null ) return;

		// Terrain-local top-left corner of the spawner bounds = WorldOffset for masks
		var terrainLocalMins = terrain.WorldTransform.PointToLocal( bounds.Mins );
		var worldOffset      = new Vector2( terrainLocalMins.x, terrainLocalMins.y );
		float worldSize      = Range;

		// Build spawner-level fitness mask (1.0 everywhere if no masks set)
		var spawnerMask = SpawnUtils.BuildCombinedMask( SpawnerMasks, terrain, this );//BuildMask( SpawnerMasks, terrain, worldOffset, worldSize );

		foreach ( var rule in SpawnRules )
		{
			if ( !rule.Enabled) continue;
			ProcessRule( rule, terrain, bounds, spawnerMask, worldOffset, worldSize );
		}
	}

private void ProcessRule(
    SpawnRule  rule,
    Terrain    terrain,
    BBox       bounds,
    MaskField  spawnerMask,
    Vector2    worldOffset,
    float      worldSize )
{
    var ruleMask = rule.GenerateMask( terrain, MaskResolution, worldOffset, worldSize );
    var fitness  = spawnerMask.Multiply( ruleMask );

    var rng       = new Random( HashCode.Combine( SpawnerName, rule.RuleName ) );
    var placed    = new List<Vector3>();

    // One batch list per model — supports mixed clutter models within one rule
    var clutterBatches = new Dictionary<Model, List<Transform>>();

    float step      = rule.LocationIncrement;
    float jitterAmt = step * (rule.Jitter / 100f);

    for ( float wy = bounds.Mins.y; wy <= bounds.Maxs.y; wy += step )
    {
        for ( float wx = bounds.Mins.x; wx <= bounds.Maxs.x; wx += step )
        {
            if ( wx < 0 || wx > terrain.Storage.TerrainSize ||
                 wy < 0 || wy > terrain.Storage.TerrainSize )
                continue;

            float jx  = ((float)rng.NextDouble() * 2f - 1f) * jitterAmt;
            float jy  = ((float)rng.NextDouble() * 2f - 1f) * jitterAmt;
            var   pos = new Vector3( wx + jx, wy + jy, 0f );

            if ( pos.x < bounds.Mins.x || pos.x > bounds.Maxs.x ||
                 pos.y < bounds.Mins.y || pos.y > bounds.Maxs.y )
                continue;

            float fit = SpawnUtils.SampleMaskAt( fitness, terrain, pos );
            if ( fit < rule.MinFitness ) continue;

            if ( (float)rng.NextDouble() * 100f > rule.SpawnProbabilityRate ) continue;

            if ( rule.SelfCollisionCheck && HasCollision( placed, pos, rule.BoundRadius ) )
                continue;

            var entry = rule.Definition.PickEntry( rng );
            if ( entry == null ) continue;

            float h       = SpawnUtils.GetTerrainHeightAt( terrain, pos );
            float yOffset = MathX.Lerp( rule.Definition.MinYOffset, rule.Definition.MaxYOffset, (float)rng.NextDouble() );
            var spawnPos  = new Vector3( pos.x, pos.y, h + yOffset );
            var rot       = SpawnUtils.GetSpawnRotation( rule.Definition, terrain, spawnPos, rng );

            if ( entry.ObjectType == SpawnObjectType.Clutter )
            {
                if ( entry.ClutterModel == null ) continue;

                float clutterScale = SpawnUtils.GetClutterScale( rule.Definition, fit, rng );

                if ( !clutterBatches.TryGetValue( entry.ClutterModel, out var batch ) )
                {
                    batch = new List<Transform>();
                    clutterBatches[entry.ClutterModel] = batch;
                }

                batch.Add( new Transform( spawnPos, rot, clutterScale ) );
            }
            else
            {
                if ( entry.Prefab == null ) continue;

                var goScale      = SpawnUtils.GetSpawnScale( rule.Definition, fit, rng );
                var go           = entry.Prefab.Clone();
                go.Parent        = SpawnRoot;
                go.WorldPosition = spawnPos;
                go.WorldRotation = rot;
                go.WorldScale    = goScale;
                go.Enabled       = true;
            }

            placed.Add( pos );
        }
    }

    foreach ( var (model, transforms) in clutterBatches )
    {
        if ( transforms.Count == 0 ) continue;

        var clutterObject  = Scene.CreateObject();
        clutterObject.Name = $"Clutter_{rule.RuleName}_{model.ResourceName}";
        clutterObject.Parent = SpawnRoot;

        var clutter   = clutterObject.Components.Create<ApexClutterComponent>();
        clutter.Model = model;
        clutter.BuildFromTransforms( transforms );
    }
}

	// ── Helpers ─────────────────────────────────────────────────────────────

	private MaskField BuildMask( List<MaskModifier> masks, Terrain terrain, Vector2 worldOffset, float worldSize )
	{
		var field = new MaskField( MaskResolution, worldSize ) { WorldOffset = worldOffset };
		field.Fill( 1f );

		foreach ( var mask in masks )
		{
			mask.Terrain = terrain;
			var temp = new MaskField( MaskResolution, worldSize ) { WorldOffset = worldOffset };
			temp.Fill( 1f );
			mask.Apply( temp );
			field = field.Multiply( temp );
		}

		return field;
	}

	private static bool HasCollision( List<Vector3> placed, Vector3 pos, float radius )
	{
		float minDist = radius * 2f;
		foreach ( var p in placed )
		{
			float dx = p.x - pos.x;
			float dy = p.y - pos.y;
			if ( dx * dx + dy * dy < minDist * minDist )
				return true;
		}
		return false;
	}

	private const int GizmoDensityStep = 12;
	private const float GizmoSphereSize = 13f;
	private const float GizmoMinValue = 0.01f;
	private MaskField _cachedmaskField;
	private BBox bounds;
	private Terrain cachedTerrain;

	[Button]
	public void Snapshot()
	{
		bounds = GenerateSpawnerBounds();

		var terrains = GetTerrainsInBounds( bounds );
		if ( terrains == null || terrains.Count == 0 )
			return;
		
		

		cachedTerrain = terrains[0];
		
		
		// Terrain-local top-left corner of the spawner bounds = WorldOffset for masks
		var terrainLocalMins = cachedTerrain.WorldTransform.PointToLocal( bounds.Mins );
		var worldOffset      = new Vector2( terrainLocalMins.x, terrainLocalMins.y );
		float worldSize      = Range;

		// Build spawner-level fitness mask (1.0 everywhere if no masks set)
		_cachedmaskField = BuildMask( SpawnerMasks, cachedTerrain, worldOffset, worldSize );
		
		Log.Info( $"{this}: snapshot taken for gizmo visualization" );

	
	}
	protected override void DrawGizmos()
	{
		if (ShowGizmos == false || !Game.IsEditor || cachedTerrain == null || _cachedmaskField == null )
			return;

		if ( SpawnerMasks == null || SpawnerMasks.Count == 0 )
			return;

		int resolution = MaskResolution;
		var storage = cachedTerrain.Storage;
		

		// Skip samples for performance
		for ( int x = 0; x < resolution; x += GizmoDensityStep )
		{
			for ( int y = 0; y < resolution; y += GizmoDensityStep )
			{
				float value = _cachedmaskField.Get(
					resolution - 1 - x,
					y
				);

				// Ignore empty mask values
				if ( value <= GizmoMinValue )
					continue;

				float u = ((resolution - 1 - x) + 0.5f) / resolution;
				float v = (y + 0.5f) / resolution;

				float worldX = _cachedmaskField.WorldOffset.x + (u * _cachedmaskField.WorldSize);
				float worldY = _cachedmaskField.WorldOffset.y + (v * _cachedmaskField.WorldSize);

				// Skip if outside terrain bounds
				if ( worldX < 0 || worldX > storage.TerrainSize ||
					 worldY < 0 || worldY > storage.TerrainSize )
					continue;

				var worldPos = new Vector3(
					worldX - WorldPosition.x,
					worldY - WorldPosition.y,
					0f
				);

				var heightSamplePos = new Vector3(
					worldX,
					worldY,
					0f
				);

				worldPos.z = SpawnUtils.GetTerrainHeightAt(
					cachedTerrain,
					heightSamplePos
				);
				
				worldPos.z += 100;
				// Black -> White based on mask value
				Gizmo.Draw.Color = Color.Lerp(
					Color.Black,
					Color.White,
					value
				);
			
				Gizmo.Draw.SolidSphere(
					worldPos,
					GizmoSphereSize
				);
			}
		}
	}
}