Editor/Effigy/SculptBlob.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;

namespace Effigy;

/// <summary>
/// A sculpt's deltas, as bytes.
///
/// WHY THIS IS NOT IN THE .effigy FILE. Every other piece of a feature is a handful of numbers and
/// goes in the text document, which diffs and which a human can repair. A sculpt is not: at L4 on a
/// 500-face cage it is 128k vertices per level, and written as text it would be tens of megabytes of
/// decimal digits sitting in the middle of a file whose whole virtue is being readable. So the
/// document keeps the feature — its id, its body selection, its place in the history — and the
/// deltas go beside it in a blob keyed by that feature id.
///
/// SIXTEEN BITS PER COMPONENT, AGAINST A PER-LEVEL BOUNDING BOX. Deltas are frame-space coefficients
/// scaled by local edge length, so within one level they occupy a small, similar range and a shared
/// box wastes almost nothing. Six bytes a vertex is the budget the plan was written against: L4 on a
/// 500-face cage is ~750 KB per level. A float32 delta would be twice that for precision far below
/// what a 16-bit step buys — on a bump of one edge length the step is about 3e-5 of an edge, which is
/// nowhere near visible and nowhere near the tolerance anything downstream cares about.
///
/// The box is stored per level rather than globally because levels differ by orders of magnitude:
/// L1 carries the shape, L4 carries pores, and one shared box would spend most of its range on the
/// level that does not need it.
/// </summary>
public static class SculptBlob
{
	/// <summary>Eight bytes so a file can be identified without parsing it.</summary>
	static readonly byte[] Magic = Encoding.ASCII.GetBytes( "EFFIGYSC" );

	/// <summary>Bumped when the layout changes. Refused by name rather than by crash, like the
	/// document format's own version.</summary>
	public const int Version = 1;

	public const string Extension = ".sculpt";

	/// <summary>Bytes per vertex per level: three components, two bytes each.</summary>
	public const int BytesPerVertex = 6;

	const int Steps = 65535;

	/// <summary>
	/// Serialise every level's deltas. Little-endian, via BinaryWriter, which is little-endian on
	/// every runtime this ships to — noted rather than assumed, because a blob is the one thing here
	/// that outlives the process that wrote it.
	/// </summary>
	public static byte[] Write( MultiresSculpt sculpt )
	{
		if ( sculpt is null )
			throw new ArgumentNullException( nameof( sculpt ) );

		using var stream = new MemoryStream();
		using var w = new BinaryWriter( stream, Encoding.ASCII, leaveOpen: true );

		w.Write( Magic );
		w.Write( Version );
		w.Write( MultiresSculpt.TopologyId( sculpt.Cage ) );
		w.Write( sculpt.LevelCount );

		for ( var level = 0; level < sculpt.LevelCount; level++ )
			WriteLayer( w, sculpt.LayerAt( level ) );

		w.Flush();
		return stream.ToArray();
	}

	static void WriteLayer( BinaryWriter w, SculptLayer layer )
	{
		var deltas = layer.Deltas;
		var min = new Vec3( float.MaxValue, float.MaxValue, float.MaxValue );
		var max = new Vec3( float.MinValue, float.MinValue, float.MinValue );

		foreach ( var d in deltas )
		{
			min = new Vec3( MathF.Min( min.x, d.x ), MathF.Min( min.y, d.y ), MathF.Min( min.z, d.z ) );
			max = new Vec3( MathF.Max( max.x, d.x ), MathF.Max( max.y, d.y ), MathF.Max( max.z, d.z ) );
		}

		// An empty level has no box at all. Writing zeroes keeps the record fixed-size, and a zero
		// range reconstructs exactly, which is what an untouched level has to do — a level nobody
		// sculpted must come back bit-identical rather than merely close.
		if ( deltas.Length == 0 )
		{
			min = Vec3.Zero;
			max = Vec3.Zero;
		}

		w.Write( deltas.Length );
		Write( w, min );
		Write( w, max );

		var range = max - min;

		foreach ( var d in deltas )
		{
			w.Write( Quantise( d.x, min.x, range.x ) );
			w.Write( Quantise( d.y, min.y, range.y ) );
			w.Write( Quantise( d.z, min.z, range.z ) );
		}
	}

	static ushort Quantise( float value, float min, float range )
	{
		if ( range <= 0f )
			return 0;

		var t = (value - min) / range;
		return (ushort)Math.Clamp( MathF.Round( t * Steps ), 0f, Steps );
	}

	static float Dequantise( ushort q, float min, float range ) => range <= 0f ? min : min + range * q / Steps;

	/// <summary>
	/// Rebuild a sculpt from its bytes onto a cage.
	///
	/// The cage is required because everything except the deltas is derived — rest surfaces, frames,
	/// the lot — so the blob does not store any of it and cannot be read without one. That is the
	/// same decision as storing deltas in a derived frame, one level up: what is derivable is never
	/// written, so it can never be stale.
	/// </summary>
	public static MultiresSculpt Read( byte[] bytes, PolyMesh cage )
	{
		if ( bytes is null )
			throw new ArgumentNullException( nameof( bytes ) );

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

		using var stream = new MemoryStream( bytes, writable: false );
		using var r = new BinaryReader( stream, Encoding.ASCII, leaveOpen: true );

		if ( bytes.Length < Magic.Length + 16 )
			throw new InvalidOperationException( "This is not a sculpt blob — it is too short to hold a header." );

		var magic = r.ReadBytes( Magic.Length );

		for ( var i = 0; i < Magic.Length; i++ )
		{
			if ( magic[i] != Magic[i] )
				throw new InvalidOperationException( "This is not a sculpt blob — it does not start with EFFIGYSC." );
		}

		var version = r.ReadInt32();

		if ( version > Version )
			throw new InvalidOperationException(
				$"This sculpt was written by a newer build (format {version}; this one reads {Version})." );

		var topology = r.ReadInt64();
		var cageTopology = MultiresSculpt.TopologyId( cage );

		if ( topology != cageTopology )
			throw new InvalidOperationException(
				"This sculpt was made on a different cage. Deltas are stored per vertex, so they cannot "
				+ "be placed on this one. Undo the feature edit that changed the cage's topology, or "
				+ "re-sculpt on the new cage." );

		var levels = r.ReadInt32();

		if ( levels < 1 )
			throw new InvalidOperationException( $"A sculpt has at least the cage level; this blob claims {levels}." );

		var sculpt = new MultiresSculpt( cage );

		for ( var level = 0; level < levels; level++ )
		{
			// Add the level BEFORE reading into it: a level's vertex count depends on the levels
			// below it being displaced first, which is only true once their deltas are in place.
			if ( level > 0 )
				sculpt.AddLevel();

			var expected = sculpt.LayerAt( level ).Count;
			var layer = ReadLayer( r, level, expected );
			sculpt.SetLayer( level, layer );
		}

		sculpt.ViewLevel = sculpt.TopLevel;
		return sculpt;
	}

	static SculptLayer ReadLayer( BinaryReader r, int level, int expected )
	{
		var count = r.ReadInt32();

		if ( count != expected )
			throw new InvalidOperationException(
				$"Level {level} of this sculpt has {count} vertices but the cage produces {expected} there. "
				+ "The blob and the cage do not belong to each other." );

		var min = ReadVec( r );
		var max = ReadVec( r );
		var range = max - min;
		var deltas = new Vec3[count];

		for ( var i = 0; i < count; i++ )
		{
			deltas[i] = new Vec3(
				Dequantise( r.ReadUInt16(), min.x, range.x ),
				Dequantise( r.ReadUInt16(), min.y, range.y ),
				Dequantise( r.ReadUInt16(), min.z, range.z ) );
		}

		return new SculptLayer( deltas );
	}

	static void Write( BinaryWriter w, Vec3 v )
	{
		w.Write( v.x );
		w.Write( v.y );
		w.Write( v.z );
	}

	static Vec3 ReadVec( BinaryReader r ) => new( r.ReadSingle(), r.ReadSingle(), r.ReadSingle() );

	/// <summary>What <see cref="Write"/> will produce, without producing it — for a UI that wants to
	/// say what a level costs on disk before the user commits to it.</summary>
	public static int PredictBytes( MultiresSculpt sculpt )
	{
		if ( sculpt is null )
			throw new ArgumentNullException( nameof( sculpt ) );

		var total = Magic.Length + sizeof( int ) + sizeof( long ) + sizeof( int );

		for ( var level = 0; level < sculpt.LevelCount; level++ )
			total += sizeof( int ) + 6 * sizeof( float ) + sculpt.LayerAt( level ).Count * BytesPerVertex;

		return total;
	}
}

/// <summary>
/// Where the blobs live next to a document.
///
/// One directory beside the .effigy file, one file per sculpt feature, named by that feature's id.
/// Keyed by id rather than by position so re-ordering the history, renaming the feature or deleting
/// the one above it does not shuffle anybody's sculpt onto the wrong feature.
///
/// Saving does not delete blobs it did not write. A file whose feature is gone from the document is
/// the cheapest possible undo of "I deleted the sculpt feature and saved", and the cost of keeping
/// it is a stale file rather than somebody's afternoon. <see cref="Prune"/> exists for when that is
/// actually wanted, and has to be asked for by name.
/// </summary>
public static class SculptSidecar
{
	/// <summary>`model.effigy` keeps its blobs in `model.sculpt/`.</summary>
	public static string DirectoryFor( string documentPath )
	{
		if ( string.IsNullOrWhiteSpace( documentPath ) )
			throw new ArgumentException( "A document path is needed to find its sculpt blobs.", nameof( documentPath ) );

		var dir = Path.GetDirectoryName( documentPath ) ?? "";
		return Path.Combine( dir, Path.GetFileNameWithoutExtension( documentPath ) + SculptBlob.Extension );
	}

	public static string PathFor( string documentPath, string featureId ) =>
		Path.Combine( DirectoryFor( documentPath ), featureId + ".bin" );

	/// <summary>Write a blob for every sculpt feature that has one. Returns how many it wrote.</summary>
	public static int Save( PartStudio studio, string documentPath )
	{
		if ( studio is null )
			throw new ArgumentNullException( nameof( studio ) );

		var pending = new List<(string Id, byte[] Bytes)>();

		foreach ( var feature in studio.Features )
		{
			if ( feature is not SculptFeature sculpt )
				continue;

			var bytes = sculpt.SaveDeltas();

			if ( bytes is not null )
				pending.Add( (feature.Id, bytes) );
		}

		if ( pending.Count == 0 )
			return 0;

		var dir = DirectoryFor( documentPath );
		Directory.CreateDirectory( dir );

		foreach ( var (id, bytes) in pending )
			File.WriteAllBytes( PathFor( documentPath, id ), bytes );

		return pending.Count;
	}

	/// <summary>
	/// Hand each sculpt feature its blob. The bytes are held unread until the next rebuild, because
	/// a blob cannot be turned into a sculpt without the cage and the cage does not exist until the
	/// features above have run.
	/// </summary>
	public static int Load( PartStudio studio, string documentPath )
	{
		if ( studio is null )
			throw new ArgumentNullException( nameof( studio ) );

		var dir = DirectoryFor( documentPath );

		if ( !Directory.Exists( dir ) )
			return 0;

		var loaded = 0;

		foreach ( var feature in studio.Features )
		{
			if ( feature is not SculptFeature sculpt )
				continue;

			var path = PathFor( documentPath, feature.Id );

			if ( !File.Exists( path ) )
				continue;

			sculpt.LoadDeltas( File.ReadAllBytes( path ) );
			loaded++;
		}

		return loaded;
	}

	/// <summary>Delete blobs no feature in this studio claims. Destructive, so it is never part of
	/// saving — see the note on this class.</summary>
	public static int Prune( PartStudio studio, string documentPath )
	{
		if ( studio is null )
			throw new ArgumentNullException( nameof( studio ) );

		var dir = DirectoryFor( documentPath );

		if ( !Directory.Exists( dir ) )
			return 0;

		var keep = new HashSet<string>( StringComparer.Ordinal );

		foreach ( var feature in studio.Features )
		{
			if ( feature is SculptFeature )
				keep.Add( feature.Id + ".bin" );
		}

		var removed = 0;

		foreach ( var path in Directory.GetFiles( dir, "*.bin" ) )
		{
			if ( keep.Contains( Path.GetFileName( path ) ) )
				continue;

			File.Delete( path );
			removed++;
		}

		return removed;
	}
}