Editor/EffigyEditor/EffigyViewport.Highlight.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Text;
namespace Marionette.EditorTools;
/// <summary>
/// The lit-up part: a whole body drawn in the selection colour.
///
/// A CACHED MODEL RATHER THAN GIZMO LINES, and that is the entire reason this file exists.
/// DrawBodyHighlight — still here, still right for what it does — walks every face of the body
/// EVERY FRAME, allocating two lists per face, triangulating it, and issuing a SolidTriangle plus
/// one Line per edge. On a CAD body of two hundred quads that is nothing. On the thing that made
/// Remesh necessary — a Meshy part-segmentation whose big parts are three hundred thousand
/// triangles each — it is roughly a million immediate-mode draw calls per frame, and what you see
/// when you click that part in the Parts list is not a highlight, it is the viewport dying. The
/// selection was working the whole time; nothing on screen could say so.
///
/// So the highlight for a WHOLE BODY is built once, when the selection changes, and handed to the
/// renderer as a model: one draw call, no per-frame CPU, and the cost is the same whether the part
/// is a box or a scan.
///
/// IT IS A SHELL, pushed a hair out along its own normals. The alternative — drawing it at exactly
/// the part's own coordinates — z-fights with the preview across the whole surface, which reads as
/// the highlight flickering rather than as the part being selected. The push is a fraction of the
/// body's own size, so it holds at any scale: Effigy's units are dimensionless and a body is as
/// likely to be one unit across as three hundred.
///
/// FACE and edge highlights stay on the gizmo path. A face is one polygon and an edge is one line;
/// they are cheap by nature, and they change with the cursor rather than with a selection, which is
/// the opposite of what a cache is for.
/// </summary>
internal sealed partial class EffigyViewport
{
/// <summary>How far the shell is pushed out along the surface normal, as a fraction of the
/// body's bounding-sphere radius. Large enough to clear the depth buffer at any distance the
/// camera can get to, small enough that the shell never separates visibly from the part.</summary>
private const float HighlightShellFraction = 0.01f;
private GameObject _highlightObject;
private ModelRenderer _highlightRenderer;
private Material _highlightMaterial;
private Color _highlightMaterialColor;
/// <summary>What the cached shell was built from. Rebuild when it moves — not per frame, and
/// not per repaint.</summary>
private string _highlightKey;
/// <summary>
/// Show these bodies as selected, or none of them.
///
/// Called every frame from the idle-selection draw, and does nothing at all on the frames where
/// the answer has not changed — which is almost all of them. The key carries the body ids AND
/// their sizes, so a rebuild that replaces a part's mesh under the same id (which is exactly
/// what Remesh does) is noticed.
/// </summary>
private void SyncBodyHighlight( IReadOnlyList<Body> bodies, Color color )
{
var key = HighlightKey( bodies, color );
if ( key == _highlightKey )
return;
_highlightKey = key;
if ( bodies is null || bodies.Count == 0 )
{
ClearBodyHighlight();
return;
}
var material = HighlightMaterial( color );
var shell = BuildHighlightShell( bodies );
var model = shell is null || material is null ? null : EffigyPreview.Build( shell, material );
if ( model is null )
{
// SAID OUT LOUD. A null here used to mean the shell silently did not appear, which is
// indistinguishable from the selection never having happened — and that is precisely the
// confusion this feature has already cost more than it is worth.
Log.Warning( "[Effigy] the selection highlight could not be built - the part is selected, "
+ "but nothing will light up." );
ClearBodyHighlight();
return;
}
using var scope = _canvas.Scene.Push();
if ( !_highlightObject.IsValid() )
{
_highlightObject = new GameObject( true, "effigy_highlight" );
_highlightRenderer = _highlightObject.GetOrAddComponent<ModelRenderer>( false );
}
_highlightRenderer.Model = model;
_highlightRenderer.Enabled = true;
}
/// <summary>Drop the shell and the object holding it. Called when the selection empties, and
/// when the viewport is torn down.</summary>
private void ClearBodyHighlight()
{
_highlightObject?.Destroy();
_highlightObject = null;
_highlightRenderer = null;
}
private static string HighlightKey( IReadOnlyList<Body> bodies, Color color )
{
if ( bodies is null || bodies.Count == 0 )
return "";
var builder = new StringBuilder();
builder.Append( color.r ).Append( ',' ).Append( color.g ).Append( ',' ).Append( color.b );
foreach ( var body in bodies )
{
if ( body?.Mesh is not { } mesh )
continue;
// Id, then the two counts. The id alone is not enough: Remesh replaces a body's mesh
// and keeps its id, and a shell left over from before the reduce is a highlight of a
// part that no longer exists at those coordinates.
builder.Append( '|' ).Append( body.Id )
.Append( ':' ).Append( mesh.VertexCount )
.Append( ',' ).Append( mesh.FaceCount );
}
return builder.ToString();
}
/// <summary>
/// One mesh over every selected body, with each vertex pushed out along its own normal.
///
/// The push is per BODY rather than over the merged bounds, so selecting a hand and a torso
/// together does not give the hand a shell thick enough to swallow it.
/// </summary>
private static PolyMesh BuildHighlightShell( IReadOnlyList<Body> bodies )
{
PolyMesh merged = null;
foreach ( var body in bodies )
{
if ( body?.Mesh is not { } mesh || mesh.FaceCount == 0 || mesh.VertexCount == 0 )
continue;
var inflated = Inflated( mesh );
// One body is the ordinary case by a mile — a Parts-list click selects one row — and
// Append on a three-hundred-thousand-triangle mesh is a second copy of it for nothing.
if ( merged is null )
{
merged = inflated;
continue;
}
MeshTransform.Append( merged, inflated );
}
return merged is null || merged.FaceCount == 0 ? null : merged;
}
/// <summary>
/// The mesh with every position moved out along the average of the face normals meeting there.
/// A vertex whose faces cancel out — a degenerate sliver, a seam in a mesh that was never welded
/// — is left where it is rather than shot off in whatever direction the arithmetic landed on.
///
/// NOT PolyMesh.Clone: the faces are shared with the body this came from, because nothing here
/// or downstream writes to a Face — the shell only moves POSITIONS, and the merge below adds new
/// Face objects rather than editing the ones it was given. Cloning them instead would copy an
/// indices array and a UV array per face, three hundred thousand times, on the click that is
/// supposed to make selection feel instant.
/// </summary>
private static PolyMesh Inflated( PolyMesh mesh )
{
var normals = new Vec3[mesh.VertexCount];
var min = mesh.Positions[0];
var max = min;
for ( var i = 1; i < mesh.VertexCount; i++ )
{
var p = mesh.Positions[i];
min = new Vec3( MathF.Min( min.x, p.x ), MathF.Min( min.y, p.y ), MathF.Min( min.z, p.z ) );
max = new Vec3( MathF.Max( max.x, p.x ), MathF.Max( max.y, p.y ), MathF.Max( max.z, p.z ) );
}
var distance = (max - min).Length * 0.5f * HighlightShellFraction;
// WHICH WAY IS OUT. Newell gives the normal of a face wound counter-clockwise; a mesh wound
// the other way — which an imported OBJ is free to be — hands back inward normals, and an
// inward shell is a shell hidden INSIDE the part. That failure looks exactly like the
// highlight not working at all, which is the trap this whole file was written to get out of,
// so the winding is measured rather than assumed: the signed volume of a closed mesh is
// positive when its faces face out.
if ( SignedVolume( mesh ) < 0f )
distance = -distance;
foreach ( var face in mesh.Faces )
{
if ( face.Count < 3 )
continue;
// Newell, so a face that is not planar still gets a normal that means something, and
// so the accumulation is area-weighted for free — a big face should have more say in
// where a shared corner goes than a sliver does.
var normal = Vec3.Zero;
for ( var i = 0; i < face.Count; i++ )
{
var a = mesh.Positions[face.Indices[i]];
var b = mesh.Positions[face.Indices[(i + 1) % face.Count]];
normal = new Vec3(
normal.x + (a.y - b.y) * (a.z + b.z),
normal.y + (a.z - b.z) * (a.x + b.x),
normal.z + (a.x - b.x) * (a.y + b.y) );
}
for ( var i = 0; i < face.Count; i++ )
{
var v = face.Indices[i];
normals[v] = normals[v] + normal;
}
}
var positions = new List<Vec3>( mesh.VertexCount );
for ( var i = 0; i < mesh.VertexCount; i++ )
{
var n = normals[i];
var length = n.Length;
positions.Add( length < 1e-9f
? mesh.Positions[i]
: mesh.Positions[i] + n * (distance / length) );
}
// A fresh Faces LIST holding the same Face objects: the merge appends to this list, and it
// must not be the body's own.
return new PolyMesh { Positions = positions, Faces = new List<Face>( mesh.Faces ) };
}
/// <summary>Six times the enclosed volume, by the divergence theorem — only the SIGN is wanted,
/// so the sixth is not worth dividing out. Fanned from each face's first corner, which is exact
/// for the volume of a closed surface whether or not the face is convex.</summary>
private static float SignedVolume( PolyMesh mesh )
{
var total = 0f;
foreach ( var face in mesh.Faces )
{
if ( face.Count < 3 )
continue;
var a = mesh.Positions[face.Indices[0]];
for ( var i = 1; i < face.Count - 1; i++ )
{
var b = mesh.Positions[face.Indices[i]];
var c = mesh.Positions[face.Indices[i + 1]];
total += a.x * (b.y * c.z - b.z * c.y)
- a.y * (b.x * c.z - b.z * c.x)
+ a.z * (b.x * c.y - b.y * c.x);
}
}
return total;
}
/// <summary>
/// The flat colour the shell wears — default.vmat with a one-pixel texture bound, the same
/// trick the weight and paint previews use to put a generated image on a part without shipping
/// a shader of their own.
/// </summary>
private Material HighlightMaterial( Color color )
{
if ( _highlightMaterial is not null && _highlightMaterialColor == color )
return _highlightMaterial;
var pixel = new byte[]
{
(byte)(color.r * 255f), (byte)(color.g * 255f), (byte)(color.b * 255f), 255,
};
var texture = Texture.Create( 1, 1, ImageFormat.RGBA8888 ).WithData( pixel ).Finish();
_highlightMaterial = Material.Load( "materials/default.vmat" )?.CreateCopy( "effigy_highlight" );
_highlightMaterial?.Set( "g_tColor", texture );
_highlightMaterialColor = color;
return _highlightMaterial;
}
}