Effigy.Tests/TentacleGen.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Effigy;

namespace Effigy.Tests;

/// <summary>
/// A tentacle, built the way the pipeline says to build one: a lofted CAD cage, a multires sculpt
/// on top of it for the suckers, then a bone chain skinned to the result.
///
/// It stands straight up the +Z axis, so it is a rig test as much as a model - a straight chain
/// with no bend in the bind pose is the one that shows immediately whether the weights fall off
/// along the length or bunch at a joint.
///
/// The suckers are sculpted rather than modelled. They are surface detail on a smooth tube: as
/// geometry in the cage they would multiply its face count for no parametric gain, and every
/// downstream edit to the taper would have to drag them along.
///
/// Invoked as: Effigy.Tests.exe --tentacle [outDir]
/// </summary>
public static class TentacleGen
{
	/// <summary>Overall length, in s&amp;box units (inches). Six feet of arm.</summary>
	const float Height = 72f;

	const float BaseRadius = 10f;
	const float TipRadius = 2f;

	/// <summary>
	/// Cross-sections up the length. Each one is a Sketch feature the loft skins through.
	///
	/// It is 40 rather than the dozen the taper needs because the sculpt is what sets this number,
	/// not the shape. A dozen sections put quads on the cage seven units tall and two wide, and a
	/// round sucker brushed onto a grid that anisotropic comes out a rectangle - the subdivision
	/// inherits the cage's proportions however deep it goes.
	/// </summary>
	const int Sections = 40;

	/// <summary>Points each section is resampled to - the loop count around the tube. Chosen with
	/// <see cref="Sections"/> so the cage's quads are about square at the base.</summary>
	const int Segments = 24;

	/// <summary>
	/// Multires levels the sculpt adds over the cage. Each one is 4x the faces; 2 over a cage this
	/// dense is about a dozen vertices across a sucker, which is what it takes for one to read as a
	/// cup rather than a dimple.
	/// </summary>
	const int SculptLevels = 2;

	const int BoneCount = 8;

	public static int Run( string outDir )
	{
		Directory.CreateDirectory( outDir );

		var studio = new PartStudio();
		studio.MaterialNames[0] = "models/effigy/tentacle.vmat";

		var loft = BuildCage( studio );

		var uv = studio.Add( new UVProjectFeature() );
		uv.Name = "tentacle_uv";
		uv.Bodies.BodyIds.Add( loft.Id + "b0" );
		uv.Mode.Index = 0; // Box
		uv.Scale.Value = 32f;

		var sculpt = studio.Add( new SculptFeature() );
		sculpt.Name = "suckers";
		sculpt.Bodies.BodyIds.Add( loft.Id + "b0" );

		// The sculpt needs a cage before it has anything to add levels to, and the cage does not
		// exist until the loft has run. So: build once, sculpt, build again.
		var report = studio.Rebuild();
		PrintErrors( studio, report, "cage" );

		if ( report.HasErrors )
			return 1;

		var cage = studio.Bodies[0].Mesh.Clone();
		Console.WriteLine( $"cage {cage.VertexCount}v {cage.FaceCount}f" );

		Sculpt( sculpt.Sculpt );

		report = studio.Rebuild();
		PrintErrors( studio, report, "sculpt" );

		if ( report.HasErrors )
			return 1;

		var mesh = studio.ToMesh();
		Console.WriteLine( $"sculpted {mesh.VertexCount}v {mesh.FaceCount}f, {MeshValidator.Validate( mesh )}" );

		var skeleton = BuildSkeleton();
		mesh.Skin = SkinBinder.SmoothWeights(
			mesh,
			SkinBinder.BindSmooth( mesh, skeleton ),
			iterations: 2,
			strength: 0.4f );

		Console.WriteLine( $"bones {skeleton.Count}, rigged {mesh.IsRigged}" );

		// The rig leaves here through DmxWriter and then through the engine's compiler, which
		// reports a bad one as a failure in a tool nobody was looking at. Ask before that.
		foreach ( var problem in RigDiagnostics.Check( skeleton, mesh ) )
			Console.WriteLine( $"  rig {problem}" );

		var dmx = Path.Combine( outDir, "tentacle.dmx" );
		var vmdl = Path.Combine( outDir, "tentacle.vmdl" );
		var doc = Path.Combine( outDir, "tentacle.effigy" );
		var obj = Path.Combine( outDir, "tentacle.obj" );

		DmxWriter.WriteFile( mesh, dmx, skeleton, modelName: "tentacle",
			materialName: studio.NameForSlot );
		File.WriteAllText( vmdl, SkinnedVmdl( "models/effigy/tentacle.dmx", skeleton, studio, mesh ) );
		ObjWriter.WriteFile( mesh, obj, "tentacle" );
		WriteSkin( outDir );

		StudioDocument.WriteFile( studio, doc );
		var blobs = SculptSidecar.Save( studio, doc );

		// Feature ids are new on every run, so yesterday's blobs are orphans in the same folder.
		// Pruning is destructive and deliberately not part of saving; here the document is the whole
		// reason that folder exists, so it is safe and the side-car stays one sculpt big.
		SculptSidecar.Prune( studio, doc );

		Reopen( doc, mesh.VertexCount );

		PngPreview.WriteSheet( new[]
		{
			new PngPreview.Tile( cage, "cage" ),
			new PngPreview.Tile( cage, "cage wire", wireframe: true ),
			new PngPreview.Tile( Facing( mesh ), "sculpted" ),
			new PngPreview.Tile( Facing( Slice( mesh, Height * 0.05f, Height * 0.3f ) ), "suckers, close up" ),
			new PngPreview.Tile( BoneCage( skeleton ), "bones", wireframe: true ),
		}, Path.Combine( outDir, "tentacle_preview.png" ), columns: 4, tileSize: 460 );

		Console.WriteLine( "DMX " + dmx );
		Console.WriteLine( "VMDL " + vmdl );
		Console.WriteLine( $"EFFIGY {doc} ({blobs} sculpt blob(s))" );
		Console.WriteLine( "DONE" );
		return 0;
	}

	/// <summary>Radius at a height fraction. A fast taper near the tip, a slight swell low down,
	/// so it reads as an arm rather than as a cone.</summary>
	static float RadiusAt( float t )
	{
		var taper = TipRadius + ( BaseRadius - TipRadius ) * MathF.Pow( 1f - t, 0.70f );
		return taper * ( 1f + 0.10f * MathF.Sin( t * MathF.PI ) );
	}

	static LoftFeature BuildCage( PartStudio studio )
	{
		var sections = new List<string>( Sections );

		for ( var i = 0; i < Sections; i++ )
		{
			var t = i / (float)( Sections - 1 );

			var s = studio.Add( new SketchFeature() );
			s.Name = $"section_{i:00}";
			s.Plane.Index = 0; // Top (XY), so the loft runs up +Z
			s.PlaneOffset.Value = t * Height;
			s.Sketch.AddCircle( Vec2.Zero, RadiusAt( t ) );
			sections.Add( s.Id );
		}

		var loft = studio.Add( new LoftFeature() );
		loft.Name = "tentacle";
		loft.Sections = sections;
		loft.Segments.Value = Segments;
		loft.Result.Index = 1; // New body
		loft.Material.Value = 0;
		return loft;
	}

	/// <summary>
	/// The sculpt: two staggered rows of suckers up the front, a few muscle bulges, and a smooth
	/// pass over the tip.
	///
	/// Every stroke is one dab - a BrushStroke with a single sample. The editor's session turns a
	/// mouse drag into a run of them; nothing here needs a drag, and one dab per sucker is what
	/// makes each sucker independently placed rather than smeared along a path.
	/// </summary>
	static void Sculpt( MultiresSculpt sculpt )
	{
		for ( var i = 0; i < SculptLevels; i++ )
			sculpt.AddLevel();

		var level = sculpt.TopLevel;
		Console.WriteLine( $"sculpt level {level}, {sculpt.Cost( level ).Vertices}v" );

		var suckers = 0;
		var moved = 0;
		var side = 1f;

		// Stops where the arm is thinner than the brush is wide, which is well short of the tip: a
		// dab wider than the tube reaches round the far side and tears it rather than denting it.
		for ( var z = Height * 0.06f; RadiusAt( z / Height ) > 2.4f; )
		{
			var t = z / Height;
			var r = RadiusAt( t );
			var size = Math.Clamp( r * 0.62f, 0.9f, 3.4f );

			// +-24 degrees off the front, alternating, which is the double row an octopus has.
			var angle = side * 24f * MathF.PI / 180f;
			var n = new Vec3( MathF.Cos( angle ), MathF.Sin( angle ), 0f );
			var p = n * r + new Vec3( 0, 0, z );

			// A sucker STANDS OUT of the arm - it is a collar with a hole in it, not a dent. So:
			// a pad pushed proud of the surface, then a pit punched through its middle deep enough
			// to end below the skin. Only the second dab is a hollow, and it is what turns the pad
			// into a ring.
			//
			// The pit uses CONSTANT falloff, which moves everything inside the radius equally and
			// so leaves a floor with a wall around it. A smooth falloff there sinks a soft dimple
			// and the sucker reads as a thumbprint.
			// ORDER MATTERS, and it is the order a thumb would work in: build the pad, round it
			// off, and only then push the hole in. Smoothing after the pit is what fills the pit
			// back in - a wide smooth pass does not know the difference between the stair on a
			// wall and the wall.
			//
			// The pit is aimed at the TOP of the pad, not at where the skin used to be. A brush
			// takes the vertices within a radius of a point in space, and the pad has just moved
			// them a pad's height away from that point: aim the pit where the skin was and it
			// reaches nothing, which looks exactly like the dab having no effect.
			var pad = size * 0.55f;
			var crown = p + n * pad;

			moved += Dab( sculpt, level, BrushKind.Draw, p, n, size, pad, BrushFalloff.Smooth );
			moved += Dab( sculpt, level, BrushKind.Smooth, crown, n, size * 1.15f, 0.35f, BrushFalloff.Sharp );
			moved += Dab( sculpt, level, BrushKind.Draw, crown, n, size * 0.45f, -size * 0.85f, BrushFalloff.Constant );
			moved += Dab( sculpt, level, BrushKind.Smooth, crown, n, size * 0.60f, 0.18f, BrushFalloff.Sharp );

			suckers++;
			side = -side;
			z += MathF.Max( 1.6f, size * 0.95f );
		}

		// Muscle: a few soft inflations down the back, off the sucker rows, so the silhouette is
		// not a clean revolve.
		for ( var i = 0; i < 6; i++ )
		{
			var t = 0.08f + i * 0.14f;
			var r = RadiusAt( t );
			var angle = MathF.PI + ( i % 2 == 0 ? 0.6f : -0.6f );
			var n = new Vec3( MathF.Cos( angle ), MathF.Sin( angle ), 0f );
			moved += Dab( sculpt, level, BrushKind.Inflate, n * r + new Vec3( 0, 0, t * Height ), n,
				r * 1.6f, r * 0.09f, BrushFalloff.Smooth );
		}

		// The tip takes the taper's last section badly - smooth it back to a point.
		moved += Dab( sculpt, level, BrushKind.Smooth, new Vec3( 0, 0, Height ), new Vec3( 0, 0, 1 ),
			TipRadius * 6f, 0.7f, BrushFalloff.Smooth );

		Console.WriteLine( $"  {suckers} suckers, {moved} vertices moved, revision {sculpt.Revision}" );
	}

	static int Dab( MultiresSculpt sculpt, int level, BrushKind kind, Vec3 position, Vec3 normal,
		float radius, float strength, BrushFalloff falloff )
	{
		var stroke = new BrushStroke { Kind = kind, Falloff = falloff };
		stroke.Samples.Add( new BrushSample( position, normal, radius, strength ) );
		return sculpt.Stroke( level, stroke ).Count;
	}

	/// <summary>A chain up the axis. Bone 0 sits at the base and is the root.</summary>
	static Skeleton BuildSkeleton()
	{
		var s = new Skeleton();
		var parent = -1;

		for ( var i = 0; i < BoneCount; i++ )
		{
			var head = new Vec3( 0, 0, Height * i / BoneCount );
			var tail = new Vec3( 0, 0, Height * ( i + 1 ) / BoneCount );
			parent = s.AddBoneFromPoints( $"tentacle_{i + 1:00}", parent, head, tail );
		}

		return s;
	}

	/// <summary>
	/// Read the document back the way the editor opens it, and check the model it rebuilds is the
	/// one just exported. A sculpt lives in a side-car keyed on feature ids, which is the part of
	/// saving that can silently not happen - the .effigy would open to a smooth cone, and nothing
	/// anywhere would say why.
	/// </summary>
	static void Reopen( string doc, int expectedVertices )
	{
		var studio = StudioDocument.ReadFile( doc );
		var loaded = SculptSidecar.Load( studio, doc );
		var report = studio.Rebuild();
		var mesh = studio.ToMesh();
		var ok = !report.HasErrors && loaded == 1 && mesh.VertexCount == expectedVertices;

		Console.WriteLine( $"reopened: {loaded} blob(s), {mesh.VertexCount}v, {report} - "
			+ ( ok ? "matches" : "DOES NOT MATCH" ) );
	}

	/// <summary>
	/// A skin for it: mottled flesh, generated rather than painted, with a vmat beside the model
	/// pointing at it. Next to the .vmdl rather than under materials/ so the whole model is one
	/// folder, which is what a generated asset wants to be.
	/// </summary>
	static void WriteSkin( string outDir )
	{
		const int n = 512;
		var pixels = new byte[n * n * 3];

		for ( var y = 0; y < n; y++ )
		for ( var x = 0; x < n; x++ )
		{
			var i = ( y * n + x ) * 3;
			var u = x / (float)n;
			var v = y / (float)n;

			// Big soft blotches, fine speckle over the top, and a wash down one end so the tube
			// does not read as one flat colour under a static light.
			var blotch = Noise( u * 5f, v * 5f );
			var speckle = Noise( u * 40f, v * 40f );
			var shade = 0.60f + 0.30f * blotch + 0.10f * speckle - 0.14f * v;

			pixels[i] = Byte( shade * 0.86f );
			pixels[i + 1] = Byte( shade * 0.46f );
			pixels[i + 2] = Byte( shade * 0.50f );
		}

		PngWriter.WriteFile( Path.Combine( outDir, "tentacle_color.png" ), pixels, n, n );
		File.WriteAllText( Path.Combine( outDir, "tentacle.vmat" ), Vmat() );
	}

	static string Vmat() =>
		"Layer0\n{\n"
		+ "\tshader \"shaders/complex.shader\"\n"
		+ "\tg_flModelTintAmount \"0.000000\"\n"
		+ "\tg_vColorTint \"[1.000000 1.000000 1.000000 1.000000]\"\n"
		+ "\tTextureColor \"models/effigy/tentacle_color.png\"\n"
		+ "\tTextureNormal \"materials/default/default_normal.tga\"\n"
		+ "\tTextureRoughness \"materials/default/default_rough.tga\"\n"
		+ "\tTextureAmbientOcclusion \"materials/default/default_ao.tga\"\n"
		+ "\tg_flMetalness \"0.000000\"\n"
		+ "\tg_flRoughness \"0.320000\"\n"
		+ "}\n";

	static byte Byte( float v ) => (byte)( Math.Clamp( v, 0f, 1f ) * 255f );

	/// <summary>Value noise, the same one TreeGen uses for bark - enough for a mottle.</summary>
	static float Noise( float x, float y )
	{
		var x0 = (int)MathF.Floor( x );
		var y0 = (int)MathF.Floor( y );
		var fx = x - x0;
		var fy = y - y0;
		fx = fx * fx * ( 3f - 2f * fx );
		fy = fy * fy * ( 3f - 2f * fy );
		var a = Hash( x0, y0 );
		var b = Hash( x0 + 1, y0 );
		var c = Hash( x0, y0 + 1 );
		var d = Hash( x0 + 1, y0 + 1 );
		return a + ( b - a ) * fx + ( c - a ) * fy + ( a - b - c + d ) * fx * fy;
	}

	static float Hash( int x, int y )
	{
		unchecked
		{
			var h = x * 374761393 + y * 668265263;
			h = ( h ^ ( h >> 13 ) ) * 1274126177;
			return ( h & 0x7fffffff ) / 2147483647f;
		}
	}

	/// <summary>
	/// A copy turned about Z so the sucker rows face the preview's fixed camera. Pictures only -
	/// the model itself keeps its suckers on +X.
	/// </summary>
	static PolyMesh Facing( PolyMesh mesh )
	{
		var turned = mesh.Clone();
		var spin = Xform.Rotate( new Vec3( 0, 0, 1 ), -125f * MathF.PI / 180f );

		for ( var i = 0; i < turned.Positions.Count; i++ )
			turned.Positions[i] = spin.TransformPoint( turned.Positions[i] );

		return turned;
	}

	/// <summary>
	/// The faces whose centre sits in a height band, as a mesh of their own. Preview only - it
	/// keeps no topology beyond the faces themselves, so it is a picture, not a model.
	/// </summary>
	static PolyMesh Slice( PolyMesh mesh, float zMin, float zMax )
	{
		var slice = new PolyMesh();

		foreach ( var face in mesh.Faces )
		{
			var centre = Vec3.Zero;

			foreach ( var vi in face.Indices )
				centre += mesh.Positions[vi];

			centre /= face.Indices.Length;

			if ( centre.z < zMin || centre.z > zMax )
				continue;

			slice.AddFace( face.Indices.Select( vi => slice.AddVertex( mesh.Positions[vi] ) ).ToArray() );
		}

		return slice;
	}

	/// <summary>The skeleton as a mesh, purely so the preview sheet can show it.</summary>
	static PolyMesh BoneCage( Skeleton skeleton )
	{
		var mesh = new PolyMesh();

		for ( var i = 0; i < skeleton.Count; i++ )
		{
			var head = skeleton.HeadWorld( i );
			var tail = skeleton.TailWorld( i );
			var w = ( tail - head ).Length * 0.18f;
			var a = mesh.AddVertex( head + new Vec3( -w, 0, 0 ) );
			var b = mesh.AddVertex( head + new Vec3( w, 0, 0 ) );
			var c = mesh.AddVertex( tail + new Vec3( 0, w, 0 ) );
			var d = mesh.AddVertex( tail + new Vec3( 0, -w, 0 ) );
			mesh.AddFace( new[] { a, b, c, d } );
		}

		return mesh;
	}

	static void PrintErrors( PartStudio studio, RebuildReport report, string when )
	{
		Console.WriteLine( $"rebuild {when}: {report}" );

		foreach ( var f in studio.Features.Where( f => f.Error is not null ) )
			Console.WriteLine( $"  ERROR {f.Name}: {f.Error}" );

		foreach ( var ( id, msg ) in report.Errors )
			Console.WriteLine( $"  report {id}: {msg}" );
	}

	// The template lives in VmdlDocument now. This used to be a hand-copied duplicate of
	// EffigyWindow.BuildSkinnedVmdl, and copies are how the OBJ import_rotation correction
	// went missing everywhere except the one place it was written.
	static string SkinnedVmdl( string meshFilename, Skeleton skeleton, PartStudio studio, PolyMesh mesh ) =>
		VmdlDocument.Skinned( meshFilename, skeleton,
			VmdlPhysics.MeshFromRender( "flesh" ),
			VmdlMaterials.GroupList( studio, mesh ) );
}