Editor/RigControlEditor/RigObjMeshes.cs
using Effigy;
using Marionette;
using Marionette.EditorTools;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;

namespace Marionette.Tools;

/// <summary>
/// Wavefront OBJ files, turned into viewport models, one per lump of the file.
///
/// WHY MARIONETTE READS OBJ AT ALL. A clip is a scene: the hand, the thing it is opening, and the
/// bits of that thing that move separately. Until now every one of those had to be a compiled
/// .vmdl asset, so animating against a mesh somebody exported from Blender meant compiling a model
/// per piece before you could even see whether the grip lined up. An OBJ is the format every tool
/// writes, it carries its own object/group markers, and Effigy already has a reader that splits on
/// them - so a file that was exported as a door, a handle and a hinge arrives as three objects that
/// can be posed and keyed separately, which is the whole point.
///
/// CACHED BY PATH AND BY PIECE. A prop is rebuilt whenever the list changes, and re-reading and
/// re-triangulating a 60k-triangle file on each of those would make editing the list feel broken.
/// The cache is keyed by the resolved absolute path, so two props out of one file share one parse.
///
/// NOTHING HERE COMPILES AN ASSET. The model is built in memory for the viewport, exactly the way
/// Effigy previews its own meshes. That is deliberate: an import must not write .vmdl files into
/// somebody's project as a side effect of being looked at.
/// </summary>
internal static class RigObjMeshes
{
	private static readonly Dictionary<string, List<ObjReader.ObjPiece>> _files = new();
	private static readonly Dictionary<string, Model> _models = new();

	/// <summary>
	/// Where an OBJ actually is on disk.
	///
	/// A clip stores the path relative to itself when the file was copied in beside it, and an
	/// absolute path when it was not - so both are tried, relative first. Relative first because
	/// that is the copy the clip owns: if a project is moved wholesale, the absolute path is the
	/// one that has gone stale.
	/// </summary>
	public static string Resolve( string source, string documentFolder )
	{
		if ( string.IsNullOrWhiteSpace( source ) )
			return null;

		if ( !string.IsNullOrWhiteSpace( documentFolder ) )
		{
			var beside = Path.GetFullPath( Path.Combine( documentFolder, source ) );

			if ( File.Exists( beside ) )
				return beside;
		}

		return File.Exists( source ) ? Path.GetFullPath( source ) : null;
	}

	/// <summary>Every lump in the file, in the order the exporter wrote them. Empty when the file
	/// is missing or unreadable - the caller reports that, since only it knows which prop asked.</summary>
	public static IReadOnlyList<ObjReader.ObjPiece> Pieces( string absolutePath )
	{
		if ( string.IsNullOrWhiteSpace( absolutePath ) )
			return Array.Empty<ObjReader.ObjPiece>();

		var key = Key( absolutePath );

		if ( _files.TryGetValue( key, out var cached ) )
			return cached;

		List<ObjReader.ObjPiece> pieces;

		try
		{
			pieces = ObjReader.ReadPieces( File.ReadAllText( absolutePath ) );
		}
		catch ( Exception e )
		{
			// A bad file is a thing to say out loud once, not to throw out of a paint loop that
			// would then run again next frame and throw again.
			Log.Warning( $"[Marionette] could not read {absolutePath}: {e.Message}" );
			pieces = new List<ObjReader.ObjPiece>();
		}

		_files[key] = pieces;
		return pieces;
	}

	/// <summary>
	/// One lump of the file as a viewport model.
	///
	/// An empty part name means the whole file - a single-object OBJ, or one an exporter wrote with
	/// no markers at all, which the reader hands back as one unnamed piece. Matching an empty name
	/// against the first piece rather than refusing is what lets those files be imported without
	/// the person doing it having to know whether their exporter bothered.
	/// </summary>
	public static Model Load( string absolutePath, string part )
	{
		if ( string.IsNullOrWhiteSpace( absolutePath ) )
			return null;

		var key = $"{Key( absolutePath )}|{part}";

		if ( _models.TryGetValue( key, out var cached ) )
			return cached;

		var pieces = Pieces( absolutePath );
		PolyMesh mesh = null;

		foreach ( var piece in pieces )
		{
			if ( !string.IsNullOrEmpty( part ) && piece.Name != part )
				continue;

			mesh = piece.Mesh;
			break;
		}

		// Named a piece the file does not have - the exporter's names changed, or the file was
		// replaced with a different one. Nothing to draw; the prop reports it.
		var model = mesh is null ? null : EffigyPreview.Build( mesh );

		_models[key] = model;
		return model;
	}

	/// <summary>Drop a file from the cache so the next look re-reads it. Called after an import,
	/// so re-importing a file you have just re-exported shows the new mesh rather than the one
	/// this session happened to read first.</summary>
	public static void Forget( string absolutePath )
	{
		if ( string.IsNullOrWhiteSpace( absolutePath ) )
			return;

		var key = Key( absolutePath );

		_files.Remove( key );

		foreach ( var modelKey in new List<string>( _models.Keys ) )
		{
			if ( modelKey.StartsWith( key + "|", StringComparison.Ordinal ) )
				_models.Remove( modelKey );
		}
	}

	/// <summary>Windows paths differ by case and by slash without differing at all, and a cache
	/// that missed on that would parse the same file twice and hand out two models for it.</summary>
	private static string Key( string path ) => path.Replace( '\\', '/' ).ToLowerInvariant();
}