UI/LevelPreview.cs
using System;
using System.Collections.Generic;

namespace BlockParty;

/// <summary>Growth direction of a preview spike strip (None = a plain solid wall band).</summary>
public enum PreviewSpike { None, Up, Down, Left, Right }

/// <summary>
/// One drawable rectangle in a <see cref="LevelPreviewModel"/>, in LOGICAL arena space (0..240,
/// origin bottom-left, +Y up — the same space as <see cref="RectF"/> / the gameplay geometry). The
/// <see cref="LevelNodePreview"/> component converts these to CSS percentages of the 240x240 arena so
/// the schematic scales to any node size.
/// </summary>
public readonly struct PreviewRect
{
	public readonly float X, Y, W, H;
	public readonly Color Color;
	/// <summary><see cref="PreviewSpike.None"/> is a solid wall band, filled flat with <see cref="Color"/>.
	/// Otherwise this is a teeth OVERLAY drawn on top of a band rect: it tiles the band-removed teeth art
	/// along its edge with no fill, so the band beneath shows through and inter-teeth gaps stay
	/// transparent. Teeth draw in <see cref="Tint"/> — white for every normal hazard; GLASS faces
	/// shade theirs toward the glass colour (blocks slide over those spikes).</summary>
	public readonly PreviewSpike Spike;

	/// <summary>Teeth tint for spike overlays (ignored for plain bands). White by default.</summary>
	public readonly Color Tint;

	public PreviewRect( float x, float y, float w, float h, Color color, PreviewSpike spike = PreviewSpike.None, Color? tint = null )
	{
		X = x; Y = y; W = w; H = h; Color = color; Spike = spike; Tint = tint ?? Color.White;
	}
}

/// <summary>
/// A schematic of a level's arena, used to draw the level-select node so each node visually reads as
/// the level it launches: the play-field / out-of-bounds / wall colours plus the obstacle layout with
/// its combined walls, elbow patches and starting spikes. Built by <see cref="LevelPreview.Build"/>
/// and rendered by <see cref="LevelNodePreview"/>.
/// </summary>
public sealed class LevelPreviewModel
{
	/// <summary>Play-field (checkerboard) colour — the node's base fill.</summary>
	public Color Background;
	/// <summary>Out-of-bounds colour — the interior fill of obstacles.</summary>
	public Color OutOfBounds;
	/// <summary>Direct wall colour for solid bands and elbow patches.</summary>
	public Color Wall;

	/// <summary>Decorative drifting-block colours (front → back), drawn as a few static squares
	/// behind the walls to hint at the level's moving backdrop.</summary>
	public IReadOnlyList<Color> BgBlockColors;
	public float BgBlockScale;
	public float BgBlockDensity;
	public float BgBlockOpacity;
	public float BgDriftSpeed;
	public BackgroundDriftBias BgDriftBias;

	/// <summary>Wall + obstacle primitives, in paint order (arena edge bands, obstacle fills, obstacle
	/// face bands, elbow patches, then every spike strip, then the line-of-sight cover).</summary>
	public List<PreviewRect> Rects = new();
}

/// <summary>
/// Builds a <see cref="LevelPreviewModel"/> from a <see cref="LevelDef"/>. Mirrors the arena/obstacle
/// wall construction in <see cref="GameStage"/> (flush cutting so abutting obstacles read as one
/// combined wall, elbow patches at the seams, and per-side starting spikes) but emits scaled
/// primitives for the UI instead of world sprites. It is a schematic — wall bands are exaggerated for
/// legibility at node size, and arena edges rely on the obstacle fills painting over them rather than
/// being flush-cut (visually identical for the split case).
/// </summary>
public static class LevelPreview
{
	// Defaults matching the classic look (see StageBase / GameManager.ClearColor / the baked wall art).
	private static readonly Color DefaultBackground = new Color( 0f, 189f / 255f, 196f / 255f );   // TEAL checkerboard
	private static readonly Color DefaultWall = LevelCosmeticsRandomizer.DefaultWallColor;
	private static readonly Color DefaultOutOfBounds = new Color( 0f, 32f / 255f, 40f / 255f );     // clear colour
	private static readonly Color CoinYellow = new( 221 / 255f, 221 / 255f, 153 / 255f );
	private static readonly Color[] DefaultBgBlocks =
	{
		new Color( 0f, 150f / 255f, 157f / 255f ),
		new Color( 0f, 135f / 255f, 142f / 255f ),
		new Color( 0f, 120f / 255f, 127f / 255f ),
	};

	// Exaggerated wall thickness so 2px arena walls stay visible when a 240px arena shrinks to a
	// ~108px node. Spikes extend the same amount again inward (the baked art is ~half solid, half
	// teeth), so a spiked band is twice as thick as a plain one.
	private const float WALL_VIS = 8f;
	private const float SPIKE_EXTRA = 8f;

	private struct Span { public float Min; public float Max; }

	public static LevelPreviewModel Build( LevelDef level )
	{
		Color wall = level?.WallColor ?? DefaultWall;
		var m = new LevelPreviewModel
		{
			Background = level?.CheckerboardColor ?? DefaultBackground,
			OutOfBounds = level?.OutOfBoundsColor ?? DefaultOutOfBounds,
			Wall = wall,
			BgBlockColors = ResolveBgBlocks( level ),
			BgBlockScale = Math.Clamp( level?.BackgroundBlockScale ?? 1f, 0.25f, 1.5f ),
			BgBlockDensity = Math.Clamp( level?.BackgroundBlockDensity ?? 1f, 0.25f, 2f ),
			BgBlockOpacity = Math.Clamp( level?.BackgroundBlockOpacity ?? 1f, 0f, 1f ),
			BgDriftSpeed = Math.Clamp( level?.BackgroundDriftSpeed ?? 1f, 0f, 3f ),
			BgDriftBias = level?.BackgroundDriftBias ?? BackgroundDriftBias.None,
		};

		float W = Arena.WIDTH, H = Arena.HEIGHT, t = WALL_VIS, s = SPIKE_EXTRA;

		// Split out the FENCES (solid only to blocks) and GLASS (solid only to players): neither
		// merges with arena walls or normal obstacles nor cuts their faces, so every wall/face
		// mirror below sees only the real walls. Each is drawn separately with its own see-through
		// look (AddFence / AddGlass).
		var obstacles = new List<RectF>();
		var fenceRects = new List<RectF>();
		var glassRects = new List<RectF>();
		foreach ( var r in level?.Obstacles ?? System.Array.Empty<RectF>() )
			(ContainsRect( level?.Fences, r ) ? fenceRects : ContainsRect( level?.Glass, r ) ? glassRects : obstacles).Add( r );

		// Every spike strip is collected here and painted after ALL the solid primitives (see below), so
		// nothing drawn later can bury one — mirroring the game, where all teeth sit on
		// WallFace.ORDER_TEETH, above every band and elbow patch on ORDER_BAND.
		var spikeOverlays = new List<PreviewRect>();

		// Arena edges. The BANDS are not flush-cut around obstacles: an obstacle sitting flush against an
		// edge is drawn (fill first) over the band, which reproduces a split spiked wall (e.g. a C-shape's
		// two floor channels) since the fill masks the covered middle. The TEETH must be cut instead —
		// they paint last, so a fill can no longer mask them.
		AddArenaEdge( m, level, obstacles, spikeOverlays, Direction.Down, Contains( level?.SpikedWalls, Direction.Down ), W, H, t, s );
		AddArenaEdge( m, level, obstacles, spikeOverlays, Direction.Up, Contains( level?.SpikedWalls, Direction.Up ), W, H, t, s );
		AddArenaEdge( m, level, obstacles, spikeOverlays, Direction.Left, Contains( level?.SpikedWalls, Direction.Left ), W, H, t, s );
		AddArenaEdge( m, level, obstacles, spikeOverlays, Direction.Right, Contains( level?.SpikedWalls, Direction.Right ), W, H, t, s );

		// Obstacle fills first, so the face bands (added next) paint on top of every fill.
		foreach ( var r in obstacles )
			m.Rects.Add( ObstacleFillRect( level, r, m.OutOfBounds ) );

		// Obstacle face bands (+ starting spikes), mirroring GameStage.
		var faces = new List<FaceSeg>();
		foreach ( var r in obstacles )
		{
			foreach ( var normal in new[] { Direction.Left, Direction.Right, Direction.Down, Direction.Up } )
			{
				if ( FlushWithArenaWall( r, normal ) )
					continue;
				bool spiked = ObstacleSideSpiked( level, r, normal );
				AddObstacleSide( m, faces, spikeOverlays, obstacles, r, normal, spiked, t, s );
			}
		}

		// Elbow patches in one pass after every face band exists, mirroring
		// GameStage.AddObstacleElbowPatches (a corner notch can sit in a rect that is not either
		// meeting face's owner — e.g. the staircase corner three flush rects make).
		AddElbowPatches( m, obstacles, faces, t );

		// Fences: an open frame + vertical bars in the level's fence colour (border = the colour,
		// bars = its brighter derivative) — the playfield stays visible through them, so they read
		// as "not a wall" at node size too. Flush fence-fence seams drop their frame segment
		// (fences only combine with each other), mirroring GameStage.BuildFenceFrameSide.
		Color fenceBorder = level?.FenceColor ?? LevelCosmeticsRandomizer.DefaultFenceColor;
		Color fenceBar = LevelCosmeticsRandomizer.FenceBarColor( fenceBorder );
		// Frame alpha matches the game's FENCE_BORDER_ALPHA (the bars get FENCE_BAR_ALPHA in AddFence).
		fenceBorder = fenceBorder.WithAlpha( 0.85f );
		var fenceFrames = new List<FaceSeg>();
		foreach ( var r in fenceRects )
			AddFence( m, fenceRects, r, t, fenceBorder, fenceBar, fenceFrames );
		AddFrameElbowPatches( m, fenceRects, fenceFrames, t / 2f, fenceBorder );

		// Glass: translucent pane + solid border in the level's glass colour; authored spikes emit
		// TINTED teeth overlays (player-only hazard — blocks slide over them), collected with the
		// other spike strips so nothing painted later can bury them.
		Color glassColor = level?.GlassColor ?? LevelCosmeticsRandomizer.DefaultGlassColor;
		var glassBorders = new List<FaceSeg>();
		foreach ( var r in glassRects )
			AddGlass( m, spikeOverlays, level, glassRects, r, t, s, glassColor, glassBorders );
		AddFrameElbowPatches( m, glassRects, glassBorders, t / 2f, glassColor );

		// Coin pickups: one yellow dot per coin, slightly oversized (the real 4x6 art shrinks to ~2px
		// at node size). Painted before the spikes/vision cover so a coin sitting in a vision shadow
		// is hidden here like it is in-game.
		foreach ( var c in level?.Coins ?? System.Array.Empty<Vector2>() )
			m.Rects.Add( new PreviewRect( c.x - 3f, c.y - 4f, 6f, 8f, CoinYellow ) );

		// Paint every spike strip now, above all fills, bands and elbow patches. Thin obstacles can be
		// narrower than the exaggerated preview band, so an opposite face emitted later must not cover
		// their spikes — nor may an obstacle standing off a spiked arena wall erase that wall's teeth
		// (the preview's spikes reach WALL_VIS + SPIKE_EXTRA inward, far further than the real ones).
		m.Rects.AddRange( spikeOverlays );

		// Line-of-sight cover: the static umbra behind each level-built vision blocker (explicit rects +
		// obstacles when the level opts in), cast from the player spawn and filled in the wall colour.
		// Painted last so it sits above the walls like the in-game cover. Excludes dynamic BlockShade.
		AddVisionCover( m, level );

		return m;
	}

	private static IReadOnlyList<Color> ResolveBgBlocks( LevelDef level )
	{
		Color[] nominal =
			level?.BackgroundBlockColors is { Count: 3 } c ? new[] { c[0], c[1], c[2] }
			: level?.BackgroundBlockColor is Color b ? new[] { Shade( b, 1f ), Shade( b, 0.9f ), Shade( b, 0.8f ) }
			: DefaultBgBlocks;

		// Level-provided block colours and the UI both use sRGB, so authored overrides can be painted
		// directly. The built-in fallback predates that convention and remains a linear render value.
		if ( level?.BackgroundBlockColors is { Count: 3 } || level?.BackgroundBlockColor is not null ) return nominal;

		var outc = new Color[nominal.Length];
		for ( int i = 0; i < nominal.Length; i++ )
			outc[i] = LinearToGamma( nominal[i] );
		return outc;
	}

	private static Color LinearToGamma( Color c ) => new Color( L2G( c.r ), L2G( c.g ), L2G( c.b ), c.a );

	// Standard sRGB linear->gamma transfer (matches what the display applies to the shader's linear output).
	private static float L2G( float v ) =>
		v <= 0.0031308f ? v * 12.92f : 1.055f * System.MathF.Pow( v, 1f / 2.4f ) - 0.055f;


	private static Color Shade( Color c, float f ) => new Color( c.r * f, c.g * f, c.b * f, c.a );

	private static void AddArenaEdge( LevelPreviewModel m, LevelDef level, IReadOnlyList<RectF> obstacles, List<PreviewRect> spikeOverlays, Direction side, bool spiked, float W, float H, float t, float s )
	{
		// Mirror the in-game WallFace: the wall BAND is always the same thickness-t rect (drawn flat in
		// the wall colour), and a spiked edge merely ADDS a teeth OVERLAY that extends SPIKE_EXTRA inward.
		// The overlay carries only the band-removed teeth art (drawn white), so the coloured band shows
		// through beneath it and the gaps between teeth stay transparent. Spikes grow inward.
		bool horizontal = side == Direction.Down || side == Direction.Up;
		m.Rects.Add( side switch
		{
			Direction.Down => new PreviewRect( 0f, 0f, W, t, m.Wall ),
			Direction.Up => new PreviewRect( 0f, H - t, W, t, m.Wall ),
			Direction.Left => new PreviewRect( 0f, 0f, t, H, m.Wall ),
			_ => new PreviewRect( W - t, 0f, t, H, m.Wall ),   // Right
		} );
		if ( !spiked ) return;

		// One teeth strip per surviving stretch of the side, cut by the obstacles flush against it —
		// exactly how GameStage.BuildArenaSide splits the side into independently-spiking faces. An
		// obstacle merely NEAR the wall does NOT cut: the game leaves that face whole and its teeth
		// overlay the obstacle's body, so the preview's (longer) teeth do the same.
		var cuts = FlushArenaSpans( level, obstacles, side );
		// An uncut side keeps the classic single full-span strip (the game's baked art spans the corners
		// too). A SPLIT side hands its corner squares to the perpendicular bands, like GameStage's
		// WALL_SIZE inset: teeth tile by stretching the tooth art across the strip, so a corner sliver
		// would otherwise smear one giant tooth over the corner.
		float min = cuts.Count == 0 ? 0f : t;
		float max = ( horizontal ? W : H ) - min;
		foreach ( var seg in SubtractSpans( min, max, cuts ) )
			spikeOverlays.Add( ArenaTeethRect( m.Wall, side, seg.Min, seg.Max, W, H, t, s ) );
	}

	private static PreviewRect ArenaTeethRect( Color wall, Direction side, float min, float max, float W, float H, float t, float s ) => side switch
	{
		Direction.Down => new PreviewRect( min, 0f, max - min, t + s, wall, PreviewSpike.Up ),
		Direction.Up => new PreviewRect( min, H - (t + s), max - min, t + s, wall, PreviewSpike.Down ),
		Direction.Left => new PreviewRect( 0f, min, t + s, max - min, wall, PreviewSpike.Right ),
		_ => new PreviewRect( W - (t + s), min, t + s, max - min, wall, PreviewSpike.Left ),   // Right
	};

	// The along-spans of the obstacles sitting flush against a boundary side: the stretches where that
	// side reads as covered wall rather than spikes (the preview analogue of
	// GameStage.FlushObstacleSpans). Vision blockers are excluded there too — they keep the arena band
	// (and their fill, see ObstacleFillRect) so they read as separate obstacles.
	private static List<Span> FlushArenaSpans( LevelDef level, IReadOnlyList<RectF> obstacles, Direction side )
	{
		bool horizontal = side == Direction.Down || side == Direction.Up;
		var spans = new List<Span>();
		foreach ( var r in obstacles )
		{
			if ( ContainsRect( level?.VisionBlockers, r ) || !FlushWithArenaWall( r, side ) )
				continue;
			spans.Add( horizontal
				? new Span { Min = r.Left, Max = r.Right }
				: new Span { Min = r.Bottom, Max = r.Top } );
		}
		return spans;
	}

	// An ordinary obstacle's fill runs out to the arena edge on a flush side: in game that side is cut,
	// and here the fill painting over the (uncut) edge rect is what reproduces the split look. A VISION
	// BLOCKER instead stands ON an intact wall (GameStage paints the band back through its footprint),
	// so its fill is clamped INWARD to the band — the exaggerated WALL_VIS one, not the real 2px, or
	// the fill would swallow three quarters of the edge and the node would still read as a hole.
	private static PreviewRect ObstacleFillRect( LevelDef level, RectF r, Color color )
	{
		if ( ContainsRect( level?.VisionBlockers, r ) )
		{
			float l = FlushWithArenaWall( r, Direction.Left ) ? WALL_VIS : r.Left;
			float b = FlushWithArenaWall( r, Direction.Down ) ? WALL_VIS : r.Bottom;
			float rt = FlushWithArenaWall( r, Direction.Right ) ? Arena.WIDTH - WALL_VIS : r.Right;
			float tp = FlushWithArenaWall( r, Direction.Up ) ? Arena.HEIGHT - WALL_VIS : r.Top;
			// A blocker thinner than the exaggerated band would invert; collapse instead of flipping.
			return new PreviewRect( l, b, Math.Max( rt - l, 0f ), Math.Max( tp - b, 0f ), color );
		}

		float left = FlushWithArenaWall( r, Direction.Left ) ? 0f : r.Left;
		float bottom = FlushWithArenaWall( r, Direction.Down ) ? 0f : r.Bottom;
		float right = FlushWithArenaWall( r, Direction.Right ) ? Arena.WIDTH : r.Right;
		float top = FlushWithArenaWall( r, Direction.Up ) ? Arena.HEIGHT : r.Top;
		return new PreviewRect( left, bottom, right - left, top - bottom, color );
	}

	private static bool ContainsRect( IReadOnlyList<RectF> rects, RectF rect )
	{
		if ( rects is null ) return false;
		foreach ( var candidate in rects )
			if ( candidate.Left == rect.Left && candidate.Bottom == rect.Bottom
				&& candidate.Right == rect.Right && candidate.Top == rect.Top )
				return true;
		return false;
	}

	/// <summary>One surviving obstacle face segment, recorded so the elbow-patch pass can probe band
	/// coverage after ALL bands exist (the preview analogue of GameStage's WallFace list).</summary>
	private struct FaceSeg { public Direction Normal; public float Edge, Min, Max; }

	private static void AddObstacleSide( LevelPreviewModel m, List<FaceSeg> faces, List<PreviewRect> spikeOverlays, IReadOnlyList<RectF> obstacles, RectF r, Direction normal, bool spiked, float t, float s )
	{
		bool horizontal = normal == Direction.Up || normal == Direction.Down;
		float alongStart = horizontal ? r.Left : r.Bottom;
		float alongEnd = horizontal ? r.Right : r.Top;
		float edge = normal switch
		{
			Direction.Left => r.Left,
			Direction.Right => r.Right,
			Direction.Down => r.Bottom,
			_ => r.Top,
		};

		// The grey band sits INSIDE the body; spikes (if any) grow outward from the face.
		foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushNeighbourSpans( obstacles, r, normal ) ) )
		{
			AddFaceBand( m, spikeOverlays, r, normal, seg.Min, seg.Max, spiked, m.Wall, t, s );
			faces.Add( new FaceSeg { Normal = normal, Edge = edge, Min = seg.Min, Max = seg.Max } );
		}
	}

	// Mirror the in-game WallFace: the wall BAND is always the same thickness-t rect (drawn flat in the
	// wall colour), and a spiked face merely ADDS a teeth OVERLAY extending SPIKE_EXTRA outward. The
	// overlay carries only the band-removed teeth art (drawn white), so the coloured band shows through
	// beneath it and the gaps between teeth stay transparent.
	private static void AddFaceBand( LevelPreviewModel m, List<PreviewRect> spikeOverlays, RectF r, Direction normal, float alongMin, float alongMax, bool spiked, Color wall, float t, float s )
	{
		float len = alongMax - alongMin;
		PreviewRect band, teeth;
		switch ( normal )
		{
			case Direction.Left:  // body to the right; spikes point left
				band = new PreviewRect( r.Left, alongMin, t, len, wall );
				teeth = new PreviewRect( r.Left - s, alongMin, t + s, len, wall, PreviewSpike.Left );
				break;
			case Direction.Right: // body to the left; spikes point right
				band = new PreviewRect( r.Right - t, alongMin, t, len, wall );
				teeth = new PreviewRect( r.Right - t, alongMin, t + s, len, wall, PreviewSpike.Right );
				break;
			case Direction.Down:  // body above; spikes point down
				band = new PreviewRect( alongMin, r.Bottom, len, t, wall );
				teeth = new PreviewRect( alongMin, r.Bottom - s, len, t + s, wall, PreviewSpike.Down );
				break;
			default:              // Up: body below; spikes point up
				band = new PreviewRect( alongMin, r.Top - t, len, t, wall );
				teeth = new PreviewRect( alongMin, r.Top - t, len, t + s, wall, PreviewSpike.Up );
				break;
		}
		m.Rects.Add( band );
		if ( spiked ) spikeOverlays.Add( teeth );
	}

	// Elbow patches (WALL_VIS squares), mirroring GameStage.AddObstacleElbowPatches: at each face
	// end, if the band-strip square just past it lies inside an obstacle body and no face's band
	// covers it, the fill would poke through the corner notch there — plug it. Probing coverage
	// (rather than patching only cut-created segment ends) also catches the staircase corner three
	// flush rects make, where both meeting faces run their full sides and the notch belongs to a
	// third rect with no faces of its own at that corner.
	private static void AddElbowPatches( LevelPreviewModel m, IReadOnlyList<RectF> obstacles, List<FaceSeg> faces, float t )
	{
		var done = new HashSet<Vector2>();   // the two faces meeting at an L both probe their shared notch
		foreach ( var f in faces )
		{
			bool horizontal = f.Normal == Direction.Up || f.Normal == Direction.Down;
			// The band's centre line sits half a band inside the body.
			float perpInside = f.Normal == Direction.Left || f.Normal == Direction.Down
				? f.Edge + t / 2f
				: f.Edge - t / 2f;
			TryElbowPatch( m, obstacles, faces, horizontal, f.Min - t / 2f, perpInside, t, done );
			TryElbowPatch( m, obstacles, faces, horizontal, f.Max + t / 2f, perpInside, t, done );
		}
	}

	private static void TryElbowPatch( LevelPreviewModel m, IReadOnlyList<RectF> obstacles, List<FaceSeg> faces, bool horizontal, float alongCenter, float perpCenter, float t, HashSet<Vector2> done )
	{
		var c = horizontal ? new Vector2( alongCenter, perpCenter ) : new Vector2( perpCenter, alongCenter );
		if ( !InsideAnyObstacle( obstacles, c ) || CoveredByFaceBand( faces, c, t ) )
			return;
		if ( done.Add( c ) )
			m.Rects.Add( new PreviewRect( c.x - t / 2f, c.y - t / 2f, t, t, m.Wall ) );
	}

	private static bool InsideAnyObstacle( IReadOnlyList<RectF> obstacles, Vector2 pos )
	{
		foreach ( var r in obstacles )
			if ( pos.x > r.Left && pos.x < r.Right && pos.y > r.Bottom && pos.y < r.Top )
				return true;
		return false;
	}

	// Does any face's grey band strip (the t-deep solid part just inside its owner's edge) cover
	// this point? (Spikes extend outward and never matter here.)
	private static bool CoveredByFaceBand( List<FaceSeg> faces, Vector2 pos, float t )
	{
		foreach ( var f in faces )
		{
			if ( f.Normal == Direction.Up || f.Normal == Direction.Down )
			{
				float inner = f.Normal == Direction.Up ? f.Edge - t : f.Edge;
				if ( pos.x >= f.Min && pos.x <= f.Max && pos.y >= inner && pos.y <= inner + t )
					return true;
			}
			else
			{
				float inner = f.Normal == Direction.Right ? f.Edge - t : f.Edge;
				if ( pos.y >= f.Min && pos.y <= f.Max && pos.x >= inner && pos.x <= inner + t )
					return true;
			}
		}
		return false;
	}

	// ---- shared preview dressing -------------------------------------------------------------------
	// The decorative layout AROUND the wall schematic (drifting-block squares + the blocks on their
	// spawn slots) generated in ONE place, consumed by both renderers: LevelNodePreview draws it as
	// animated CSS panels, WorkshopThumbnail rasterises the identical arrangement into the baked
	// Steam-page PNG. Sharing the generation (including its PRNG draw ORDER) is what keeps the two
	// from drifting apart — a consumer that doesn't need a drawn value must still let it be drawn.

	/// <summary>One decorative drifting-block square, in logical arena space (origin bottom-left).</summary>
	public readonly struct PreviewBgBlock
	{
		public readonly Vector2 Pos;
		public readonly float Size;
		public readonly int Depth;
		public readonly Color Color;
		/// <summary>Drift velocity (logical units/second). Animated consumers move the square; static
		/// ones ignore it — it is drawn from the PRNG either way, keeping the layouts identical.</summary>
		public readonly Vector2 Vel;

		public PreviewBgBlock( Vector2 pos, float size, int depth, Color color, Vector2 vel )
		{
			Pos = pos; Size = size; Depth = depth; Color = color; Vel = vel;
		}
	}

	/// <summary>Deterministic drifting-block layout for a level's preview: count, sizes, depths,
	/// positions, colours and drift velocities, seeded by the level id (stable across runs), sorted
	/// deepest first so lighter, closer squares paint on top.</summary>
	public static List<PreviewBgBlock> BuildBgBlocks( LevelDef level, LevelPreviewModel model )
	{
		var result = new List<PreviewBgBlock>();
		var cols = model.BgBlockColors;
		if ( cols is null || cols.Count == 0 )
			return result;

		// Deterministic PRNG seeded by the level Id (stable across runs).
		uint state = ((uint)(level?.Id?.GetHashCode() ?? 0)) ^ 0x9E3779B9u;
		float Next() // xorshift -> [0,1)
		{
			state ^= state << 13; state ^= state >> 17; state ^= state << 5;
			return (state & 0xFFFFFF) / (float)0x1000000;
		}

		// Guarantee at least one block of EACH depth (the 3 palette shades) so every preview shows the
		// full set, then a few random extras on top.
		int baseCount = cols.Count + 2 + (int)(Next() * 4f);   // cols.Count + (2..5)
		int count = Math.Max( cols.Count, (int)MathF.Round( baseCount * model.BgBlockDensity ) );
		for ( int i = 0; i < count; i++ )
		{
			// Base size (20..52), with an occasional larger block mixed in (up to ~86).
			float size = (20f + Next() * 32f) * model.BgBlockScale;
			if ( Next() < 0.4f )
				size += Next() * 34f * model.BgBlockScale;

			// The first cols.Count blocks each take a distinct depth so all shades appear; rest random.
			int depth = i < cols.Count ? i : (int)(Next() * cols.Count);
			if ( !TryBgBlockPosition( size, depth, result, Next, out var pos ) )
				continue;

			// Simple cardinal drift: move purely along ONE axis, random direction + gentle speed.
			float speed = (14f + Next() * 20f) * model.BgDriftSpeed;
			Direction direction = BgDriftDirection( model.BgDriftBias, Next );
			Vector2 vel = Globals.GetVectorForDirection( direction ) * speed;

			Color color = cols[depth % cols.Count];
			result.Add( new PreviewBgBlock( pos, size, depth,
				color.WithAlpha( color.a * model.BgBlockOpacity ), vel ) );
		}

		result.Sort( ( a, b ) => b.Depth.CompareTo( a.Depth ) );
		return result;
	}

	private static bool TryBgBlockPosition( float size, int depth, IReadOnlyList<PreviewBgBlock> blocks, Func<float> next, out Vector2 position )
	{
		const float clearance = 3f;
		for ( int attempt = 0; attempt < 96; attempt++ )
		{
			var candidate = new Vector2( next() * (Arena.WIDTH - size), next() * (Arena.HEIGHT - size) );
			bool clear = true;
			foreach ( var block in blocks )
			{
				if ( block.Depth != depth ) continue;
				if ( candidate.x < block.Pos.x + block.Size + clearance && candidate.x + size + clearance > block.Pos.x
					&& candidate.y < block.Pos.y + block.Size + clearance && candidate.y + size + clearance > block.Pos.y )
				{
					clear = false;
					break;
				}
			}
			if ( clear ) { position = candidate; return true; }
		}
		position = default;
		return false;
	}

	private static Direction BgDriftDirection( BackgroundDriftBias bias, Func<float> next )
	{
		Direction RandomDirection() => (Direction)(1 + (int)(next() * 4f));
		if ( bias == BackgroundDriftBias.None || next() >= 0.8f ) return RandomDirection();
		if ( bias == BackgroundDriftBias.Horizontal ) return next() < 0.5f ? Direction.Left : Direction.Right;
		if ( bias == BackgroundDriftBias.Vertical ) return next() < 0.5f ? Direction.Down : Direction.Up;
		return bias switch
		{
			BackgroundDriftBias.Left => Direction.Left,
			BackgroundDriftBias.Right => Direction.Right,
			BackgroundDriftBias.Down => Direction.Down,
			BackgroundDriftBias.Up => Direction.Up,
			_ => RandomDirection(),
		};
	}

	/// <summary>The blocks a preview shows on their spawn slots. Exact levels show their authored set;
	/// pool levels show a deterministic sample of the pool rather than consuming gameplay RNG just to
	/// draw a thumbnail. Pinned blocks always draw at their own slot; the random set fills the
	/// remaining free slots in listed order.</summary>
	public static List<(BlockType Type, Vector2 Pos)> BuildSpawnBlocks( LevelDef level )
	{
		var result = new List<(BlockType, Vector2)>();
		if ( level is null )
			return result;

		var spawns = level.SpawnPositions;
		var pinnedSlots = new HashSet<int>();
		if ( level.SpawnPins is not null && spawns is { Count: > 0 } )
		{
			for ( int i = 0; i < level.SpawnPins.Count && i < spawns.Count; i++ )
			{
				if ( level.SpawnPins[i] is not PinnedBlock pin ) continue;
				pinnedSlots.Add( i );
				result.Add( (pin.Type, spawns[i]) );
			}
		}

		int randomCount = level.Blocks?.Count ?? Math.Clamp( level.BlockCount, 0, level.Pool?.Count ?? 0 );
		int slot = 0;
		for ( int i = 0; i < randomCount; i++ )
		{
			BlockType type;
			if ( level.Blocks is { Count: > 0 } exact ) type = exact[i % exact.Count];
			else if ( level.Pool is { Count: > 0 } pool ) type = pool[i % pool.Count];
			else continue;

			Vector2 position;
			if ( spawns is { Count: > 0 } )
			{
				// Advance past pinned slots so random blocks only occupy free ones.
				while ( slot < spawns.Count && pinnedSlots.Contains( slot ) ) slot++;
				position = slot < spawns.Count ? spawns[slot++] : FallbackBlockPosition( i, randomCount );
			}
			else
			{
				position = FallbackBlockPosition( i, randomCount );
			}
			result.Add( (type, position) );
		}

		return result;
	}

	private static Vector2 FallbackBlockPosition( int index, int count )
	{
		int columns = Math.Min( count, 3 );
		int rows = (count + columns - 1) / columns;
		int column = index % columns;
		int row = index / columns;
		return new Vector2(
			Arena.WIDTH * (column + 1f) / (columns + 1f),
			Arena.HEIGHT * (rows - row) / (rows + 1f) );
	}

	/// <summary>Preview art for a block type (extensionless name under <c>sprites/ui/blocks</c>).</summary>
	public static string BlockPreviewImage( BlockType type ) =>
		type == BlockType.Mimic ? "mimic_preview" : $"{type.ToString().ToLowerInvariant()}_p1";

	/// <summary>Fill colour a preview block shows while (or in place of) its sprite art.</summary>
	public static Color BlockFallbackColor( BlockType type ) =>
		BlockFallbackColors[(int)type % BlockFallbackColors.Length];

	private static readonly Color[] BlockFallbackColors =
	{
		new Color( 0.95f, 0.78f, 0.25f ),
		new Color( 0.30f, 0.78f, 0.92f ),
		new Color( 0.92f, 0.38f, 0.42f ),
		new Color( 0.48f, 0.84f, 0.42f ),
		new Color( 0.78f, 0.52f, 0.92f ),
	};

	// ---- geometry helpers (ported from GameStage) -------------------------------------------------
	private const float FLUSH_EPS = 0.5f;

	private static bool FlushWithArenaWall( RectF r, Direction side ) => side switch
	{
		Direction.Down => r.Bottom <= Arena.WALL_SIZE + FLUSH_EPS,
		Direction.Up => r.Top >= Arena.HEIGHT - Arena.WALL_SIZE - FLUSH_EPS,
		Direction.Left => r.Left <= Arena.WALL_SIZE + FLUSH_EPS,
		_ => r.Right >= Arena.WIDTH - Arena.WALL_SIZE - FLUSH_EPS,
	};

	private static List<Span> FlushNeighbourSpans( IReadOnlyList<RectF> obstacles, RectF rect, Direction normal )
	{
		const float EPS = FLUSH_EPS;
		var spans = new List<Span>();
		foreach ( var q in obstacles )
		{
			if ( q.Left == rect.Left && q.Bottom == rect.Bottom && q.Right == rect.Right && q.Top == rect.Top )
				continue;   // self
			bool flush;
			float a, b;
			switch ( normal )
			{
				case Direction.Left: flush = q.Right > rect.Left - EPS && q.Right < rect.Left + EPS; a = q.Bottom; b = q.Top; break;
				case Direction.Right: flush = q.Left > rect.Right - EPS && q.Left < rect.Right + EPS; a = q.Bottom; b = q.Top; break;
				case Direction.Down: flush = q.Top > rect.Bottom - EPS && q.Top < rect.Bottom + EPS; a = q.Left; b = q.Right; break;
				default: flush = q.Bottom > rect.Top - EPS && q.Bottom < rect.Top + EPS; a = q.Left; b = q.Right; break;
			}
			if ( flush )
				spans.Add( new Span { Min = a, Max = b } );
		}
		return spans;
	}

	// [min,max] minus the cut intervals -> the surviving segments. Segments no longer than FLUSH_EPS are
	// DROPPED, like GameStage.SubtractSpans: flushness is EPS-tolerant, so a fractionally-offset rect can
	// leave a sub-EPS sliver, and a strip that short stretches its art into an artifact (a lone giant
	// tooth, since the teeth tile scales to the strip's span).
	private static List<Span> SubtractSpans( float min, float max, List<Span> cuts )
	{
		cuts.Sort( ( p, q ) => p.Min.CompareTo( q.Min ) );
		var result = new List<Span>();
		float cursor = min;
		foreach ( var c in cuts )
		{
			float s = c.Min < min ? min : (c.Min > max ? max : c.Min);
			float e = c.Max < min ? min : (c.Max > max ? max : c.Max);
			if ( e <= cursor ) continue;
			if ( s > cursor + FLUSH_EPS ) result.Add( new Span { Min = cursor, Max = s } );
			cursor = e > cursor ? e : cursor;
		}
		if ( cursor < max - FLUSH_EPS ) result.Add( new Span { Min = cursor, Max = max } );
		return result;
	}

	private static bool ObstacleSideSpiked( LevelDef level, RectF rect, Direction normal )
	{
		if ( level?.SpikedObstacleSides is null )
			return false;
		foreach ( var spec in level.SpikedObstacleSides )
		{
			if ( spec.Rect.Left != rect.Left || spec.Rect.Bottom != rect.Bottom
				|| spec.Rect.Right != rect.Right || spec.Rect.Top != rect.Top )
				continue;
			if ( Contains( spec.Sides, normal ) )
				return true;
		}
		return false;
	}

	private static bool Contains( IReadOnlyList<Direction> list, Direction d )
	{
		if ( list is null ) return false;
		for ( int i = 0; i < list.Count; i++ )
			if ( list[i] == d ) return true;
		return false;
	}

	// ---- fences (solid only to blocks; open frame + bars in the level's fence colour) --------------

	private static void AddFence( LevelPreviewModel m, IReadOnlyList<RectF> fences, RectF r, float t, Color border, Color barColor, List<FaceSeg> frames )
	{
		float f = t / 2f;          // frame thickness: half a preview wall band — lighter than a wall
		float bar = f / 2f;        // bar width
		const float spacing = 12f; // bar rhythm, world-aligned so bars continue across flush fences

		// Bars first, edge-to-edge (the frame rects paint over their ends — later rects draw on
		// top), so where a flush fence cuts the frame away (a seam) the bars run straight through
		// without breaking rhythm — mirroring GameStage.AddFence, including its FENCE_BAR_ALPHA.
		for ( float x = System.MathF.Ceiling( r.Left / spacing ) * spacing; x <= r.Right - bar; x += spacing )
			m.Rects.Add( new PreviewRect( x, r.Bottom, bar, r.Height, barColor.WithAlpha( 0.5f ) ) );

		foreach ( var normal in new[] { Direction.Left, Direction.Right, Direction.Down, Direction.Up } )
		{
			bool horizontal = normal == Direction.Up || normal == Direction.Down;
			float alongStart = horizontal ? r.Left : r.Bottom;
			float alongEnd = horizontal ? r.Right : r.Top;
			float edge = normal switch
			{
				Direction.Left => r.Left,
				Direction.Right => r.Right,
				Direction.Down => r.Bottom,
				_ => r.Top,
			};
			foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushKinSpans( fences, r, normal ) ) )
			{
				m.Rects.Add( normal switch
				{
					Direction.Down => new PreviewRect( seg.Min, r.Bottom, seg.Max - seg.Min, f, border ),
					Direction.Up => new PreviewRect( seg.Min, r.Top - f, seg.Max - seg.Min, f, border ),
					Direction.Left => new PreviewRect( r.Left, seg.Min, f, seg.Max - seg.Min, border ),
					_ => new PreviewRect( r.Right - f, seg.Min, f, seg.Max - seg.Min, border ),
				} );
				frames.Add( new FaceSeg { Normal = normal, Edge = edge, Min = seg.Min, Max = seg.Max } );
			}
		}
	}

	// ---- glass (solid only to players; translucent pane + solid border + tinted spike teeth) -------

	private static void AddGlass( LevelPreviewModel m, List<PreviewRect> spikeOverlays, LevelDef level, IReadOnlyList<RectF> glass, RectF r, float t, float s, Color color, List<FaceSeg> borders )
	{
		float f = t / 2f;   // border thickness: half a preview wall band, like the fence frame

		// Pane tint only (same alpha as the game's pane) — the node schematic is too small for the
		// in-game diagonal reflection streaks, and its rects are axis-aligned CSS boxes anyway.
		m.Rects.Add( new PreviewRect( r.Left, r.Bottom, r.Width, r.Height, color.WithAlpha( 0.30f ) ) );

		Color tint = LevelCosmeticsRandomizer.GlassTeethTint( color );
		foreach ( var normal in new[] { Direction.Left, Direction.Right, Direction.Down, Direction.Up } )
		{
			bool horizontal = normal == Direction.Up || normal == Direction.Down;
			float alongStart = horizontal ? r.Left : r.Bottom;
			float alongEnd = horizontal ? r.Right : r.Top;
			// Authored spikes: TINTED teeth overlays, split around flush glass neighbours; a side
			// flush with an arena wall is skipped exactly like an obstacle's (buried face).
			bool spiked = !FlushWithArenaWall( r, normal ) && ObstacleSideSpiked( level, r, normal );
			float edge = normal switch
			{
				Direction.Left => r.Left,
				Direction.Right => r.Right,
				Direction.Down => r.Bottom,
				_ => r.Top,
			};

			foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushKinSpans( glass, r, normal ) ) )
			{
				float len = seg.Max - seg.Min;
				m.Rects.Add( normal switch
				{
					Direction.Down => new PreviewRect( seg.Min, r.Bottom, len, f, color ),
					Direction.Up => new PreviewRect( seg.Min, r.Top - f, len, f, color ),
					Direction.Left => new PreviewRect( r.Left, seg.Min, f, len, color ),
					_ => new PreviewRect( r.Right - f, seg.Min, f, len, color ),
				} );
				borders.Add( new FaceSeg { Normal = normal, Edge = edge, Min = seg.Min, Max = seg.Max } );
				if ( !spiked ) continue;
				spikeOverlays.Add( normal switch
				{
					Direction.Down => new PreviewRect( seg.Min, r.Bottom - s, len, t + s, color, PreviewSpike.Down, tint ),
					Direction.Up => new PreviewRect( seg.Min, r.Top - t, len, t + s, color, PreviewSpike.Up, tint ),
					Direction.Left => new PreviewRect( r.Left - s, seg.Min, t + s, len, color, PreviewSpike.Left, tint ),
					_ => new PreviewRect( r.Right - t, seg.Min, t + s, len, color, PreviewSpike.Right, tint ),
				} );
			}
		}
	}

	// Fence-frame / glass-border elbow patches, mirroring GameStage.AddFrameElbowPatches: the strips
	// are seam-cut around flush same-kind panels, so at a composite's inner corner two perpendicular
	// strips only corner-touch, leaving a frame-sized notch in the outline (in the staircase case the
	// notch belongs to a THIRD rect with no strips of its own there). Probe the square just past each
	// strip end: patch iff it lies inside some same-kind rect and no strip covers it (a collinear
	// continuation across a seam does; open space past a true outer corner is outside every rect).
	private static void AddFrameElbowPatches( LevelPreviewModel m, IReadOnlyList<RectF> kin, List<FaceSeg> strips, float f, Color border )
	{
		var done = new HashSet<Vector2>();   // the two strips meeting at an L both probe their shared notch
		foreach ( var seg in strips )
		{
			bool horizontal = seg.Normal == Direction.Up || seg.Normal == Direction.Down;
			// The strip's centre line sits half a frame inside the edge.
			float perpInside = seg.Normal == Direction.Left || seg.Normal == Direction.Down
				? seg.Edge + f / 2f
				: seg.Edge - f / 2f;
			TryFrameElbowPatch( m, kin, strips, horizontal, seg.Min - f / 2f, perpInside, f, border, done );
			TryFrameElbowPatch( m, kin, strips, horizontal, seg.Max + f / 2f, perpInside, f, border, done );
		}
	}

	private static void TryFrameElbowPatch( LevelPreviewModel m, IReadOnlyList<RectF> kin, List<FaceSeg> strips, bool horizontal, float alongCenter, float perpCenter, float f, Color border, HashSet<Vector2> done )
	{
		var c = horizontal ? new Vector2( alongCenter, perpCenter ) : new Vector2( perpCenter, alongCenter );
		if ( !InsideAnyObstacle( kin, c ) || CoveredByFaceBand( strips, c, f ) )
			return;
		if ( done.Add( c ) )
			m.Rects.Add( new PreviewRect( c.x - f / 2f, c.y - f / 2f, f, f, border ) );
	}

	// The along-spans of OTHER rects of the same kind (fences or glass — each only combines with its
	// own kind) sitting flush against this side (the preview analogue of GameStage.FlushKinNeighbourSpans).
	private static List<Span> FlushKinSpans( IReadOnlyList<RectF> fences, RectF rect, Direction normal )
	{
		const float EPS = FLUSH_EPS;
		var spans = new List<Span>();
		foreach ( var q in fences )
		{
			if ( q.Left == rect.Left && q.Bottom == rect.Bottom && q.Right == rect.Right && q.Top == rect.Top )
				continue;   // self
			bool flush;
			float a, b;
			switch ( normal )
			{
				case Direction.Left: flush = q.Right > rect.Left - EPS && q.Right < rect.Left + EPS; a = q.Bottom; b = q.Top; break;
				case Direction.Right: flush = q.Left > rect.Right - EPS && q.Left < rect.Right + EPS; a = q.Bottom; b = q.Top; break;
				case Direction.Down: flush = q.Top > rect.Bottom - EPS && q.Top < rect.Bottom + EPS; a = q.Left; b = q.Right; break;
				default: flush = q.Bottom > rect.Top - EPS && q.Bottom < rect.Top + EPS; a = q.Left; b = q.Right; break;
			}
			if ( flush )
				spans.Add( new Span { Min = a, Max = b } );
		}
		return spans;
	}

	// ---- line-of-sight cover (static; mirrors VisionOccluder, cast from the player spawn) ----------
	// The umbra is a polygon, so it can't be a single axis-aligned PreviewRect; it's rasterised into a
	// stack of thin horizontal strips instead. Computed once (in Build), never per frame.

	private const float UMBRA_EXTRUDE = 2000f;   // extrude far past the arena; the result is arena-clipped
	private const float UMBRA_STRIP = 3f;        // scanline strip height (logical units)

	/// <summary>Add the static vision cover: the umbra behind each vision-flagged obstacle rect, cast
	/// from the player spawn and filled in the wall colour. Excludes the dynamic BlockShade block (only
	/// static, level-authored blockers).</summary>
	private static void AddVisionCover( LevelPreviewModel m, LevelDef level )
	{
		if ( level is null ) return;
		// Multi-spawn levels cast the schematic's shadow from the FIRST candidate (which one the run
		// picks isn't known until stage enter; the preview just needs a representative light source).
		Vector2 spawn = level.PlayerSpawns is { Count: > 0 } spawns ? spawns[0] : new Vector2( 16, 40 );

		if ( level.VisionBlockers is not null )
		{
			// Vision blockers are solid obstacle rects; the game insets the caster ~1.5px so the umbra
			// base starts inside the spike band. The preview's walls/spikes are EXAGGERATED (WALL_VIS vs
			// real WALL_SIZE), so scale the inset by the same factor or the enlarged far-face spikes poke
			// out below the shadow.
			float inset = 1.5f * ( WALL_VIS / Arena.WALL_SIZE );
			foreach ( var r in level.VisionBlockers )
				AddUmbra( m, new RectF( r.Left + inset, r.Bottom + inset, r.Right - inset, r.Top - inset ), spawn );
		}
	}

	private static void AddUmbra( LevelPreviewModel m, RectF r, Vector2 light )
	{
		// Player inside the blocker: no meaningful shadow.
		if ( light.x > r.Left && light.x < r.Right && light.y > r.Bottom && light.y < r.Top )
			return;

		// One shadow quad per BACK-FACING edge, extruded away from the light (mirrors
		// VisionOccluder.AppendBlockerShadow). Their union is the umbra and — unlike a convex hull of the
		// edge points, which would bridge the box's corner and cover HALF the box — no quad ever covers
		// the blocker itself (each lies entirely on the outward side of its edge). Strips from different
		// quads may overlap, which is harmless (same colour).
		var corners = new[] { r.BottomLeft, r.BottomRight, r.TopRight, r.TopLeft };
		var quad = new List<Vector2>( 4 );
		var clipped = new List<Vector2>();
		for ( int i = 0; i < 4; i++ )
		{
			Vector2 a = corners[i], b = corners[( i + 1 ) % 4];
			Vector2 edge = b - a;
			Vector2 outward = new Vector2( edge.y, -edge.x );
			Vector2 mid = ( a + b ) * 0.5f;
			if ( Vector2.Dot( outward, mid - light ) <= 0f )
				continue; // front-facing edge casts no umbra

			quad.Clear();
			quad.Add( a );
			quad.Add( b );
			quad.Add( ExtrudeUmbra( b, light ) );
			quad.Add( ExtrudeUmbra( a, light ) );

			ClipToArenaPoly( quad, clipped );
			RasterizeConvex( m.Rects, clipped, m.Wall );
		}
	}

	private static Vector2 ExtrudeUmbra( Vector2 p, Vector2 light )
	{
		Vector2 dir = p - light;
		float len = dir.Length;
		if ( len < 0.0001f ) return p;
		return p + dir / len * UMBRA_EXTRUDE;
	}
	// Rasterise a convex polygon into horizontal strips: one PreviewRect per scanline row (2
	// intersections per row for a convex polygon give the row's x-span).
	private static void RasterizeConvex( List<PreviewRect> outRects, List<Vector2> poly, Color color )
	{
		if ( poly.Count < 3 ) return;

		float yMin = float.PositiveInfinity, yMax = float.NegativeInfinity;
		foreach ( var p in poly ) { if ( p.y < yMin ) yMin = p.y; if ( p.y > yMax ) yMax = p.y; }
		yMin = System.MathF.Max( yMin, 0f );
		yMax = System.MathF.Min( yMax, Arena.HEIGHT );

		int n = poly.Count;
		for ( float y = yMin; y < yMax; y += UMBRA_STRIP )
		{
			float rowY = y + UMBRA_STRIP * 0.5f;
			float xMin = float.PositiveInfinity, xMax = float.NegativeInfinity;
			for ( int i = 0; i < n; i++ )
			{
				Vector2 a = poly[i], b = poly[( i + 1 ) % n];
				if ( ( a.y <= rowY ) == ( b.y <= rowY ) )
					continue; // edge doesn't straddle this row
				float x = a.x + ( b.x - a.x ) * ( ( rowY - a.y ) / ( b.y - a.y ) );
				if ( x < xMin ) xMin = x;
				if ( x > xMax ) xMax = x;
			}
			if ( xMax > xMin )
			{
				float h = System.MathF.Min( UMBRA_STRIP, yMax - y );
				outRects.Add( new PreviewRect( xMin, y, xMax - xMin, h, color ) );
			}
		}
	}

	// Sutherland–Hodgman clip of a convex polygon to the arena rect [0,W]x[0,H].
	private static void ClipToArenaPoly( List<Vector2> input, List<Vector2> result )
	{
		var a = new List<Vector2>( input );
		var b = new List<Vector2>();
		ClipArenaEdge( a, b, 0 );
		ClipArenaEdge( b, a, 1 );
		ClipArenaEdge( a, b, 2 );
		ClipArenaEdge( b, a, 3 );
		result.Clear();
		result.AddRange( a );
	}

	private static void ClipArenaEdge( List<Vector2> src, List<Vector2> dst, int edge )
	{
		dst.Clear();
		int n = src.Count;
		if ( n == 0 ) return;
		Vector2 prev = src[n - 1];
		bool prevIn = ArenaInside( prev, edge );
		for ( int i = 0; i < n; i++ )
		{
			Vector2 cur = src[i];
			bool curIn = ArenaInside( cur, edge );
			if ( curIn )
			{
				if ( !prevIn ) dst.Add( ArenaIntersect( prev, cur, edge ) );
				dst.Add( cur );
			}
			else if ( prevIn )
			{
				dst.Add( ArenaIntersect( prev, cur, edge ) );
			}
			prev = cur; prevIn = curIn;
		}
	}

	private static bool ArenaInside( Vector2 p, int edge ) => edge switch
	{
		0 => p.x >= 0f,
		1 => p.x <= Arena.WIDTH,
		2 => p.y >= 0f,
		_ => p.y <= Arena.HEIGHT,
	};

	private static Vector2 ArenaIntersect( Vector2 a, Vector2 b, int edge )
	{
		float t = edge switch
		{
			0 => ( 0f - a.x ) / ( b.x - a.x ),
			1 => ( Arena.WIDTH - a.x ) / ( b.x - a.x ),
			2 => ( 0f - a.y ) / ( b.y - a.y ),
			_ => ( Arena.HEIGHT - a.y ) / ( b.y - a.y ),
		};
		return a + ( b - a ) * t;
	}
}