Editor/Effigy/SculptReprojection.cs
using System;

namespace Effigy;

/// <summary>What a reprojection managed, so the caller can say so rather than guess.</summary>
public sealed class ReprojectionReport
{
	public readonly int Vertices;
	public readonly int Hit;
	public readonly float MaxDistance;

	public ReprojectionReport( int vertices, int hit, float maxDistance )
	{
		Vertices = vertices;
		Hit = hit;
		MaxDistance = maxDistance;
	}

	/// <summary>How much of the new surface found the old one. Below about a half means the two
	/// shapes have little to do with each other and the result is not worth keeping.</summary>
	public float Coverage => Vertices == 0 ? 0f : (float)Hit / Vertices;

	public override string ToString() =>
		$"{Hit} of {Vertices} vertices found the old surface ({Coverage:P0}), searching {MaxDistance:0.###} either side";
}

/// <summary>
/// Moving a sculpt onto a cage it was not made on.
///
/// THE LAST RESORT, AND IT IS MEANT TO BE. Deltas are per vertex, so a cage whose topology changed
/// has no vertex to put them on and <see cref="MultiresSculpt.SetCage"/> refuses. That refusal is
/// right nearly always: the usual cause is an upstream edit the user did not mean, and undoing it
/// brings the sculpt back exactly. This is for the other case — the edit WAS meant, the old sculpt
/// is worth more than nothing, and the honest offer is an approximation clearly labelled as one.
///
/// The method is the plan's: build the new cage's levels empty, then for each vertex of the top
/// level fire a ray along its own normal and move it onto the old sculpted surface. What comes back
/// is the old shape resampled at the new cage's density.
///
/// WHAT IS LOST, stated up front so it is not discovered later:
///
/// - Detail finer than the new cage can carry. Resampling cannot invent vertices.
/// - The level structure. Everything lands in the top level as one displacement, so going back down
///   to L1 afterwards no longer shows the coarse shape with the detail riding it — there is no
///   coarse shape any more, just the top level and the cage. Sculpting at a lower level still works;
///   it just starts from a flat lower level.
/// - Anything the rays missed. A new cage that reaches somewhere the old surface never was leaves
///   those vertices where they are, which is the cage's own shape and the right answer.
/// </summary>
public static class SculptReprojection
{
	/// <summary>
	/// Resample <paramref name="old"/>'s sculpted surface onto <paramref name="newCage"/>.
	///
	/// <paramref name="levels"/> defaults to the old sculpt's, which keeps the cost the user already
	/// chose. <paramref name="maxDistance"/> of zero derives a search range from the cage's size.
	/// </summary>
	public static MultiresSculpt Reproject( MultiresSculpt old, PolyMesh newCage, out ReprojectionReport report,
		int levels = -1, float maxDistance = 0f )
	{
		if ( old is null )
			throw new ArgumentNullException( nameof( old ) );

		if ( newCage is null )
			throw new ArgumentNullException( nameof( newCage ) );

		if ( levels < 0 )
			levels = old.TopLevel;

		if ( levels < 0 )
			throw new ArgumentOutOfRangeException( nameof( levels ) );

		var source = old.Evaluate( old.TopLevel );
		var bvh = MeshBVH.Build( source );

		var result = new MultiresSculpt( newCage );

		for ( var i = 0; i < levels; i++ )
			result.AddLevel();

		var target = result.Rest( levels );
		var normals = target.ComputeVertexNormals();
		var reach = maxDistance > 0f ? maxDistance : DefaultReach( newCage );
		var hits = 0;

		for ( var i = 0; i < target.VertexCount; i++ )
		{
			var normal = normals[i];

			if ( normal.LengthSquared < 0.5f )
				continue;

			// Fired from outside inward, like the bake, so the nearest hit is the surface facing
			// this vertex rather than whatever is behind it. A vertex that finds nothing is left
			// exactly where the new cage put it, which is the correct answer for a part of the model
			// the old sculpt never covered.
			var hit = bvh.Raycast( source, target.Positions[i] + normal * reach, -normal );

			if ( hit is null || hit.Value.Distance > reach * 2f )
				continue;

			target.Positions[i] = hit.Value.Point;
			hits++;
		}

		result.Record( levels, target );
		result.ViewLevel = levels;

		report = new ReprojectionReport( target.VertexCount, hits, reach );
		return result;
	}

	/// <summary>A tenth of the cage's diagonal, the same figure the bake uses and for the same
	/// reason: far enough for ordinary relief, near enough that a ray rarely reaches an unrelated
	/// part of the model.</summary>
	static float DefaultReach( PolyMesh cage )
	{
		var diagonal = cage.BoundsDiagonal;
		return diagonal > 1e-6f ? diagonal * 0.1f : 1f;
	}
}