DiamondPanel.cs
using System;
using System.Collections.Generic;
using Sandbox;
using Sandbox.UI;

namespace Diamonds;

/// <summary>Every visible element is drawn with the new Painter API; no sprite assets.</summary>
public sealed partial class DiamondPanel : Panel
{
	public DiamondBoard Board { get; set; }
	public DiamondDebris Debris { get; set; }
	public DiamondAtmosphere Atmosphere { get; set; }
	public DiamondScoreEffects ScoreEffects { get; set; }
	public Vector2 ShakeOffset { get; set; }
	public Vector2 PieceShakeOffset { get; set; }
	public bool IsMuted { get; set; }
	/// <summary>Editor-only music state, drawn in the bottom-left screen corner when set.</summary>
	public string MusicDebugText { get; set; }
	public bool ShowLandingIndicator { get; set; } = false;
	/// <summary>Multiplies the landing point marker's original size.</summary>
	public float LandingPointScale { get; set; } = 1;
	/// <summary>Thumbnail capture: draw only the background dye, nothing else.</summary>
	public bool OnlyDye { get; set; }
	/// <summary>Thumbnail capture: skip every dye layer, including the static and menu dye.</summary>
	public bool HideDye { get; set; }
	/// <summary>Thumbnail capture: blends every dye layer from its normal faint wash (0) to opaque, fully saturated color (1).</summary>
	public float DyeVibrancy { get; set; }
	float DyeLayerOpacity( float opacity )
	{
		float vibrancy = Math.Clamp( DyeVibrancy, 0, 1 );
		return 0.22f * opacity + (1 - 0.22f * opacity) * vibrancy;
	}
	sealed class LandingPointGlow
	{
		public DiamondDamage.Contact Contact;
		public float Opacity;
		public float Rotation;
		public bool Visible;
		// Seconds since appearing, driving the uneven per-ray pulse; Appear eases the pop-in scale.
		public float Age;
		public float Appear;
		public float PulsePhase;
	}
	readonly List<LandingPointGlow> landingPointGlows = new();
	readonly List<DiamondDamage.Contact> landingPointContacts = new( 4 );
	Rect[] atmosphereTiles;
	int atmosphereTileVersion;
	Texture atmosphereTexture;
	Color32[] atmospherePixels;
	DyeTextureStamp atmosphereTextureStamp;
	struct DyeTextureStamp
	{
		public object Source;
		public ulong Version;
		public Color[] Palette;
		public Color Background;
		public float Vibrancy;
	}
	// Only the editor comparison tools enable the retained rectangle reference.
	bool atmosphereReferenceTiles = false;
	// Editor GPU checks compare this against full resolution on an identical frame.
	float dyeLayerResolution = 0.5f;

	public override void OnDeleted()
	{
		ReleaseGemAtlas();
		ReleaseSparkAtlas();
		ReleaseWallMasks();
		atmosphereTexture?.Dispose();
		atmosphereTexture = null;
		titleDyeTexture?.Dispose();
		titleDyeTexture = null;
		base.OnDeleted();
	}
	readonly Random glowRandom = new();
	DiamondBoard glowBoard;
	int glowResetVersion;
	float boundaryTime;
	DiamondBoard previewBoard;
	int previewResetVersion, previewSpawnVersion;
	float previewBounceTime = 0.45f;

	public void UpdateNextPreview( float delta )
	{
		if ( Board is null ) return;
		if ( previewBoard != Board || previewResetVersion != Board.ResetVersion || previewSpawnVersion != Board.SpawnVersion )
		{
			previewBoard = Board;
			previewResetVersion = Board.ResetVersion;
			previewSpawnVersion = Board.SpawnVersion;
			previewBounceTime = 0;
		}
		else if ( !Board.Paused ) previewBounceTime = MathF.Min( 0.45f, previewBounceTime + MathF.Max( 0, delta ) );
	}

	float NextPreviewScale
	{
		get
		{
			float t = previewBounceTime / 0.45f;
			return 1 + 0.09f * MathF.Sin( t * MathF.PI * 2 ) * (1 - t) * (1 - t);
		}
	}
	static readonly Color Ink = Color.Parse( "#e4eeef" ).Value;
	static readonly Color Muted = Color.Parse( "#82959d" ).Value;
	static readonly Color TimerInk = Color.Parse( "#50636d" ).Value;
	static void DrawTimer( Painter p, string value, Vector2 center ) =>
		DrawTimeNumber( p, value, center, 32, TimerInk.WithAlpha( 0.42f ), outline: false );
	[SkipHotload] static readonly Color ToothFill = Color.Parse( "#1c2933" ).Value;
	[SkipHotload] static readonly Color GameOverToothFill = Color.Parse( "#451e25" ).Value;
	[SkipHotload]
	static readonly Color FloorInk = Color.Parse( "#70818c" ).Value;
	[SkipHotload]
	static readonly Color GameOverSpikeInk = Color.Parse( "#e34343" ).Value;
	[SkipHotload]
	static readonly Color GameOverFlashRed = Color.Parse( "#ef3238" ).Value;
	[SkipHotload]
	static readonly Color GameOverFlashYellow = Color.Parse( "#ffe04a" ).Value;
	Color SpikeInk => Board.GameOver ? GameOverSpikeInk : FloorInk;
	// Saturated per-diamond colors, shared by the active piece, ghost and next preview.
	// These are presentation constants: refresh their initializers when hotloading.
	[SkipHotload]
	static readonly Color[] Palette = [Color.Parse( "#f23d3d" ).Value, Color.Parse( "#2298f0" ).Value,
		Color.Parse( "#32cc59" ).Value, Color.Parse( "#ffdc32" ).Value, Color.Parse( "#b63fc9" ).Value];
	// Preserve the 1592-wide canvas, keeping gem scale identical at every viewport size.
	const float Left = (1592 - DiamondBoard.BoardWidth) * 0.5f;
	// Fit the expanded board to the screen, scaling gems and boundary teeth together.
	// Keep gameplay and HUD placement stable; the board rendering extends above this margin.
	const float Top = 8;
	const float NextPreviewCenterY = Top + 212;
	const float CanvasWidth = DiamondBoard.BoardWidth + Left * 2;
	const float CanvasHeight = Top + DiamondBoard.BoardHeight + 8;
	float boardRenderTop;
	float boardOffsetY;
	Vector2 BoardOffset => ShakeOffset + new Vector2( 0, boardOffsetY );
	Rect BoardRenderBounds => new( Left, boardRenderTop, DiamondBoard.BoardWidth, Top + DiamondBoard.BoardHeight - boardRenderTop );

	// Include any extra horizontal margin outside the fitted canvas on wide screens.
	static float LetterboxCenter( Vector2 size, bool right = false )
	{
		float scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );
		float extra = scale > 0 ? MathF.Max( 0, (size.x / scale - CanvasWidth) * 0.5f ) : 0;
		float center = (Left - extra) * 0.5f;
		return right ? CanvasWidth - center : center;
	}

	public DiamondPanel()
	{
		Style.Width = Length.Percent( 100 );
		Style.Height = Length.Percent( 100 );
		Style.PointerEvents = PointerEvents.All;
	}

	public void UpdateLandingPointGlows( float delta )
	{
		if ( Board != glowBoard || Board?.ResetVersion != glowResetVersion )
		{
			landingPointGlows.Clear();
			boundaryTime = 0;
			glowBoard = Board;
			glowResetVersion = Board?.ResetVersion ?? 0;
		}
		if ( Board is null || Board.Paused ) return;
		boundaryTime = (boundaryTime + MathF.Max( 0, delta )) % 3.6f;
		foreach ( var glow in landingPointGlows ) glow.Visible = false;
		Board.CopyLandingPointContacts( landingPointContacts );
		foreach ( var contact in landingPointContacts )
		{
			// Keep each marker's fade and rotation when projection rounding changes slightly.
			LandingPointGlow glow = null;
			foreach ( var candidate in landingPointGlows )
				if ( candidate.Contact.ColorIndex == contact.ColorIndex &&
					MathF.Abs( candidate.Contact.Lane - contact.Lane ) < 0.001f && MathF.Abs( candidate.Contact.Y - contact.Y ) < 0.02f )
				{ glow = candidate; break; }
			if ( glow is null )
			{
				glow = new LandingPointGlow { Contact = contact, Rotation = (float)glowRandom.NextDouble() * MathF.PI * 2,
					PulsePhase = (float)glowRandom.NextDouble() * MathF.PI * 2 };
				landingPointGlows.Add( glow );
			}
			glow.Visible = true;
		}
		delta = MathF.Max( 0, delta );
		for ( int i = landingPointGlows.Count - 1; i >= 0; i-- )
		{
			var glow = landingPointGlows[i];
			// Frame-rate independent lerp; retain old contact points until they fade away.
			float blend = 1 - MathF.Exp( -delta / (glow.Visible ? 0.09f : 0.07f) );
			glow.Opacity += ((glow.Visible ? 1 : 0) - glow.Opacity) * blend;
			glow.Rotation = (glow.Rotation + delta * 0.3f) % (MathF.PI * 2);
			glow.Age = (glow.Age + delta) % 1000;
			if ( glow.Visible ) glow.Appear = MathF.Min( 1, glow.Appear + delta / LandingPointAppearTime );
			if ( !glow.Visible && glow.Opacity < 0.01f ) landingPointGlows.RemoveAt( i );
		}
	}

	public override void OnDraw( Painter p )
	{
#if STANDALONE
#endif
		var screenTransform = p.Transform;
		p.Fill = Color.Parse( "#0b1017" ).Value;
		p.Stroke = Stroke.None;
		p.Rect( p.Bounds );
		if ( Board is null ) return;
		float scale = MathF.Min( p.Bounds.Width / CanvasWidth, p.Bounds.Height / CanvasHeight );
		if ( scale <= 0 ) return;
		// Halve the full bottom gap, including any vertical letterboxing, without moving the HUD.
		float verticalMargin = MathF.Max( 0, (p.Bounds.Height / scale - CanvasHeight) * 0.5f );
		boardOffsetY = (verticalMargin + 8) * 0.5f;
		// Reach the actual screen edge even after lowering the board or shaking it downward.
		boardRenderTop = -verticalMargin - boardOffsetY - MathF.Abs( ShakeOffset.y ) - 8;
		if ( OnlyDye )
		{
			DrawDyeOnly( p, scale );
			return;
		}
		if ( TitleScreen )
		{
			using ( p.Scope() )
			{
				p.Translate( (p.Bounds.Size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f );
				p.Scale( scale );
				DrawTitleScreen( p, scale );
			}
			DrawTutorial( p );
			DrawOptions( p );
			DrawLeaderboard( p );
			DrawTransition( p );
			DrawMusicDebug( p );
			return;
		}
		p.Translate( (p.Bounds.Size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f );
		p.Scale( scale );
		float rightCenter = LetterboxCenter( p.Bounds.Size, true );
		DrawTimer( p, DiamondLeaderboard.FormatTime( (long)Board.ElapsedSeconds ), new Vector2( LetterboxCenter( p.Bounds.Size ), Top + 40 ) );

		if ( !Board.GameOver )
		{
			var next = DiamondShapes.All[Board.NextShapeIndex];
			float previewLane = (next.Cells.Min( c => c.Lane ) + next.Cells.Max( c => c.Lane )) * 0.5f;
			float previewRow = (next.Cells.Min( c => c.Row ) + next.Cells.Max( c => c.Row )) * 0.5f;
			// Scale the entire mixed-color piece about its visual center, including its outlines.
			using var previewScope = p.Scope();
			var previewCenter = new Vector2( rightCenter, NextPreviewCenterY );
			p.Translate( previewCenter );
			p.Scale( NextPreviewScale );
			p.Translate( -previewCenter );
			DrawShape( p, Board.Next,
				new Vector2( rightCenter - previewLane * DiamondBoard.Width * 0.5f, NextPreviewCenterY - previewRow * DiamondBoard.Height * 0.5f ), false );
		}

		using ( p.Scope() )
		{
			p.Translate( BoardOffset );
			using ( p.Scope() )
			{
				ClipBoard( p );
				p.Fill = Color.Parse( "#080d13" ).Value;
				p.Stroke = Stroke.None;
				p.Rect( BoardRenderBounds );
				DrawAtmosphere( p );
				DrawFloorFill( p );
				DrawSideWalls( p );
				if ( ShowLandingIndicator && !Board.GameOver && !Board.IsSliding && !Board.IsResolving )
					DrawShape( p, Board.Active, Position( Board.Active.Lane, Board.LandingY ), true );
				var displayCells = DamageShakeCells();
				foreach ( var piece in displayCells )
					DrawDiamond( p, Position( piece.Lane, piece.Y ), piece.ColorIndex, false, health: piece.Health, crackCorner: piece.FirstCrack );
				DrawGroupOutlines( p, displayCells, Board.BondedGroups, new Vector2( Left, Top ), pulses: Board.BondPulses );
				if ( !Board.GameOver && !Board.IsResolving )
					DrawActivePiece( p );
				foreach ( var cell in Board.Shattering )
				{
					if ( Board.IsDamagePaused )
						DrawDiamond( p, Position( cell.Lane, cell.Y ), cell.ColorIndex, false, health: 1, crackCorner: cell.FirstCrack );
					else if ( Board.GameOver )
						DrawDiamond( p, Position( cell.Lane, cell.Y ), cell.ColorIndex, false,
							scale: 1 - Board.ShatterProgress * 0.75f, opacity: 1 - Board.ShatterProgress,
							health: cell.Health, crackCorner: cell.FirstCrack );
				}
				DrawDamageShines( p );

				// The floor slopes exactly match the lower half of a diamond.
				int cornerSkip = Board.FillCornerPockets ? 2 : 0;
				Span<Vector2> floor = stackalloc Vector2[DiamondBoard.Valleys * 2 + 1 - cornerSkip * 2];
				for ( int i = 0; i < floor.Length; i++ )
					floor[i] = new Vector2( Left + (i + cornerSkip) * DiamondBoard.Width * 0.5f,
						Top + DiamondBoard.BoardHeight - (i % 2 == 0 ? DiamondBoard.Height * 0.5f : 0) );
				p.Stroke = Stroke.Solid( SpikeInk.WithAlpha( 0.08f + Board.LockFlash * 0.18f ), 9 );
				p.Line( floor );
				p.Stroke = Stroke.Solid( SpikeInk, 2 );
				p.Line( floor );
				foreach ( var glow in landingPointGlows ) DrawLandingPointGlow( p, glow, LandingPointScale );
				foreach ( var flash in Board.DamageFlashes ) DrawDamageFlash( p, flash );
				foreach ( var flash in Board.FaceFlashes ) DrawFaceFlash( p, flash );
				DrawDebris( p );
				if ( Debris is not null )
				{
					foreach ( var burst in Debris.ShatterSunbursts )
						DrawSunburst( p, new Vector2( Left, Top ) + burst.Position, burst.ColorIndex,
							burst.Age / burst.Lifetime, burst.Rotation, burst.Brightness, burst.Scale );
					foreach ( var burst in Debris.CrushSunbursts )
						DrawSunburst( p, new Vector2( Left, Top ) + burst.Position, burst.ColorIndex,
							burst.Age / burst.Lifetime, burst.Rotation, burst.Brightness, burst.Scale );
				}
				DrawSmoke( p );
				DrawGameOverFlash( p );
			}
		}

		if ( !Board.GameOver ) DrawScoreNumber( p, "NEXT", new Vector2( rightCenter, Top + 72 ), 48, Muted * 0.6f );
		DrawScore( p, LetterboxCenter( p.Bounds.Size ) );
		DrawControls( p, rightCenter );
		DrawTutorialButton( p, scale );
		DrawOptionsButton( p, scale );
		DrawLeaveButton( p, scale );

		if ( Board.GameOver ) DrawGameOver( p, scale );
		DrawBoardBorder( p, scale );
		// After game over, the RESTARTING frame covers the RESTART button instead of the board center.
		DrawRestartHold( p, screenTransform, Board.GameOver ? GameOverRestartButton.Center
			: new Vector2( Left + DiamondBoard.BoardWidth * 0.5f, Top + boardOffsetY + DiamondBoard.BoardHeight * 0.5f ), scale );
		p.Transform = screenTransform;
		DrawTutorial( p );
		DrawOptions( p );
		DrawLeaderboard( p );
		DrawTransition( p );
		DrawMusicDebug( p );
	}

	/// <summary>Thumbnail capture: the current screen's dye over its plain background, without the board, HUD or overlays.</summary>
	void DrawDyeOnly( Painter p, float scale )
	{
		using var scope = p.Scope();
		p.Translate( (p.Bounds.Size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f );
		p.Scale( scale );
		if ( TitleScreen )
		{
			DrawTitleDye( p );
			return;
		}
		p.Translate( BoardOffset );
		ClipBoard( p );
		p.Fill = Color.Parse( "#080d13" ).Value;
		p.Stroke = Stroke.None;
		p.Rect( BoardRenderBounds );
		DrawAtmosphere( p );
	}

	/// <summary>Screen-space debug readout above everything, including the tutorial.</summary>
	void DrawMusicDebug( Painter p )
	{
		if ( string.IsNullOrEmpty( MusicDebugText ) ) return;
		var lines = MusicDebugText.Split( '\n' );
		const float size = 14, lineHeight = 18, padding = 10, width = 600;
		float height = lines.Length * lineHeight + padding * 2;
		float top = p.Bounds.Height - 14 - height;
		p.Fill = Color.Black.WithAlpha( 0.62f );
		p.Stroke = Stroke.None;
		p.Rect( new Rect( 14, top, width, height ) );
		for ( int i = 0; i < lines.Length; i++ )
			// Painter's text rect includes the font's ascent, so give each line generous height.
			Text( p, lines[i].TrimEnd( '\r' ), 14 + padding, top + padding + i * lineHeight - size * 0.4f, width - padding * 2, lineHeight * 2,
				size, Color.White.WithAlpha( 0.9f ), fontName: "Roboto Mono" );
	}

	static void DrawControls( Painter p, float center )
	{
		DrawScoreNumber( p, "ROTATE", new Vector2( center, Top + 374 ), 26, Muted * 0.65f );
		DrawControlKey( p, new Vector2( center, Top + 428 ), "↑" );
		string[] arrows = ["←", "↓", "→"];
		string[] labels = ["LEFT", "DROP", "RIGHT"];
		for ( int i = 0; i < 3; i++ )
		{
			float x = center + (i - 1) * 84;
			DrawControlKey( p, new Vector2( x, Top + 506 ), arrows[i] );
			DrawScoreNumber( p, labels[i], new Vector2( x, Top + 560 ), 20, Muted * 0.65f );
		}
	}

	static void DrawControlKey( Painter p, Vector2 center, string arrow )
	{
		p.Fill = Ink.WithAlpha( 0.025f );
		p.Stroke = Stroke.None;
		p.Rect( new Rect( center - new Vector2( 32 ), new Vector2( 64 ) ) );
		DrawScoreNumber( p, arrow, center, 58, Ink.WithAlpha( 0.5f ) );
	}

	void DrawBoardBorder( Painter p, float scale )
	{
		using var scope = p.Scope();
		p.Translate( BoardOffset );
		float cornerScale = Board.FillCornerPockets ? 1.5f : 0.5f;
		float cornerWidth = DiamondBoard.Width * cornerScale;
		float cornerHeight = DiamondBoard.Height * cornerScale;
		float right = Left + DiamondBoard.BoardWidth;
		float bottom = Top + DiamondBoard.BoardHeight;
		// Keep a 3.5-pixel outline at every canvas scale, with sharp joins and an open top.
		ReadOnlySpan<Vector2> points =
		[
			new( Left, boardRenderTop ), new( Left, bottom - cornerHeight ),
			new( Left + cornerWidth, bottom ), new( right - cornerWidth, bottom ),
			new( right, bottom - cornerHeight ), new( right, boardRenderTop )
		];
		if ( borderPoints is null || !points.SequenceEqual( borderPoints ) )
		{
			borderPoints = points.ToArray();
			borderMiters = BuildPerimeterMiters( points, false );
		}
		DrawPerimeter( p, points, borderMiters, SpikeInk, 3.5f / scale, false );
	}

	void DrawGameOverFlash( Painter p )
	{
		if ( !Board.GameOver || Board.Settled.Count == 0 && Board.Shattering.Count == 0 ) return;
		float yellow = 0.5f + 0.5f * MathF.Sin( boundaryTime * MathF.PI * 2 / 0.9f );
		p.Fill = new Color(
			GameOverFlashRed.r + (GameOverFlashYellow.r - GameOverFlashRed.r) * yellow,
			GameOverFlashRed.g + (GameOverFlashYellow.g - GameOverFlashRed.g) * yellow,
			GameOverFlashRed.b + (GameOverFlashYellow.b - GameOverFlashRed.b) * yellow )
			.WithAlpha( 0.055f + 0.035f * yellow );
		p.Stroke = Stroke.None;
		// Draw inside ClipBoard so the wash follows the cut corners and never enters the letterboxes.
		p.Rect( BoardRenderBounds );
	}

	void ClipBoard( Painter p )
	{
		p.Clip( BoardRenderBounds );
		// Intersect with two diagonal half-planes. The background, effects, and
		// game-over wash all stop at the actual end slopes instead of a rectangular box.
		float cornerHeight = DiamondBoard.Height * (Board.FillCornerPockets ? 1.5f : 0.5f);
		float extent = CanvasWidth + CanvasHeight + MathF.Abs( boardRenderTop );
		var transform = p.Transform;
		foreach ( int side in new[] { -1, 1 } )
		{
			p.Translate( Left + (side > 0 ? DiamondBoard.BoardWidth : 0), Top + DiamondBoard.BoardHeight - cornerHeight );
			p.Rotate( MathF.Atan2( DiamondBoard.Height, -side * DiamondBoard.Width ) * 180 / MathF.PI );
			p.Clip( new Rect( -extent, side < 0 ? -extent : 0, extent * 2, extent ) );
			// Clips capture their transform; restoring only the drawing transform keeps them active.
			p.Transform = transform;
		}
	}

	void DrawAtmosphere( Painter p )
	{
		if ( HideDye || Atmosphere is null || !Atmosphere.Enabled || Atmosphere.Opacity <= 0 ) return;
		if ( atmosphereReferenceTiles ) { DrawAtmosphereTiles( p ); return; }
		const int columns = DiamondAtmosphere.Columns, rows = DiamondAtmosphere.Rows;
		UpdateDyeTexture( Atmosphere, Atmosphere.DyeVersion, Atmosphere.Samples, columns, rows, Color.Parse( "#080d13" ).Value,
			"diamonds-gameplay-dye", Math.Clamp( DyeVibrancy, 0, 1 ), ref atmosphereTexture, ref atmospherePixels, ref atmosphereTextureStamp );
		var origin = DiamondAtmosphere.GridToWorld( 0, 0 ) + new Vector2( Left, Top ) - new Vector2( DiamondAtmosphere.CellSize * 0.5f );
		DrawDyeLayer( p, atmosphereTexture, BoardRenderBounds,
			new Rect( origin, new Vector2( columns, rows ) * DiamondAtmosphere.CellSize ), DyeLayerOpacity( Atmosphere.RenderOpacity ) );
	}

	void DrawDyeLayer( Painter p, Texture texture, Rect bounds, Rect image, float opacity )
	{
		float resolution = dyeLayerResolution;
		using var scope = p.Scope();
		p.Scale( 1 / resolution );
		var smallBounds = new Rect( bounds.Position * resolution, bounds.Size * resolution );
		var smallImage = new Rect( image.Position * resolution, image.Size * resolution );
		using var blur = p.BeginLayer( smallBounds, opacity, new Painter.Filter { Blur = 6 * resolution } );
		p.Texture( texture, smallImage );
	}

	// Both screens share color packing, linear texture format and reusable upload storage.
	static void UpdateDyeTexture( object source, ulong version, ReadOnlySpan<float> samples, int columns, int rows, Color background,
		string name, float vibrancy, ref Texture texture, ref Color32[] pixels, ref DyeTextureStamp stamp )
	{
		bool recreate = texture is null || !texture.IsValid || texture.Width != columns || texture.Height != rows;
		// Opacity and placement belong to Painter, so changing either needs no pixel upload.
		if ( !recreate && pixels?.Length == columns * rows && ReferenceEquals( stamp.Source, source ) &&
			stamp.Version == version && ReferenceEquals( stamp.Palette, Palette ) && stamp.Background.Equals( background ) && stamp.Vibrancy == vibrancy ) return;
		if ( recreate )
		{
			texture?.Dispose();
			// UI solid fills use these raw color values; avoid an extra sRGB decode.
			texture = Texture.Create( columns, rows, ImageFormat.RGBA8888_LINEAR )
				.WithDynamicUsage().WithName( name ).Finish();
		}
		if ( pixels is null || pixels.Length != columns * rows ) pixels = new Color32[columns * rows];
		for ( int n = 0; n < pixels.Length; n++ )
		{
			float total = 0, weightedTotal = 0, red = 0, green = 0, blue = 0;
			for ( int color = 0; color < DiamondBoard.ColorCount; color++ )
			{
				float amount = samples[n * DiamondBoard.ColorCount + color];
				total += amount;
				float vividAmount = amount * amount;
				weightedTotal += vividAmount;
				var ink = Palette[color];
				red += ink.r * vividAmount; green += ink.g * vividAmount; blue += ink.b * vividAmount;
			}
			// Vibrant dye saturates from a smaller amount; at full vibrancy only truly empty space stays dark.
			float coverage = Math.Clamp( (total - (0.04f - 0.035f * vibrancy)) / (0.28f - 0.22f * vibrancy), 0, 1 );
			coverage = coverage * coverage * (3 - 2 * coverage);
			pixels[n] = weightedTotal <= 0 || coverage <= 0 ? background :
				new Color( background.r + (red / weightedTotal - background.r) * coverage,
					background.g + (green / weightedTotal - background.g) * coverage,
					background.b + (blue / weightedTotal - background.b) * coverage );
		}
		texture.Update( pixels.AsSpan(), width: columns, height: rows );
		stamp = new DyeTextureStamp { Source = source, Version = version, Palette = Palette, Background = background, Vibrancy = vibrancy };
	}

	void DrawAtmosphereTiles( Painter p )
	{
		if ( Atmosphere is null || !Atmosphere.Enabled || Atmosphere.Opacity <= 0 ) return;
		if ( atmosphereTiles is null || atmosphereTiles.Length != DiamondAtmosphere.Columns * DiamondAtmosphere.Rows ||
			atmosphereTileVersion != 9 )
		{
			atmosphereTiles = BuildAtmosphereTiles();
			atmosphereTileVersion = 9;
		}
		using var layer = p.BeginLayer( BoardRenderBounds,
			0.22f * Atmosphere.RenderOpacity, new Painter.Filter { Blur = 6 } );
		var background = Color.Parse( "#080d13" ).Value;
		p.Stroke = Stroke.None;
		var samples = Atmosphere.Samples;
		for ( int y = 0; y < DiamondAtmosphere.Rows; y++ )
		for ( int x = 0; x < DiamondAtmosphere.Columns; x++ )
		{
			var tile = atmosphereTiles[y * DiamondAtmosphere.Columns + x];
			if ( tile.Width <= 0 ) continue;
			float total = 0, weightedTotal = 0, red = 0, green = 0, blue = 0;
			int sampleIndex = (y * DiamondAtmosphere.Columns + x) * DiamondBoard.ColorCount;
			for ( int color = 0; color < DiamondBoard.ColorCount; color++ )
			{
				float amount = samples[sampleIndex + color];
				total += amount;
				float vividAmount = amount * amount;
				weightedTotal += vividAmount;
				var ink = Palette[color];
				red += ink.r * vividAmount;
				green += ink.g * vividAmount;
				blue += ink.b * vividAmount;
			}
			float coverage = Math.Clamp( (total - 0.04f) / 0.28f, 0, 1 );
			coverage = coverage * coverage * (3 - 2 * coverage);
			if ( weightedTotal <= 0 || coverage <= 0 ) continue;
			p.Fill = new Color( background.r + (red / weightedTotal - background.r) * coverage,
				background.g + (green / weightedTotal - background.g) * coverage,
				background.b + (blue / weightedTotal - background.b) * coverage );
			p.Rect( tile );
		}
	}

	static Rect[] BuildAtmosphereTiles()
	{
		var tiles = new Rect[DiamondAtmosphere.Columns * DiamondAtmosphere.Rows];
		// Screen-aligned squares keep the previous overlap to hide seams under the blur.
		float radius = DiamondAtmosphere.CellSize * 0.5f + 3 * 0.70710678f;
		for ( int y = 0; y < DiamondAtmosphere.Rows; y++ )
		for ( int x = 0; x < DiamondAtmosphere.Columns; x++ )
		{
			Vector2 center = DiamondAtmosphere.GridToWorld( x, y );
			if ( center.x < -radius || center.x > DiamondBoard.BoardWidth + radius ||
				center.y < -radius || center.y > DiamondBoard.BoardHeight + radius ) continue;
			center += new Vector2( Left, Top );
			tiles[y * DiamondAtmosphere.Columns + x] = new Rect( center - new Vector2( radius ), new Vector2( radius * 2 ) );
		}
		return tiles;
	}

	void DrawFloorFill( Painter p )
	{
		p.Stroke = Stroke.None;
		for ( int i = 0; i <= DiamondBoard.Valleys; i++ )
		{
			var center = new Vector2( Left + i * DiamondBoard.Width, Top + DiamondBoard.BoardHeight );
			DrawFloorTooth( p, center, SpikeInk, Board.GameOver );
		}
	}

	static void DrawFloorTooth( Painter p, Vector2 center, Color ink, bool gameOver = false )
	{
		var left = center + new Vector2( -DiamondBoard.Width * 0.5f, 0 );
		var tip = center + new Vector2( 0, -DiamondBoard.Height * 0.5f );
		var right = center + new Vector2( DiamondBoard.Width * 0.5f, 0 );
		p.Stroke = Stroke.None;
		p.Fill = gameOver ? GameOverToothFill : ToothFill;
		p.Polygon( [left, tip, right] );
		p.Fill = ink.WithAlpha( 0.12f );
		p.Polygon( [left, tip, center] );
	}

	void DrawSideWalls( Painter p )
	{
		Span<Vector2> edge = stackalloc Vector2[3];
		// Continue the same tooth pattern above the gameplay bounds to the screen edge.
		for ( float y = DiamondBoard.BoardHeight; Top + y + DiamondBoard.Height * 0.5f > boardRenderTop; y -= DiamondBoard.Height )
		for ( int side = 0; side < 2; side++ )
		{
			var tooth = new DiamondBoard.Diamond( side == 0 ? -1 : DiamondBoard.LaneCount, y, 0 );
			if ( Board.FillCornerPockets && tooth.Y == DiamondBoard.BoardHeight ) continue;
			var center = Position( tooth.Lane, tooth.Y );
			float inward = tooth.Lane < 0 ? 1 : -1;
			edge[0] = center + new Vector2( 0, -DiamondBoard.Height * 0.5f );
			edge[1] = center + new Vector2( inward * DiamondBoard.Width * 0.5f, 0 );
			edge[2] = center + new Vector2( 0, DiamondBoard.Height * 0.5f );
			bool corner = Board.FillCornerPockets && tooth.Y == DiamondBoard.BoardHeight - DiamondBoard.Height;
			if ( corner ) edge[2] += new Vector2( inward * DiamondBoard.Width, 0 );
			p.Stroke = Stroke.None;
			if ( !corner )
			{
				p.Fill = Board.GameOver ? GameOverToothFill : ToothFill;
				p.Polygon( edge );
				p.Fill = SpikeInk.WithAlpha( 0.12f );
				p.Polygon( [edge[0], edge[1], center] );
			}
			if ( DrawWallStrokeMask( p, center, inward, corner ) ) continue;
			p.Stroke = Stroke.Solid( SpikeInk.WithAlpha( 0.08f + Board.LockFlash * 0.18f ), 9 );
			p.Line( edge );
			p.Stroke = Stroke.Solid( SpikeInk, 2 );
			p.Line( edge );
		}
	}

	void DrawDebris( Painter p )
	{
		if ( Debris is null ) return;
		p.Stroke = Stroke.None;
		foreach ( var chip in Debris.Particles )
		{
			if ( !chip.Visible ) continue;
			var position = new Vector2( Left, Top ) + chip.Position;
			// Include rotation, the widest mitered glow, and an antialiasing margin.
			float radius = (chip.Radius + (chip.IsSpark ? 6 : 7.5f)) * (chip.IsSpark ? 1.6f : 1) + 2;
			if ( !drawingReferenceGeometry && (position.x + radius < BoardRenderBounds.Left ||
				position.x - radius > BoardRenderBounds.Right || position.y + radius < BoardRenderBounds.Top ||
				position.y - radius > BoardRenderBounds.Bottom) ) continue;
			float opacity = chip.Opacity;
			using var scope = p.Scope();
			p.Translate( position );
			p.Rotate( chip.Angle );
			var color = GemColor( chip.ColorIndex, chip.IsSpark ? DiamondBoard.MaxHealth : 1 );
			float highlight = chip.IsSpark ? chip.Brightness : 0.12f + chip.Brightness * 0.25f;
			var tint = new Color( color.r + (1 - color.r) * highlight,
				color.g + (1 - color.g) * highlight, color.b + (1 - color.b) * highlight );
			if ( chip.IsSpark )
			{
				// Enlarge the light without changing the small physical collision fixture.
				p.Scale( 1.6f );
				if ( !DrawSparkGlow( p, chip, opacity ) )
					DrawParticleGlow( p, chip.Vertices, color, opacity * 0.24f, 1, ParticleGlowLayers( chip ) );
				p.Fill = tint.WithAlpha( opacity );
				p.Polygon( chip.Vertices );
				p.Polygon( [chip.Vertices[0], chip.Vertices[1], Vector2.Zero] );
				continue;
			}

			DrawShardFacets( p, chip.Vertices, color, tint, opacity, ParticleGlowLayers( chip ) );
			DrawShardShine( p, chip );
			DrawGemStroke( p, chip.Vertices, tint.WithAlpha( opacity * 0.45f ), 0.8f, true );
		}
	}

	static void DrawShardFacets( Painter p, Vector2[] vertices, Color color, Color tint, float opacity = 1, Vector2[][] glowLayers = null )
	{
		DrawParticleGlow( p, vertices, color, opacity * 0.12f, 1.25f, glowLayers );
		var shadow = new Color( color.r * 0.68f, color.g * 0.68f, color.b * 0.68f );
		var center = (vertices[0] + vertices[1] + vertices[2]) / 3;
		for ( int facet = 0; facet < 3; facet++ )
		{
			p.Fill = (facet == 0 ? tint : facet == 1 ? shadow : color).WithAlpha( opacity );
			p.Polygon( [vertices[facet], vertices[(facet + 1) % 3], center] );
		}
	}

	static void DrawParticleGlow( Painter p, ReadOnlySpan<Vector2> vertices, Color color, float opacity, float spread, Vector2[][] layers = null )
	{
		p.Stroke = Stroke.None;
		if ( layers is not null )
		{
			for ( int i = 0; i < layers.Length; i++ )
			{
				p.Fill = color.WithAlpha( opacity / (3 - i) );
				p.Polygon( layers[i] );
			}
			return;
		}
		// Tiny filled polygons use Painter's inline shape path. Stroked polygons
		// rebuild path primitives and a BVH for every layer of every particle.
		// Expand the convex triangle/quad along its face normals, retaining an
		// angular halo with the same three widths and falloff. Facets cover its core.
		Span<Vector2> corners = stackalloc Vector2[4];
		Span<Vector2> expanded = stackalloc Vector2[4];
		ParticleGlowCorners( vertices, corners );
		for ( int layer = 3; layer >= 1; layer-- )
		{
			for ( int i = 0; i < vertices.Length; i++ ) expanded[i] = vertices[i] + corners[i] * (layer * spread);
			p.Fill = color.WithAlpha( opacity / layer );
			p.Polygon( expanded[..vertices.Length] );
		}
	}

	static void ParticleGlowCorners( ReadOnlySpan<Vector2> vertices, Span<Vector2> corners )
	{
		float winding = 0;
		for ( int i = 0; i < vertices.Length; i++ )
		{
			var a = vertices[i]; var b = vertices[(i + 1) % vertices.Length];
			winding += a.x * b.y - a.y * b.x;
		}
		for ( int i = 0; i < vertices.Length; i++ )
		{
			var incoming = (vertices[i] - vertices[(i + vertices.Length - 1) % vertices.Length]).Normal;
			var outgoing = (vertices[(i + 1) % vertices.Length] - vertices[i]).Normal;
			var normal = new Vector2( incoming.y, -incoming.x ) * MathF.Sign( winding );
			var nextNormal = new Vector2( outgoing.y, -outgoing.x ) * MathF.Sign( winding );
			var bisector = (normal + nextNormal).Normal;
			corners[i] = bisector / MathF.Max( 0.5f, Vector2.Dot( bisector, normal ) );
		}
	}

	void DrawSmoke( Painter p )
	{
		if ( Debris is null ) return;
		p.Stroke = Stroke.None;
		foreach ( var puff in Debris.Smoke )
		{
			float t = Math.Clamp( puff.Age / puff.Lifetime, 0, 1 );
			float opacity = MathF.Min( 1, puff.Age / 0.035f ) * (1 - t) * (1 - t);
			float radius = puff.Radius * (1 + t * 1.4f);
			var center = new Vector2( Left, Top ) + puff.Position;
			// Soft, faint grey dust is composited last, over gems and chip fragments.
			for ( int layer = 4; layer >= 1; layer-- )
			{
				p.Fill = new Color( 0.70f, 0.74f, 0.78f ).WithAlpha( opacity * 0.05f );
				p.Circle( center, radius * layer / 4 );
			}
		}
	}

	const float LandingPointAppearTime = 0.14f;
	// Unrelated per-ray pulse rates (Hz), so the spokes never breathe in step.
	static readonly float[] LandingRayPulseRates = [1.31f, 2.07f, 1.63f, 2.53f, 1.12f, 1.87f];

	// Spoke core blur, widest and faintest first; stacked, the middle reaches about 92% opacity.
	[SkipHotload] static readonly (float Width, float Alpha)[] LandingCoreLayers = [(1.5f, 0.15f), (1.2f, 0.2f), (0.95f, 0.3f), (0.72f, 0.45f), (0.5f, 0.7f)];

	static float LandingRayLength( int ray ) => ray switch { 0 => 9, 1 => 5.5f, 2 => 7, 3 => 8, 4 => 5, _ => 6.5f };

	static void DrawLandingPointGlow( Painter p, LandingPointGlow glow, float scale )
	{
		if ( scale <= 0 ) return;
		using var scope = p.Scope();
		p.Translate( Position( glow.Contact.Lane, glow.Contact.Y ) );
		// Pop in from a quarter size with a slight ease-out-back overshoot; fading out keeps full size.
		float t = glow.Appear - 1;
		float grow = 1 + 2.2f * t * t * t + 1.2f * t * t;
		p.Scale( 1.6f * scale * (0.25f + 0.75f * grow) );
		var center = Vector2.Zero;
		var color = Palette[glow.Contact.ColorIndex];
		var tint = new Color( (color.r + 1) * 0.5f, (color.g + 1) * 0.5f, (color.b + 1) * 0.5f );
		p.Stroke = Stroke.None;
		p.Fill = Fill.RadialGradient()
			.WithStop( 0, tint.WithAlpha( 0.26f * glow.Opacity ) )
			.WithStop( 0.25f, tint.WithAlpha( 0.2f * glow.Opacity ) )
			.WithStop( 0.5f, tint.WithAlpha( 0.1f * glow.Opacity ) )
			.WithStop( 0.75f, tint.WithAlpha( 0.04f * glow.Opacity ) )
			.WithStop( 1, tint.WithAlpha( 0 ) );
		p.Circle( center, 13 );
		Span<float> pulses = stackalloc float[6], coreReach = stackalloc float[6];
		for ( int ray = 0; ray < 6; ray++ )
		{
			// Two detuned sines per ray give an irregular, subtle breathing.
			float phase = glow.PulsePhase * (ray + 1) * 1.618f;
			float w = glow.Age * LandingRayPulseRates[ray] * MathF.PI * 2;
			pulses[ray] = 1 + 0.1f * MathF.Sin( w + phase ) + 0.05f * MathF.Sin( w * 1.73f + phase * 0.5f );
			// The core's reach, as a fraction of its spoke, pulses on its own unrelated clock (about 0.45-0.75).
			float corePhase = glow.PulsePhase * (ray + 2) * 2.414f;
			float coreW = glow.Age * LandingRayPulseRates[5 - ray] * 1.37f * MathF.PI * 2;
			coreReach[ray] = 0.6f + 0.1f * MathF.Sin( coreW + corePhase ) + 0.05f * MathF.Sin( coreW * 1.61f + corePhase * 0.7f );
		}
		// Blurry white halos first: a soft radial gradient stretched into an ellipse along each spoke.
		var halo = Fill.RadialGradient()
			.WithStop( 0, Color.White.WithAlpha( 0.42f * glow.Opacity ) )
			.WithStop( 0.4f, Color.White.WithAlpha( 0.2f * glow.Opacity ) )
			.WithStop( 0.75f, Color.White.WithAlpha( 0.06f * glow.Opacity ) )
			.WithStop( 1, Color.White.WithAlpha( 0 ) );
		for ( int ray = 0; ray < 6; ray++ )
		{
			float angle = glow.Rotation + ray * MathF.PI / 3;
			float reach = LandingRayLength( ray ) * pulses[ray];
			float along = reach * 0.6f + 3, across = 3.4f * pulses[ray];
			using var haloScope = p.Scope();
			p.Translate( new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * reach * 0.45f );
			p.Rotate( angle * 180 / MathF.PI );
			p.Scale( 1, across / along );
			p.Fill = halo;
			p.Circle( Vector2.Zero, along );
		}
		p.Fill = tint.WithAlpha( glow.Opacity );
		for ( int ray = 0; ray < 6; ray++ )
		{
			float angle = glow.Rotation + ray * MathF.PI / 3;
			var direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			var side = new Vector2( -direction.y, direction.x );
			float pulse = pulses[ray];
			p.Polygon( [center + side * 1.2f * pulse, center + direction * LandingRayLength( ray ) * pulse, center - side * 1.2f * pulse] );
		}
		// A thin near-white core partway down each spoke, instead of a round hotspot in the middle. Layers
		// share the tip but widen toward the center, so the core is blurred inside and sharp at the point.
		var core = new Color( (tint.r + 3) * 0.25f, (tint.g + 3) * 0.25f, (tint.b + 3) * 0.25f );
		for ( int layer = 0; layer < LandingCoreLayers.Length; layer++ )
		{
			var (width, alpha) = LandingCoreLayers[layer];
			p.Fill = core.WithAlpha( alpha * glow.Opacity );
			for ( int ray = 0; ray < 6; ray++ )
			{
				float angle = glow.Rotation + ray * MathF.PI / 3;
				var direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
				var side = new Vector2( -direction.y, direction.x );
				float pulse = pulses[ray];
				p.Polygon( [center + side * width * pulse, center + direction * LandingRayLength( ray ) * coreReach[ray] * pulse, center - side * width * pulse] );
			}
		}
	}

	static void DrawDamageFlash( Painter p, DiamondBoard.DamageFlash flash )
		=> DrawSunburst( p, Position( flash.Lane, flash.Y ), flash.ColorIndex,
			flash.Age / DiamondBoard.DamageFlashDuration, flash.Rotation, flash.Brightness, flash.Scale );

	static void DrawSunburst( Painter p, Vector2 position, int colorIndex, float progress, float rotation, float brightness, float scale )
		=> DrawSunburst( p, position, Palette[colorIndex], progress, rotation, brightness, scale );

	static void DrawSunburst( Painter p, Vector2 position, Color color, float progress, float rotation, float brightness, float scale )
	{
		using var scope = p.Scope();
		p.Translate( position );
		p.Scale( scale );
		float t = Math.Clamp( progress, 0, 1 );
		float fade = (1 - t) * (1 - t);
		float radius = 17 + 28 * (1 - MathF.Pow( 1 - t, 3 ));
		// Lift the receiver's color toward white while retaining its hue. Brightness
		// is chosen once per burst so fading doesn't flicker between random shades.
		var tint = new Color( color.r + (1 - color.r) * brightness,
			color.g + (1 - color.g) * brightness, color.b + (1 - color.b) * brightness );
		p.Stroke = Stroke.None;
		for ( int layer = 3; layer >= 1; layer-- )
		{
			p.Fill = tint.WithAlpha( fade * 0.07f );
			p.Circle( Vector2.Zero, radius * layer * 0.45f );
		}
		// Fixed ray orientation throughout the expanding, fading burst.
		for ( int ray = 0; ray < 12; ray++ )
		{
			float angle = ray * MathF.PI / 6 + rotation;
			var direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			var side = new Vector2( -direction.y, direction.x );
			float length = radius * (ray % 3 == 0 ? 1 : ray % 2 == 0 ? 0.76f : 0.52f);
			float width = (ray % 3 == 0 ? 2.8f : 1.6f) * (1 - t * 0.6f);
			p.Fill = tint.WithAlpha( fade * 0.9f );
			p.Polygon( [side * width, direction * length, -side * width] );
		}
		p.Fill = new Color( (tint.r + 1) * 0.5f, (tint.g + 1) * 0.5f, (tint.b + 1) * 0.5f ).WithAlpha( fade );
		p.Circle( Vector2.Zero, 4.2f * (1 - t) );
	}

	static void DrawFaceFlash( Painter p, DiamondBoard.FaceFlash flash )
	{
		var face = flash.Contact;
		DrawFaceFlash( p, Position( face.StartLane, face.StartY ), Position( face.EndLane, face.EndY ),
			face.ColorIndex, flash.Age, flash.Seed, flash.Brightness, flash.DamageCount );
	}

	static void DrawFaceFlash( Painter p, Vector2 start, Vector2 end, int colorIndex,
		float age, float seed, float brightness, int damageCount )
		=> DrawFaceFlash( p, start, end, Palette[colorIndex], age, seed, brightness, damageCount );

	static void DrawFaceFlash( Painter p, Vector2 start, Vector2 end, Color color,
		float age, float seed, float brightness, int damageCount )
	{
		using var scope = p.Scope();
		var tangent = (end - start).Normal;
		var normal = new Vector2( -tangent.y, tangent.x );
		float t = Math.Clamp( age / DiamondBoard.DamageFlashDuration, 0, 1 );
		float fade = (1 - t) * (1 - t);
		float scale = 1 + 0.5f * Math.Max( 0, damageCount - 1 );
		var tint = new Color( color.r + (1 - color.r) * brightness,
			color.g + (1 - color.g) * brightness, color.b + (1 - color.b) * brightness );
		// Keep the seam anchored to the receiving face; only the escaping light grows.
		for ( int layer = 3; layer >= 1; layer-- )
		{
			p.Stroke = Stroke.Solid( tint.WithAlpha( fade * 0.10f ), layer * 4 * scale );
			p.Line( start, end );
		}
		p.Stroke = Stroke.None;
		p.Fill = tint.WithAlpha( fade * 0.9f );
		for ( int ray = 0; ray < 11; ray++ )
		{
			float along = (ray + 0.5f) / 11;
			var origin = start + (end - start) * along;
			for ( int side = -1; side <= 1; side += 2 )
			{
				// Each spoke fans out independently, with a fixed angle for its lifetime.
				float angle = MathF.Sin( seed * 1.37f + ray * 2.399f + side * 1.71f ) * 1.25f;
				var direction = normal * (side * MathF.Cos( angle )) + tangent * MathF.Sin( angle );
				var across = new Vector2( -direction.y, direction.x );
				float variation = 0.5f + 0.5f * MathF.Sin( seed + ray * 4.13f + side * 2.57f );
				float length = (13.5f + 31 * (1 - MathF.Pow( 1 - t, 3 ))) * (0.45f + variation * 0.55f) * scale;
				float width = (1.15f + variation * 1.15f) * (1 - t * 0.6f);
				p.Polygon( [origin - across * width, origin + direction * length, origin + across * width] );
			}
		}
		var core = new Color( (tint.r + 1) * 0.5f, (tint.g + 1) * 0.5f, (tint.b + 1) * 0.5f );
		p.Stroke = Stroke.Solid( core.WithAlpha( fade ), 2.8f * (1 - t) );
		p.Line( start, end );
	}

	static Vector2 Position( float lane, float y ) => new( Left + DiamondBoard.LaneX( lane ), Top + y );

	/// <summary>Draw the controlled piece trailing its lattice position while a step or turn animates.</summary>
	void DrawActivePiece( Painter p )
	{
		var piece = Board.Active;
		var trail = Board.ControlOffset;
		var anchor = Position( piece.Lane + trail.Lane, piece.Y + trail.Y ) + PieceShakeOffset;
		float turns = Board.ControlTurns;
		if ( MathF.Abs( turns ) < 0.0005f )
		{
			DrawShape( p, piece, anchor, false );
			return;
		}
		// Show the nearest whole rotation, turned the rest of the way on the lattice:
		// a rotation in half-width/half-height units, so each endpoint matches the
		// lattice exactly and the facets swap mid-turn while motion is fastest.
		int whole = (int)MathF.Round( turns );
		var shown = piece with { Rotation = (piece.Rotation - whole) & 3 };
		var pivotOffset = DiamondShapes.All[piece.ShapeIndex].Pivot;
		var pivot = anchor + new Vector2( pivotOffset.Lane * DiamondBoard.Width * 0.5f, pivotOffset.Row * DiamondBoard.Height * 0.5f );
		using var scope = p.Scope();
		p.Translate( pivot );
		p.Scale( DiamondBoard.Width * 0.5f, DiamondBoard.Height * 0.5f );
		p.Rotate( (whole - turns) * 90 );
		p.Scale( 2 / DiamondBoard.Width, 2 / DiamondBoard.Height );
		p.Translate( -pivot );
		DrawShape( p, shown, anchor, false );
	}

	void DrawShape( Painter p, DiamondBoard.Piece piece, Vector2 anchor, bool ghost, float scale = 1 )
	{
		if ( drawingReferenceGeometry )
		{
			var referenceCells = DiamondBoard.CellsOf( piece ).ToArray();
			foreach ( var cell in referenceCells )
				DrawDiamond( p, anchor + new Vector2( (cell.Lane - piece.Lane) * DiamondBoard.Width * 0.5f, cell.Y - piece.Y ) * scale,
					cell.ColorIndex, ghost, scale, health: cell.Health, crackCorner: cell.FirstCrack );
			DrawGroupOutlines( p, referenceCells, DiamondMatching.FindGroups( referenceCells ),
				anchor - new Vector2( DiamondBoard.LaneX( piece.Lane ), piece.Y ) * scale, scale, ghost ? 0.35f : 1, !ghost );
			return;
		}
		// Reuse cell/group storage while preserving the original layer bounds and pixel alignment.
		var cache = ghost ? ghostShapeGeometry : piece == Board.Next ? nextShapeGeometry : activeShapeGeometry;
		cache.Update( piece );
		var cells = cache.Cells;
		foreach ( var cell in cells )
			DrawDiamond( p, anchor + new Vector2( (cell.Lane - piece.Lane) * DiamondBoard.Width * 0.5f, cell.Y - piece.Y ) * scale,
				cell.ColorIndex, ghost, scale, health: cell.Health, crackCorner: cell.FirstCrack );
		DrawGroupOutlines( p, cells, cache.Groups,
			anchor - new Vector2( DiamondBoard.LaneX( piece.Lane ), piece.Y ) * scale, scale, ghost ? 0.35f : 1, !ghost,
			geometryCache: cache.Outlines );
	}

	void DrawGroupOutlines( Painter p, IReadOnlyList<DiamondBoard.Diamond> cells,
		IReadOnlyList<IReadOnlyList<int>> groups, Vector2 origin, float scale = 1, float opacity = 1, bool glow = true,
		IReadOnlyList<DiamondBoard.BondPulse> pulses = null, OutlineGeometryCache geometryCache = null )
	{
		if ( cells.Count == 0 || opacity <= 0 ) return;
		using var scope = p.Scope();
		p.Translate( origin );
		p.Scale( scale );
		// One filtered layer for the whole collection. Drawing coordinates inside
		// BeginLayer are local; the parent transform is applied when compositing.
		float minX = float.PositiveInfinity, maxX = float.NegativeInfinity;
		float minY = float.PositiveInfinity, maxY = float.NegativeInfinity;
		for ( int i = 0; i < cells.Count; i++ )
		{
			var cell = cells[i];
			float x = DiamondBoard.LaneX( cell.Lane );
			minX = MathF.Min( minX, x ); maxX = MathF.Max( maxX, x );
			minY = MathF.Min( minY, cell.Y ); maxY = MathF.Max( maxY, cell.Y );
		}
		minX -= DiamondBoard.Width * 0.5f + 20; maxX += DiamondBoard.Width * 0.5f + 20;
		minY -= DiamondBoard.Height * 0.5f + 20; maxY += DiamondBoard.Height * 0.5f + 20;
		using var layer = p.BeginLayer( new Rect( minX, minY, maxX - minX, maxY - minY ),
			opacity, new Painter.Filter { Blur = glow ? 3 : 0 } );
		p.Fill = Fill.None;
		geometryCache ??= ReferenceEquals( groups, Board?.BondedGroups ) ? settledOutlineGeometry : null;
		var geometry = !drawingReferenceGeometry && geometryCache is not null
			? geometryCache.Get( cells, groups ) : BuildOutlineGeometry( cells, groups );
		foreach ( var group in geometry )
		{
			if ( group is null ) continue;
			// Existing contour caches can survive hotload without the newly added offsets.
			group.Miters ??= Array.ConvertAll( group.Contours, c => BuildPerimeterMiters( c.Points, c.Closed ) );
			var firstEdge = group.FirstEdge with { X1 = group.FirstEdge.X1 + group.Offset.x, Y1 = group.FirstEdge.Y1 + group.Offset.y,
				X2 = group.FirstEdge.X2 + group.Offset.x, Y2 = group.FirstEdge.Y2 + group.Offset.y };
			float connection = 0;
			if ( pulses is not null )
				foreach ( var pulse in pulses )
					if ( pulse.Edges.Contains( firstEdge ) )
					{
						float t = Math.Clamp( pulse.Age / DiamondBoard.BondPulseDuration, 0, 1 );
						connection = MathF.Sin( MathF.PI * t ) * (1 - t);
						break;
					}
			float breathing = MathF.Sin( boundaryTime * MathF.PI * 2 / 3.6f );
			var color = Palette[group.FirstEdge.ColorIndex];
			float lift = glow ? 0.32f : 0;
			color = new Color( color.r + (1 - color.r) * lift,
				color.g + (1 - color.g) * lift, color.b + (1 - color.b) * lift );
			// A single continuous, softly filtered stroke: no stacked bands, end caps,
			// bright corner discs or detached rings. New bonds swell this same glow.
			for ( int i = 0; i < group.Contours.Length; i++ )
			{
				var contour = group.Contours[i];
				DrawPerimeter( p, contour.Points, group.Miters?[i], color.WithAlpha( glow ? 0.88f + breathing * 0.06f : 1 ),
					glow ? 4.5f + breathing * 0.25f + connection * 5 : 1, contour.Closed );
			}
		}
	}

	static Color GemColor( int colorIndex, int health )
	{
		var color = Palette[colorIndex];
		float brightness = health >= DiamondBoard.MaxHealth ? 1 : health == 2 ? 0.93f : 0.78f;
		return new Color( color.r * brightness, color.g * brightness, color.b * brightness );
	}

	void DrawDiamond( Painter p, Vector2 center, int colorIndex, bool ghost, float scale = 1, float opacity = 1, int health = DiamondBoard.MaxHealth, DiamondBoard.Corner crackCorner = DiamondBoard.Corner.Top )
	{
		// Ghosts and shatter fades have per-facet opacity, which must not be flattened.
		if ( !drawingReferenceGems && !drawingReferenceStrokes && !ghost && opacity == 1 && scale > 0 &&
			DrawAtlasGem( p, center, colorIndex, scale, health, crackCorner ) ) return;
		DrawDiamondVector( p, center, colorIndex, ghost, scale, opacity, health, crackCorner );
	}

	static void DrawDiamondVector( Painter p, Vector2 center, int colorIndex, bool ghost, float scale = 1, float opacity = 1, int health = DiamondBoard.MaxHealth, DiamondBoard.Corner crackCorner = DiamondBoard.Corner.Top )
	{
		using var scope = p.Scope();
		p.Translate( center );
		p.Scale( scale );
		Color color = GemColor( colorIndex, health );
		float w = DiamondBoard.Width * 0.5f;
		float h = DiamondBoard.Height * 0.5f;
		Vector2 top = new( 0, -h ), right = new( w, 0 ), bottom = new( 0, h ), left = new( -w, 0 );
		Vector2 middle = Vector2.Zero;

		// Every cut tiles the same diamond. Broad light/dark faces provide the visual
		// identity; there are no icons or marks floating above the gem surface.
		p.Fill = color.WithAlpha( opacity * (ghost ? 0.045f : 0.94f) );
		p.Stroke = Stroke.None;
		p.Quad( top, right, bottom, left );
		switch ( colorIndex )
		{
			case 0: // Classic: four facets meet at the center, retaining the original cut.
				Facet( p, color, 0.32f, opacity, ghost, [top, middle, left] );
				Facet( p, color, 0.02f, opacity, ghost, [top, right, middle] );
				Facet( p, color, -0.42f, opacity, ghost, [middle, right, bottom] );
				Facet( p, color, -0.12f, opacity, ghost, [left, middle, bottom] );
				break;
			case 1: // Bevel: a smaller diamond table surrounded by four broad bevels.
				Vector2 innerTop = top * 0.48f, innerRight = right * 0.48f;
				Vector2 innerBottom = bottom * 0.48f, innerLeft = left * 0.48f;
				Facet( p, color, 0.42f, opacity, ghost, [top, innerTop, innerLeft, left] );
				Facet( p, color, -0.12f, opacity, ghost, [top, right, innerRight, innerTop] );
				Facet( p, color, -0.48f, opacity, ghost, [right, bottom, innerBottom, innerRight] );
				Facet( p, color, -0.2f, opacity, ghost, [bottom, left, innerLeft, innerBottom] );
				Facet( p, color, 0.14f, opacity, ghost, [innerTop, innerRight, innerBottom, innerLeft] );
				break;
			case 2: // Table: a long, flat hexagonal face with clipped ends and narrow sides.
				Vector2 upperLeft = new( -w * 0.23f, -h * 0.54f ), upperRight = new( w * 0.23f, -h * 0.54f );
				Vector2 midLeft = new( -w * 0.5f, 0 ), midRight = new( w * 0.5f, 0 );
				Vector2 lowerLeft = new( -w * 0.23f, h * 0.54f ), lowerRight = new( w * 0.23f, h * 0.54f );
				Facet( p, color, 0.48f, opacity, ghost, [top, upperRight, upperLeft] );
				Facet( p, color, 0.24f, opacity, ghost, [top, upperLeft, midLeft, left] );
				Facet( p, color, -0.32f, opacity, ghost, [top, right, midRight, upperRight] );
				Facet( p, color, -0.12f, opacity, ghost, [left, midLeft, lowerLeft, bottom] );
				Facet( p, color, -0.48f, opacity, ghost, [right, bottom, lowerRight, midRight] );
				Facet( p, color, -0.25f, opacity, ghost, [bottom, lowerLeft, lowerRight] );
				Facet( p, color, 0.08f, opacity, ghost, [upperLeft, upperRight, midRight, lowerRight, lowerLeft, midLeft] );
				break;
			case 3: // Steps: three chevron ridges cut across a central spine.
				Vector2 a = new( -w * 0.5f, -h * 0.5f ), b = new( w * 0.5f, -h * 0.5f );
				Vector2 c = new( 0, -h * 0.2f ), d = new( 0, h * 0.35f );
				Vector2 e = new( -w * 0.5f, h * 0.5f ), f = new( w * 0.5f, h * 0.5f ), g = new( 0, h * 0.8f );
				Facet( p, color, 0.36f, opacity, ghost, [top, c, a] );
				Facet( p, color, -0.14f, opacity, ghost, [top, b, c] );
				Facet( p, color, -0.08f, opacity, ghost, [a, c, d, left] );
				Facet( p, color, -0.44f, opacity, ghost, [c, b, right, d] );
				Facet( p, color, 0.3f, opacity, ghost, [left, d, g, e] );
				Facet( p, color, -0.16f, opacity, ghost, [d, right, f, g] );
				Facet( p, color, -0.14f, opacity, ghost, [e, g, bottom] );
				Facet( p, color, -0.5f, opacity, ghost, [g, f, bottom] );
				break;
			case 4: // Fan: long triangular planes radiate from the top point.
				Vector2 fanLeft = (left + bottom) * 0.5f, fanRight = (right + bottom) * 0.5f;
				Facet( p, color, 0.32f, opacity, ghost, [top, fanLeft, left] );
				Facet( p, color, -0.34f, opacity, ghost, [top, bottom, fanLeft] );
				Facet( p, color, 0.12f, opacity, ghost, [top, fanRight, bottom] );
				Facet( p, color, -0.48f, opacity, ghost, [top, right, fanRight] );
				break;
		}

		if ( !ghost && health < DiamondBoard.MaxHealth ) DrawCracks( p, w, h, health, opacity, colorIndex, crackCorner );
		// The colored perimeter is drawn once around the bonded group after its facets.
	}

	static Vector2 CrackPoint( Vector2 point, float w, float h, float mirror, DiamondBoard.Corner corner )
	{
		point.x *= mirror;
		// Rotate in normalized gem coordinates before restoring its tall proportions.
		point = corner switch
		{
			DiamondBoard.Corner.Right => new( -point.y, point.x ),
			DiamondBoard.Corner.Bottom => new( -point.x, -point.y ),
			DiamondBoard.Corner.Left => new( point.y, -point.x ),
			_ => point
		};
		return new( point.x * w, point.y * h );
	}

	static void DrawCracks( Painter p, float w, float h, int health, float opacity, int colorIndex, DiamondBoard.Corner corner )
	{
		float mirror = colorIndex % 2 == 0 ? 1 : -1;
		Color crackColor = Color.Lerp( Palette[colorIndex], Color.Black, health == 1 ? 0.6f : 0.45f );
		float crackAlpha = health == 1 ? 0.94f : 0.88f;
		void Crack( Painter p, float w, float h, int health, float opacity, float mirror, DiamondBoard.Corner corner, Span<Vector2> path )
		{
			for ( int i = 0; i < path.Length; i++ ) path[i] = CrackPoint( path[i], w, h, mirror, corner );
			DrawGemStroke( p, path, crackColor.WithAlpha( opacity * crackAlpha ), health == 2 ? 2.2f : 3.3f, false );
			for ( int i = 0; i < path.Length; i++ ) path[i] += new Vector2( 1, -0.4f );
			DrawGemStroke( p, path, Color.White.WithAlpha( opacity * 0.55f ), 0.65f, false );
		}
		Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0, -1 ), new( 0.12f, -0.66f ), new( -0.10f, -0.43f ), new( 0.08f, -0.21f ), new( -0.12f, 0.06f ), new( 0.05f, 0.22f ) } );
		Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( -0.10f, -0.43f ), new( -0.31f, -0.29f ), new( -0.4f, -0.06f ) } );
		if ( health > 1 ) return;
		Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.05f, 0.22f ), new( 0.11f, 0.67f ) } );
		Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.05f, 0.22f ), new( 0.34f, -0.04f ), new( 0.55f, 0.12f ), new( 0.87f, 0.1f ) } );
		Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.34f, -0.04f ), new( 0.25f, -0.24f ), new( 0.55f, -0.4f ) } );
		Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.11f, 0.67f ), new( -0.12f, 0.7f ), new( 0, 0.98f ) } );
		p.Fill = crackColor.WithAlpha( opacity * 0.65f );
		p.Stroke = Stroke.None;
		p.Polygon( [CrackPoint( new( -0.12f, 0.06f ), w, h, mirror, corner ), CrackPoint( new( -0.04f, 0.13f ), w, h, mirror, corner ), CrackPoint( new( 0.05f, 0.22f ), w, h, mirror, corner )] );
	}

	static void Facet( Painter p, Color color, float light, float opacity, bool ghost, ReadOnlySpan<Vector2> vertices )
	{
		float amount = MathF.Abs( light );
		Color target = light >= 0 ? Color.White : Color.Black;
		Color face = new( color.r + (target.r - color.r) * amount,
			color.g + (target.g - color.g) * amount, color.b + (target.b - color.b) * amount );
		p.Fill = ghost ? Fill.None : face.WithAlpha( opacity * 0.96f );
		p.Stroke = Stroke.None;
		p.Polygon( vertices );
		DrawGemStroke( p, vertices, (ghost ? color : Color.Black).WithAlpha( opacity * (ghost ? 0.2f : 0.28f) ), 0.8f, true );
	}
	static void Text( Painter p, string text, float x, float y, float width, float height, float size, Color color, bool center = false, string fontName = "Roboto" )
	{
		p.TextStyle = new TextStyle { FontName = fontName, FontSize = size, Color = color, Alignment = center ? TextFlag.Center : TextFlag.LeftTop };
		p.Text( text, new Rect( x, y, width, height ) );
	}
}