Effigy/PaintReplay.cs
using System;
using System.Collections.Generic;

namespace Effigy;

/// <summary>
/// The texel bounds a dab touched, so the live session can recompose and re-upload only what a dab
/// changed rather than the whole canvas. A value type with no allocation, because a stroke fires
/// thousands of dabs and a class here would be thousands of objects a second.
/// </summary>
public readonly struct TexelBounds
{
	public readonly bool Any;
	public readonly int MinX, MinY, MaxX, MaxY;

	public TexelBounds( int minX, int minY, int maxX, int maxY )
	{
		Any = true;
		MinX = minX;
		MinY = minY;
		MaxX = maxX;
		MaxY = maxY;
	}

	public TexelBounds With( int x, int y ) => Any
		? new TexelBounds( Math.Min( MinX, x ), Math.Min( MinY, y ), Math.Max( MaxX, x ), Math.Max( MaxY, y ) )
		: new TexelBounds( x, y, x, y );
}

/// <summary>
/// Replays paint strokes onto a canvas — the derived-artifact half of the storage decision in
/// docs/dev/PAINTING.md §4.
///
/// Paint is stored as strokes in object space, not as texels, so a rebuilt or re-unwrapped mesh gets
/// its paint re-applied from scratch rather than pasted through an atlas that moved under it. This is
/// the re-applier: for each stroke it drops the brush sphere onto the surface and rasterises whatever
/// faces the sphere touches, weighting every texel by its 3D distance from the dab centre. Neither
/// the brush nor the rasteriser ever learns what a seam is — a stroke crossing a chart boundary
/// paints both charts because both have faces inside the sphere.
///
/// A STROKE IS COMPOSITED ONCE, NOT PER DAB. The vertex-colour path blended every dab straight into
/// the result, so a held brush kept compositing the same coverage over itself and the colour "ticked
/// up" like a sculpt brush instead of laying down like a paint brush. Here each stroke's dabs are
/// first stamped into a per-stroke COVERAGE buffer (the maximum falloff any dab reached at each
/// texel, never the sum), and then that one buffer is composited into the canvas. Holding still
/// re-stamps the same coverage and changes nothing.
///
/// THE DAB IS SHARED, NOT DUPLICATED. <see cref="PaintSession"/> composites a live dab through
/// <see cref="StampDab"/>; a rebuild replays the whole list through <see cref="Replay"/>. Both paths
/// end at the same rasterise-and-stamp, so a stroke painted live and the same stroke replayed later
/// produce identical texels.
///
/// AN ERASE IS A STROKE LIKE ANY OTHER, and takes the identical route: same dabs, same coverage
/// buffer, same once-per-stroke application. Only the last step differs — <see cref="Apply"/> sends a
/// painting stroke to <see cref="Composite"/> and an erasing one to <see cref="Erase"/>. That is why
/// erases and paint interleave correctly without any ordering machinery: they are the same log.
/// </summary>
public static class PaintReplay
{
	/// <summary>How many texels a replayed canvas bleeds outward at its islands' edges, so a shader
	/// filtering across a seam finds colour there instead of the gutter. Same figure the bake uses.</summary>
	const int DilatePasses = 4;

	/// <summary>
	/// Replay every stroke, in order, onto a fresh canvas. The order is the whole point — colour
	/// blending does not commute — and this preserves it exactly, compositing each stroke once from
	/// its own coverage buffer so dabs within a stroke never accumulate.
	/// </summary>
	public static PaintCanvas Replay( PolyMesh mesh, IReadOnlyList<PaintStroke> strokes, int resolution )
	{
		if ( mesh is null )
			throw new ArgumentNullException( nameof( mesh ) );

		if ( resolution < 1 )
			throw new ArgumentOutOfRangeException( nameof( resolution ) );

		var canvas = new PaintCanvas( resolution, resolution );

		if ( strokes is { Count: > 0 } )
		{
			var bvh = MeshBVH.Build( mesh );
			var faces = new List<int>();
			var coverage = new float[resolution * resolution];

			// Which texels the log last spoke about with an ERASE, so the dilate below leaves them
			// alone. Without this an erased hole inside an island is indistinguishable from the
			// gutter outside one, and the dilate bleeds the paint straight back into it — see
			// PaintCanvas.Dilate. Allocated only when something actually erases, because the
			// overwhelmingly common document has no erases in it at all.
			bool[] erased = null;

			foreach ( var stroke in strokes )
			{
				Array.Clear( coverage, 0, coverage.Length );
				StampStroke( stroke, mesh, bvh, coverage, resolution, faces );
				Apply( canvas, stroke, coverage );

				if ( stroke.Erase )
					erased ??= new bool[resolution * resolution];

				if ( erased is not null )
					MarkErased( erased, coverage, stroke.Erase );
			}

			canvas.Dilate( DilatePasses, erased );
		}

		return canvas;
	}

	/// <summary>
	/// Apply one stroke's finished coverage to a canvas: paint composites its colour in, an erase
	/// takes coverage back out of the alpha.
	///
	/// THE ONE PLACE THAT BRANCH IS MADE. The replay, the live session's commit and its reload all
	/// end here, so there is no route by which a stroke painted by hand and the same stroke replayed
	/// later could disagree about which of the two it was.
	/// </summary>
	internal static void Apply( PaintCanvas canvas, PaintStroke stroke, float[] coverage )
	{
		if ( stroke.Erase )
			Erase( canvas, coverage );
		else
			Composite( canvas, coverage, ToByte( stroke.R ), ToByte( stroke.G ), ToByte( stroke.B ) );
	}

	/// <summary>Note which texels this stroke touched, and whether it was an erase that touched them.
	/// LAST WRITER WINS, because the strokes are a log: painting over an erased texel makes it
	/// painted again, and erasing a painted one makes it deliberately empty.</summary>
	static void MarkErased( bool[] erased, float[] coverage, bool erasing )
	{
		for ( var i = 0; i < erased.Length; i++ )
		{
			if ( coverage[i] > 0f )
				erased[i] = erasing;
		}
	}

	/// <summary>Stamp one stroke's dabs into a coverage buffer, taking the maximum. The live session
	/// uses this to seed its coverage from the stroke in flight, so it must not composite anything —
	/// the composite is the caller's step, once, at the end.</summary>
	internal static void StampStroke( PaintStroke stroke, PolyMesh mesh, MeshBVH bvh,
		float[] coverage, int resolution, List<int> faces )
	{
		if ( stroke is null || stroke.Path.Count == 0 )
			return;

		// Coverage folds the stroke's own opacity into its strength, so the alpha the composite sees
		// is "how much paint arrives here" and the colour has no alpha of its own to double-count.
		var coverageScale = stroke.Strength * stroke.A;

		foreach ( var point in stroke.Path )
			StampDab( mesh, bvh, coverage, resolution, point.Position, point.Normal,
				stroke.Radius, coverageScale, stroke.Falloff, faces );
	}

	/// <summary>
	/// One dab into a coverage buffer: the brush sphere at <paramref name="point"/>, rasterised into
	/// the faces it reaches, each texel's coverage raised to the dab's weight where that is higher.
	/// Returns the texel bounds it actually touched, so the live session can recompose and re-upload
	/// only that region.
	///
	/// THE 3D FOOTPRINT IS THE WHOLE DESIGN. A 2D disc in UV space cannot know a seam exists; a sphere
	/// in object space touches faces on both sides of one and paints them both for free. The normal
	/// gate is the other half of the same idea — a dab on one side of a thin wall must not bleed
	/// through to the far face, which is rejected by comparing its normal to the recorded surface
	/// normal.
	///
	/// MAX, NOT SUM, is what makes a stroke lay down like paint. Two dabs of one stroke overlapping
	/// must not darken each other — the coverage is the strongest the brush pressed anywhere, and the
	/// stroke is composited from that once.
	/// </summary>
	internal static TexelBounds StampDab( PolyMesh mesh, MeshBVH bvh, float[] coverage, int resolution,
		Vec3 point, Vec3 normal, float radius, float strength, BrushFalloff falloff, List<int> faces )
	{
		if ( radius <= 0f || strength <= 0f )
			return default;

		bvh.FacesInRadius( mesh, point, radius, faces );

		var n = normal.LengthSquared >= 0.5f ? normal.Normal : new Vec3( 0, 0, 1 );
		var bounds = default( TexelBounds );

		foreach ( var fi in faces )
		{
			var face = mesh.Faces[fi];

			if ( face.Count < 3 || face.UVs is null || face.UVs.Length != face.Count )
				continue;

			// The far side of a thin wall points away from the brush; a face exactly perpendicular to
			// it belongs to the neighbouring face's own dab, not this one.
			if ( Vec3.Dot( mesh.FaceNormal( face ), n ) <= 0f )
				continue;

			var corners = new List<Vec3>( face.Count );

			for ( var c = 0; c < face.Count; c++ )
				corners.Add( mesh.Positions[face.Indices[c]] );

			foreach ( var (ia, ib, ic) in Triangulate.Face( corners ) )
			{
				var p0 = corners[ia];
				var p1 = corners[ib];
				var p2 = corners[ic];

				var u0 = face.UVs[ia];
				var u1 = face.UVs[ib];
				var u2 = face.UVs[ic];

				NormalBake.Rasterise( u0, u1, u2, resolution, resolution, ( x, y, wa, wb, wc ) =>
				{
					// The barycentrics Rasterise hands back let a texel's 3D position be rebuilt from
					// its three corners, so the falloff can be measured in object space rather than in
					// UV space — the same distance that made the brush one consistent size everywhere.
					var p = p0 * wa + p1 * wb + p2 * wc;
					var t = (p - point).Length / radius;
					var weight = Brush.Falloff( t, falloff ) * strength;

					if ( weight <= 0f )
						return;

					var index = y * resolution + x;

					if ( weight > coverage[index] )
					{
						coverage[index] = weight;
						bounds = bounds.With( x, y );
					}
				} );
			}
		}

		return bounds;
	}

	/// <summary>Composite a coverage buffer into a canvas once, at the stroke's colour. The coverage
	/// is the stroke's per-texel alpha, already capped at the maximum any of its dabs reached.</summary>
	internal static void Composite( PaintCanvas canvas, float[] coverage, byte r, byte g, byte b )
	{
		for ( var y = 0; y < canvas.Height; y++ )
		{
			for ( var x = 0; x < canvas.Width; x++ )
			{
				var weight = coverage[y * canvas.Width + x];

				if ( weight > 0f )
					canvas.Blend( x, y, r, g, b, weight );
			}
		}
	}

	/// <summary>Take a coverage buffer back out of a canvas's alpha, once — the mirror of
	/// <see cref="Composite"/>. Colour is left alone; see PaintCanvas.Erase for why straight alpha
	/// means an erase is entirely a statement about how much paint is there.</summary>
	internal static void Erase( PaintCanvas canvas, float[] coverage )
	{
		for ( var y = 0; y < canvas.Height; y++ )
		{
			for ( var x = 0; x < canvas.Width; x++ )
			{
				var weight = coverage[y * canvas.Width + x];

				if ( weight > 0f )
					canvas.Erase( x, y, weight );
			}
		}
	}

	/// <summary>A float colour channel to a byte, the same rounding the canvas blend uses.</summary>
	internal static byte ToByte( float v ) => (byte)Math.Clamp( MathF.Round( v * 255f ), 0f, 255f );
}