Editor/Effigy/VmdlDocument.cs
using System;

namespace Effigy;

/// <summary>
/// The .vmdl ModelDoc reads, built in one place.
///
/// WHY THIS IS A FILE AND NOT A STRING LITERAL IN THE EXPORTER. There were four copies of this
/// template — EffigyWindow.BuildVmdl, EffigyWindow.BuildSkinnedVmdl, TreeGen and TentacleGen —
/// and only the first one carried the import_rotation correction below. The other three said
/// [0, 0, 0], which is right for DMX and catastrophically wrong for OBJ, and nothing about a
/// copied literal tells you which case you are in. Anything outside the editor window that wanted
/// a model — the remesh harness, a generator — had no way to ask for a correct one, so it
/// hand-made a vmdl in ModelDoc instead and got the rotation wrong the same way.
///
/// THE ROTATION IS DERIVED FROM THE MESH FILE, not passed in, because that is the actual rule:
/// it is the OBJ importer specifically that turns the mesh, so the extension decides. See
/// <see cref="ImportRotation"/>. A caller cannot get it wrong by forgetting to pass it.
/// </summary>
public static class VmdlDocument
{
	const string Header =
		"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} "
		+ "format:modeldoc29:version{3cec427c-1b0e-4d48-a90a-0436f33a6041} -->\n";

	/// <summary>
	/// What to put in import_rotation for a mesh file, as the three numbers of the kv3 array.
	///
	/// ModelDoc's OBJ importer does not land the mesh in the coordinates the file gives it. It
	/// reads the file as Y-up (the OBJ convention) and then turns it another quarter turn, so the
	/// whole thing arrives cyclically permuted:
	///
	///     engine.x = obj.z    engine.y = obj.x    engine.z = obj.y
	///
	/// The kernel is Z-up — its sketch planes are named "Top (XY)", "Front (XZ)", "Right (YZ)" —
	/// so this is TWO errors stacked. A bare -90 yaw undoes the extra turn and leaves the Y-up
	/// reading in place, landing the mesh at (obj.x, -obj.z, obj.y): a part drawn lying flat comes
	/// out standing on its side. [-90, -90, 0] is the full inverse of the permutation above and
	/// puts the mesh back in the coordinates the file was written in.
	///
	/// MEASURED. A two-box part whose OBJ bounds are 155 x 159 x 84 compiled to 84 x 155 x 159 at
	/// rotation zero — the permutation, read straight off the numbers — and to 155 x 159 x 84 at
	/// [-90, -90, 0], with the bar still pointing along +x and the raised lip still on top, so this
	/// is the identity and not some other transform that happens to share its bounds.
	///
	/// This matters most for collision. Physics shapes are written in kernel coordinates and
	/// import_rotation does not touch them, so the mesh has to arrive in kernel coordinates too,
	/// or the collision sits at an angle to the model it belongs to.
	///
	/// DMX AND SMD MUST NOT GET THIS. Only the OBJ importer turns the mesh; the DMX importer lands
	/// it where the file says. Applying the correction there would break the rigged path, which is
	/// why this is decided per file rather than set once.
	/// </summary>
	public static string ImportRotation( string meshFilename ) =>
		meshFilename is not null
		&& meshFilename.EndsWith( ".obj", StringComparison.OrdinalIgnoreCase )
			? "-90.0, -90.0, 0.0"
			: "0.0, 0.0, 0.0";

	/// <summary>The RenderMeshList node — one RenderMeshFile pointing at the mesh on disk.</summary>
	static string RenderMeshList( string meshFilename ) =>
		"\t\t\t{\n"
		+ "\t\t\t\t_class = \"RenderMeshList\"\n"
		+ "\t\t\t\tchildren = \n"
		+ "\t\t\t\t[\n"
		+ "\t\t\t\t\t{\n"
		+ "\t\t\t\t\t\t_class = \"RenderMeshFile\"\n"
		+ "\t\t\t\t\t\tname = \"Body_LOD0\"\n"
		+ "\t\t\t\t\t\tchildren = \n"
		+ "\t\t\t\t\t\t[\n"
		+ "\t\t\t\t\t\t]\n"
		+ $"\t\t\t\t\t\tfilename = \"{meshFilename}\"\n"
		+ "\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]\n"
		+ $"\t\t\t\t\t\timport_rotation = [ {ImportRotation( meshFilename )} ]\n"
		+ "\t\t\t\t\t\timport_scale = 1.0\n"
		+ "\t\t\t\t\t\talign_origin_x_type = \"None\"\n"
		+ "\t\t\t\t\t\talign_origin_y_type = \"None\"\n"
		+ "\t\t\t\t\t\talign_origin_z_type = \"None\"\n"
		+ "\t\t\t\t\t\tparent_bone = \"\"\n"
		+ "\t\t\t\t\t},\n"
		+ "\t\t\t\t]\n"
		+ "\t\t\t},\n";

	static string Wrap( string children, string animGraph = "" ) =>
		Header
		+ "{\n"
		+ "\trootNode = \n"
		+ "\t{\n"
		+ "\t\t_class = \"RootNode\"\n"
		+ "\t\tchildren = \n"
		+ "\t\t[\n"
		+ children
		+ "\t\t]\n"
		+ "\t\tmodel_archetype = \"\"\n"
		+ "\t\tprimary_associated_entity = \"\"\n"
		+ $"\t\tanim_graph_name = \"{animGraph}\"\n"
		+ "\t\tbase_model_name = \"\"\n"
		+ "\t}\n"
		+ "}\n";

	/// <summary>
	/// A static model: one mesh file, whatever PhysicsShapeList and MaterialGroupList the caller
	/// built, and no skeleton.
	///
	/// MATERIALS MATTER AS MUCH AS COLLISION. An omitted MaterialGroupList is what ModelDoc fills
	/// in with use_global_default = true and materials/default.vmat — a part that rendered in the
	/// viewport with the materials that were dropped on it compiles as a blank grey prop. Pass an
	/// empty list with the global default off to leave the mesh's own names in place.
	/// </summary>
	public static string Static( string meshFilename, string physics = "", string materials = "" ) =>
		Wrap( materials + RenderMeshList( meshFilename ) + physics );

	/// <summary>
	/// Citizen's animation graph. A skinned model that names this, and carries citizen's bones
	/// under citizen's names in citizen's bind pose, is a playermodel - the graph drives it exactly
	/// as it drives citizen. Nothing validates the pairing: naming the graph on a model with its
	/// own skeleton compiles and then animates into a knot, which is why
	/// <see cref="SkeletonRetarget"/> exists.
	/// </summary>
	public const string CitizenAnimGraph = "models/citizen/citizen.vanmgrph";

	/// <summary>
	/// A skinned model: the mesh file carries the bone hierarchy, bind pose and per-vertex weights,
	/// so this adds the bone markup and the animation list on top of the static shape.
	/// </summary>
	/// <param name="meshFilename">The mesh on disk, project-relative. Its extension decides the
	/// import rotation — see <see cref="ImportRotation"/>.</param>
	/// <param name="skeleton">The bones the mesh file carries, for the bone markup node.</param>
	/// <param name="physics">The PhysicsShapeList node, or empty for no collision.</param>
	/// <param name="materials">The MaterialGroupList node.</param>
	/// <param name="animations">The AnimationList node. Null or empty means the bind pose alone —
	/// <see cref="VmdlAnimation.AnimationList"/> with no clips is byte-identical to
	/// <see cref="VmdlAnimation.BindPoseList"/>, and a test holds that, so the no-clips path is
	/// unchanged rather than merely equivalent.</param>
	/// <param name="animGraph">An animation graph to drive the model, project-relative — see
	/// <see cref="CitizenAnimGraph"/>. Empty, the default, leaves the model animated only by
	/// whatever clips it carries, which is what every Effigy export wanted before playermodels.</param>
	public static string Skinned( string meshFilename, Skeleton skeleton, string physics = "",
		string materials = "", string animations = null, string animGraph = "" ) =>
		Wrap( materials
			+ RenderMeshList( meshFilename )
			+ VmdlAnimation.BoneMarkupList( skeleton )
			+ (string.IsNullOrEmpty( animations ) ? VmdlAnimation.BindPoseList() : animations)
			+ physics, animGraph );
}