Editor/EffigyEditor/EffigyPreview.cs
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
namespace Marionette.EditorTools;
/// <summary>
/// Turns the Part Studio's in-memory PolyMesh straight into a runtime Model, so the viewport can
/// show the result of a feature the moment it is added.
///
/// The alternative — the path Export/Compile still take — is OBJ on disk, a generated .vmdl, and
/// a call into the asset compiler. That takes hundreds of milliseconds and writes files, which is
/// fine for producing a placeable prop and hopeless as the response to dragging a slider. This
/// path never touches the disk.
///
/// It is deliberately NOT a second geometry pipeline: it shares MeshNormals with ObjWriter, so
/// what you see here and what the compiler bakes are smoothed by the same rule.
///
/// MATERIALS RENDER HERE NOW. A face carries a slot number and PartStudio.MaterialNames binds the
/// slot to a vmat, so the preview groups faces by the material they resolve to and builds one
/// submesh per material — the same grouping the exporters express as usemtl runs. Faces on slot 0,
/// on a slot nothing is bound to, or on a slot whose vmat will not load fall back to the flat
/// placeholder rather than rendering as nothing. Pass no resolver — the sculpt preview does — and
/// the whole model is the placeholder, which is the old behaviour.
/// </summary>
internal static class EffigyPreview
{
/// <summary>
/// FLAT grey, with no pattern on it at all. The fallback for a face whose slot names no
/// material, or names one that will not load.
///
/// It used to be dev/reflectivity_30, and that material actively lies about scale: its texture
/// is a grid with the number "30" printed in every tile - the material's reflectivity, nothing
/// to do with size - and caps take plane coordinates straight through as UVs, so it tiles once
/// per sketch unit. A 30x30 face therefore came out covered in thirty-odd squares each labelled
/// "30", which reads as a part 900 units across. gray_50's texture is a single flat colour.
/// </summary>
private const string PreviewMaterial = "materials/dev/gray_50.vmat";
/// <summary>
/// Build a Model from the mesh, optionally resolving each face's material slot to a real vmat.
/// </summary>
/// <param name="materialForSlot">Slot number to bound material path, or null / empty for an
/// unbound slot. Pass null to render everything on the placeholder.</param>
public static Model Build( PolyMesh mesh, Func<int, string> materialForSlot = null,
float smoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees )
{
if ( mesh is null || mesh.FaceCount == 0 || mesh.VertexCount == 0 )
return null;
var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );
var placeholder = Material.Load( PreviewMaterial );
var bounds = BoundsOf( mesh );
// Faces bucketed by the material they render with. One drop of brushed steel onto three
// faces is one bucket and one submesh; a part nobody assigned is one bucket on the
// placeholder. The slot is resolved once and cached, not once per face.
var buckets = new Dictionary<Material, List<int>>();
var slotCache = new Dictionary<int, Material>();
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
if ( mesh.Faces[fi].Count < 3 )
continue;
var material = ResolveMaterial( mesh.Faces[fi].Material, materialForSlot, placeholder, slotCache );
if ( !buckets.TryGetValue( material, out var faces ) )
buckets[material] = faces = new List<int>();
faces.Add( fi );
}
var builder = Model.Builder;
var any = false;
foreach ( var (material, faces) in buckets )
{
if ( BuildSubmesh( mesh, faces, cornerNormals, normals, material, bounds ) is { } sub )
{
builder.AddMesh( sub );
any = true;
}
}
return any ? builder.Create() : null;
}
/// <summary>
/// The material a slot renders with: the vmat bound to it, or the flat placeholder.
///
/// Slot 0 is the slot every face starts on and never carries a material, so it is the
/// placeholder without a lookup. A named slot whose vmat will not load — a path into a package
/// that is not installed, a material deleted since it was assigned — also falls back rather
/// than rendering the part as nothing, which is the failure the single-material preview used
/// to avoid wholesale.
/// </summary>
private static Material ResolveMaterial( int slot, Func<int, string> materialForSlot,
Material placeholder, Dictionary<int, Material> cache )
{
if ( slot <= 0 || materialForSlot is null )
return placeholder;
if ( cache.TryGetValue( slot, out var cached ) )
return cached;
var name = materialForSlot( slot );
var material = string.IsNullOrWhiteSpace( name ) ? null : Material.Load( name );
// A named slot whose vmat would not load is the one case the fallback hides — the face
// renders as placeholder grey and nothing says why. Say why, once per slot per build.
if ( material is null && !string.IsNullOrWhiteSpace( name ) )
Log.Warning( $"[effigy-preview] slot {slot}: material '{name}' failed to load — using placeholder" );
return cache[slot] = material ?? placeholder;
}
/// <summary>One Mesh over a subset of the faces, all sharing one material.</summary>
private static Mesh BuildSubmesh( PolyMesh mesh, List<int> faceIndices, int[][] cornerNormals,
List<Vec3> normals, Material material, BBox bounds )
{
// One vertex per face corner rather than per position. Corner normals are the whole point
// of MeshNormals - sharing a vertex between two faces that disagree about the normal is
// exactly what rounds off a box's edges.
var vertices = new List<SimpleVertex>( faceIndices.Count * 4 );
var indices = new List<int>( faceIndices.Count * 6 );
foreach ( var fi in faceIndices )
{
var face = mesh.Faces[fi];
var corners = cornerNormals[fi];
var first = vertices.Count;
for ( var c = 0; c < face.Count; c++ )
{
var p = mesh.Positions[face.Indices[c]];
var n = normals[corners[c]];
var uv = face.UVs is not null && c < face.UVs.Length ? face.UVs[c] : default;
var position = new Vector3( p.x, p.y, p.z );
var normal = new Vector3( n.x, n.y, n.z );
vertices.Add( new SimpleVertex( position, normal, TangentFor( normal ), new Vector2( uv.x, uv.y ) ) );
}
// EAR CLIPPING, NOT A FAN. This used to fan from corner 0 on the grounds that every
// face the kernel produces is convex. Extrude caps are not: they are whatever closed
// region was drawn, and fanning a concave one fills its notches in - draw a dart and
// the solid came back as a quadrilateral with the concave corner swallowed.
var polygon = new List<Vec3>( face.Count );
for ( var k = 0; k < face.Count; k++ )
polygon.Add( mesh.Positions[face.Indices[k]] );
foreach ( var (a, b, cc) in Triangulate.Face( polygon ) )
{
indices.Add( first + a );
indices.Add( first + b );
indices.Add( first + cc );
}
}
if ( indices.Count == 0 )
return null;
var sbMesh = new Mesh( material );
sbMesh.CreateVertexBuffer<SimpleVertex>( vertices.Count, vertices );
sbMesh.CreateIndexBuffer( indices.Count, indices );
sbMesh.Bounds = bounds;
return sbMesh;
}
/// <summary>
/// Any unit vector perpendicular to the normal will do. Effigy has no tangent basis of its own
/// - UVs come from box or planar projection, not from an unwrap - so there is nothing to
/// derive a real tangent from, and the preview material does not read one.
/// </summary>
private static Vector3 TangentFor( Vector3 normal )
{
// Cross with whichever axis the normal is least aligned to, so the result never collapses.
var axis = MathF.Abs( normal.z ) < 0.9f ? Vector3.Up : Vector3.Forward;
var tangent = Vector3.Cross( normal, axis );
return tangent.IsNearZeroLength ? Vector3.Forward : tangent.Normal;
}
private static BBox BoundsOf( PolyMesh mesh )
{
var min = new Vector3( float.MaxValue );
var max = new Vector3( float.MinValue );
foreach ( var p in mesh.Positions )
{
var v = new Vector3( p.x, p.y, p.z );
min = Vector3.Min( min, v );
max = Vector3.Max( max, v );
}
return new BBox( min, max );
}
}