Editor widget that previews a rigged mesh. It draws a capped wireframe and a stick-skeleton, runs a per-joint "wiggle" deformation test, allows dragging joints to translate subtrees, and CPU-skins a cached subset of vertices for preview solid rendering.
using System;
using System.Collections.Generic;
using System.Linq;
using AutoRig.Mesh;
using AutoRig.Rig;
using Editor;
using Sandbox;
using VecN = System.Numerics.Vector3;
using QuatN = System.Numerics.Quaternion;
namespace AutoRig.Editor;
/// <summary>
/// Pre-compile rig preview: the source mesh as a capped wireframe and the generated
/// skeleton as stick bones with joint markers, in an orbitable editor scene (same
/// idiom as humanoid-retargeter's PreviewWidget). The wiggle test rotates each joint
/// ±15° in sequence and CPU-skins the wireframe so a novice can SEE whether the rig
/// deforms sensibly before exporting.
/// </summary>
public sealed class RigPreviewWidget : SceneRenderingWidget
{
const int MaxWireSegments = 9000;
const int MaxSkinnedVertices = 150_000;
const float WiggleDegrees = 15f;
const float SecondsPerJoint = 0.9f;
SceneLineObject _wire;
SceneLineObject _bones;
RigMesh _mesh;
RigResult _rig;
// Precomputed FK/skin state.
VecN[] _bindWorld; // joint bind positions
VecN[] _posedWorld; // joint posed positions (wiggle)
QuatN[] _poseRotation; // per-joint world rotation during wiggle
int[] _wireVertexIndices; // mesh vertex ids used by the capped wireframe, unique
Vector3[] _wirePositions; // posed positions for those vertices
Dictionary<int, int> _wireSlotOf;
(int A, int B)[] _wireSegments;
float _yaw = 35f;
float _zoom = 1f;
int _wiggleFrame;
Vector2 _lastMouse;
float _wiggleTime = -1f; // < 0 = not wiggling
bool _skinnedWiggle;
// ComputeBounds is O(all vertices); it was being called every frame from
// both UpdateCamera and RedrawWire, which pegged a core with a big mesh in
// the preview. Cache the extent + center once per SetRig instead.
float _boundsLength = 1f;
Vector3 _boundsCenter; // scene-space
int _dragJoint = -1; // joint being dragged (move/deform preview), or -1
bool _dragMoved; // this gesture actually moved the joint
bool _posedActive; // the live pose diverges from the bind pose
/// <summary>Fired once, at the first movement of a joint-drag, BEFORE the
/// pose changes - so the dialog can snapshot for undo.</summary>
public Action BeforeJointMove { get; set; }
/// <summary>Ctrl+Z inside the viewport (the widget usually holds focus after
/// a click/drag) - the dialog runs its undo.</summary>
public Action UndoRequested { get; set; }
protected override void OnKeyPress( KeyEvent e )
{
if ( e.Key == KeyCode.Z && e.HasCtrl )
{
UndoRequested?.Invoke();
return;
}
base.OnKeyPress( e );
}
/// <summary>The currently highlighted joint name ("" when none).</summary>
public string SelectedJoint => _highlight;
/// <summary>Current live joint positions (mesh space), for undo snapshots.</summary>
public VecN[] GetPose() => _posedWorld is null ? null : (VecN[])_posedWorld.Clone();
/// <summary>Restores a joint pose captured by <see cref="GetPose"/>.</summary>
public void SetPose( VecN[] pose )
{
if ( pose is null || _posedWorld is null || pose.Length != _posedWorld.Length )
return;
Array.Copy( pose, _posedWorld, pose.Length );
_posedActive = false;
for ( var i = 0; i < _posedWorld.Length && !_posedActive; i++ )
if ( _bindWorld is not null && _posedWorld[i] != _bindWorld[i] )
_posedActive = true;
if ( _skinnedWiggle )
RebuildSolid( posed: _posedActive );
RebuildSkeletonGeometry();
}
/// <summary>True while the wiggle test runs.</summary>
public bool Wiggling => _wiggleTime >= 0f;
/// <summary>Name of the joint currently wiggling ("" when idle).</summary>
public string WigglingJoint { get; private set; } = "";
SceneObject _solid;
public RigPreviewWidget( Widget parent ) : base( parent )
{
MinimumSize = new Vector2( 320, 320 );
MouseTracking = true;
Scene = Scene.CreateEditorScene();
using ( Scene.Push() )
{
Camera = new GameObject( true, "camera" ).GetOrAddComponent<CameraComponent>( false );
Camera.BackgroundColor = Theme.ControlBackground;
Camera.ZNear = 0.01f;
Camera.ZFar = 8192f;
Camera.FieldOfView = 45f;
Camera.Enabled = true;
}
// Same lighting rig as humanoid-retargeter's preview: a warm key and a
// cooler fill, no shadows (lines don't cast; the solid mesh reads cleanly).
var world = Scene.SceneWorld;
new ScenePointLight( world, new Vector3( 120, 100, 120 ), 600, Color.White * 3.5f ).ShadowsEnabled = false;
new ScenePointLight( world, new Vector3( -120, -100, 90 ), 600, Color.White * 2.0f ).ShadowsEnabled = false;
_wire = new SceneLineObject( world ) { Opaque = false, Lighting = false };
// No overlay-layer tricks: that pass is not rendered by this widget's
// camera (bones vanished entirely). Rigged view hides the solid instead,
// so the skeleton inside the translucent wireframe is always visible.
_bones = new SceneLineObject( world ) { Opaque = false, Lighting = false };
}
/// <summary>Builds a lit solid model from the raw mesh (the wireframe alone was
/// nearly invisible: fixed-width translucent lines, unlit scene).</summary>
/// <param name="posed">CPU-skin against the current wiggle pose (the solid IS
/// the model the user watches - the line wireframe never renders here).</param>
void RebuildSolid( bool posed = false )
{
_solid?.Delete();
_solid = null;
if ( _mesh is null || _mesh.TriangleCount == 0 )
return;
try
{
var material = TexturedMaterial()
?? Material.Load( "materials/dev/reflectivity_30.vmat" )
?? Material.Load( "materials/default/white.vmat" );
var sceneMesh = new Sandbox.Mesh( material );
var vertices = new List<SimpleVertex>( _mesh.Triangles.Length );
for ( var t = 0; t < _mesh.TriangleCount; t++ )
{
for ( var k = 0; k < 3; k++ )
{
var v = _mesh.Triangles[t * 3 + k];
var p = posed && _rig is not null ? SkinVertex( v ) : _mesh.Positions[v];
var n = v < _mesh.Normals.Length ? _mesh.Normals[v] : System.Numerics.Vector3.UnitZ;
var uv = v < _mesh.Uvs.Length ? _mesh.Uvs[v] : default;
vertices.Add( new SimpleVertex(
new Vector3( p.X, p.Y, p.Z ),
new Vector3( n.X, n.Y, n.Z ),
Vector3.Zero,
new Vector2( uv.X, uv.Y ) ) );
}
}
sceneMesh.CreateVertexBuffer( vertices.Count, SimpleVertex.Layout, vertices );
var model = Model.Builder.AddMesh( sceneMesh ).Create();
_solid = new SceneObject( Scene.SceneWorld, model,
new Transform( Vector3.Zero, Rotation.FromAxis( new Vector3( 1, 0, 0 ), 90f ) ) )
{
ColorTint = new Color( 0.62f, 0.66f, 0.72f ),
};
if ( _rig is not null )
{
// Ghost immediately (per-frame rebuilds must not flicker opaque).
_solid.ColorTint = new Color( 0.62f, 0.66f, 0.72f, 0.3f );
_solid.Flags.IsTranslucent = true;
_solid.Flags.IsOpaque = false;
}
}
catch ( Exception )
{
_solid?.Delete();
_solid = null; // wireframe fallback still draws below
}
}
Material _texturedMaterial;
bool _texturedTried;
/// <summary>The mesh's base-color image (glTF/FBX embedded texture) as a
/// preview material, decoded once. Null when absent/undecodable - the flat
/// dev material stands in.</summary>
Material TexturedMaterial()
{
if ( _texturedTried )
return _texturedMaterial;
_texturedTried = true;
var image = _mesh?.Materials?.FirstOrDefault( m => m.BaseColorImage is not null )
?.BaseColorImage;
if ( image is null )
return null;
try
{
var bitmap = Bitmap.CreateFromBytes( image );
if ( bitmap is null )
return null;
var texture = bitmap.ToTexture();
var material = Material.Create( $"autorig_preview_{GetHashCode()}", "simple" );
material.Set( "Color", texture );
_texturedMaterial = material;
return material;
}
catch ( Exception )
{
return null;
}
}
/// <summary>Installs the mesh + rig to display (null rig = mesh only).</summary>
public void SetRig( RigMesh mesh, RigResult rig )
{
_mesh = mesh;
_rig = rig;
_wiggleTime = -1f;
WigglingJoint = "";
if ( mesh is null )
{
_wire.Clear();
_bones.Clear();
_solid?.Delete();
_solid = null;
_skeletonObject?.Delete();
_skeletonObject = null;
_boneObject?.Delete();
_boneObject = null;
_highlightObject?.Delete();
_highlightObject = null;
return;
}
var meshBounds = mesh.ComputeBounds();
_boundsLength = MathF.Max( meshBounds.Size.Length(), 1e-3f );
_boundsCenter = ToVector3( meshBounds.Center );
RebuildSolid();
BuildWireTopology();
if ( rig is not null )
{
_bindWorld = new VecN[rig.Skeleton.Joints.Count];
_posedWorld = new VecN[rig.Skeleton.Joints.Count];
_poseRotation = new QuatN[rig.Skeleton.Joints.Count];
for ( var i = 0; i < rig.Skeleton.Joints.Count; i++ )
{
_bindWorld[i] = rig.Skeleton.Joints[i].Position;
_posedWorld[i] = _bindWorld[i];
_poseRotation[i] = QuatN.Identity;
}
// No vertex cap: a static model during the wiggle test defeats the test.
// Heavy meshes throttle the rebuild rate instead (see PreFrame).
_skinnedWiggle = rig.Weights is not null;
}
RedrawWire( bindPose: true );
RedrawBones();
RebuildSkeletonGeometry();
UpdateLineBounds();
}
/// <summary>SceneLineObjects keep tiny default bounds and get frustum-culled -
/// the reason neither the wireframe nor the skeleton ever showed. Give both the
/// mesh's (scene-space) bounds, padded.</summary>
void UpdateLineBounds()
{
if ( _mesh is null )
return;
var bounds = _mesh.ComputeBounds();
var a = ToVector3( bounds.Min );
var b = ToVector3( bounds.Max );
var box = new BBox( Vector3.Min( a, b ), Vector3.Max( a, b ) );
box = box.Grow( bounds.Size.Length() * 0.5f + 1f );
_wire.Bounds = box;
_bones.Bounds = box;
}
/// <summary>Starts the wiggle test (each joint ±15° in sequence; root skipped).</summary>
public void StartWiggle()
{
if ( _rig is null )
return;
_wiggleTime = 0f;
_wiggleFrame = 0;
Log.Info( $"[auto-rig] wiggle: {_mesh?.Positions.Length ?? 0} verts, "
+ $"skinned deform = {_skinnedWiggle}" );
}
/// <summary>Stops the wiggle test and returns to the bind pose.</summary>
public void StopWiggle()
{
_wiggleTime = -1f;
WigglingJoint = "";
if ( _rig is not null )
{
for ( var i = 0; i < _poseRotation.Length; i++ )
{
_poseRotation[i] = QuatN.Identity;
_posedWorld[i] = _bindWorld[i];
}
}
RedrawWire( bindPose: true );
RedrawBones();
RebuildSkeletonGeometry();
RebuildSolid(); // back to the bind pose
}
protected override void PreFrame()
{
Scene.EditorTick( RealTime.Now, RealTime.Delta );
UpdateCamera();
// The wire/skeleton are SceneLineObjects - they must be re-submitted each
// frame or they vanish. Submission is cheap (bounded by the wire cap); it
// was the two O(all-verts) ComputeBounds() calls per frame (here + in
// UpdateCamera) that pegged a core with a preview open - both now cached.
if ( _mesh is not null && _wiggleTime < 0f )
{
RedrawWire( bindPose: !_posedActive );
RedrawBones();
}
if ( _wiggleTime >= 0f && _rig is not null )
{
_wiggleTime += RealTime.Delta;
var jointCount = _rig.Skeleton.Joints.Count;
var wigglable = Math.Max( 1, jointCount - 1 ); // skip the root
var slot = (int)(_wiggleTime / SecondsPerJoint);
if ( slot >= wigglable )
{
StopWiggle();
return;
}
var joint = slot + 1; // joints are parent-before-child; 0 is root
WigglingJoint = _rig.Skeleton.Joints[joint].Name;
var phase = (_wiggleTime % SecondsPerJoint) / SecondsPerJoint; // 0..1
var angle = MathF.Sin( phase * MathF.Tau ) * WiggleDegrees * (MathF.PI / 180f);
ApplyWigglePose( joint, angle );
RedrawWire( bindPose: false );
RedrawBones();
RebuildSkeletonGeometry();
// The solid IS what the user watches - deform it with the pose. Heavy
// meshes rebuild every 3rd frame (choppier but MOVING).
_wiggleFrame++;
if ( _skinnedWiggle
&& (_mesh.Positions.Length <= 80_000 || _wiggleFrame % 3 == 0) )
RebuildSolid( posed: true );
}
}
/// <summary>FK pose with a single joint rotated about its hinge axis (or X).</summary>
void ApplyWigglePose( int wiggleJoint, float angle )
{
var joints = _rig.Skeleton.Joints;
var axis = joints[wiggleJoint].HingeAxis;
var axisN = axis == VecN.Zero ? VecN.UnitX : VecN.Normalize( axis );
var spin = QuatN.CreateFromAxisAngle( axisN, angle );
for ( var i = 0; i < joints.Count; i++ )
{
var parent = joints[i].Parent;
if ( parent < 0 )
{
_poseRotation[i] = i == wiggleJoint ? spin : QuatN.Identity;
_posedWorld[i] = _bindWorld[i];
continue;
}
var localOffset = _bindWorld[i] - _bindWorld[parent];
var parentRotation = _poseRotation[parent];
_posedWorld[i] = _posedWorld[parent] + VecN.Transform( localOffset, parentRotation );
_poseRotation[i] = i == wiggleJoint
? parentRotation * spin
: parentRotation;
}
}
/// <summary>Collects a capped set of triangle edges (stride over triangles) plus the
/// unique vertex list they reference, so wiggle re-skins only what is drawn.</summary>
void BuildWireTopology()
{
var segments = new List<(int A, int B)>();
_wireSlotOf = new Dictionary<int, int>();
var vertexIds = new List<int>();
int SlotOf( int vertex )
{
if ( !_wireSlotOf.TryGetValue( vertex, out var slot ) )
{
slot = vertexIds.Count;
vertexIds.Add( vertex );
_wireSlotOf.Add( vertex, slot );
}
return slot;
}
var stride = Math.Max( 1, _mesh.TriangleCount * 3 / MaxWireSegments );
for ( var t = 0; t < _mesh.TriangleCount; t += stride )
{
var a = _mesh.Triangles[t * 3];
var b = _mesh.Triangles[t * 3 + 1];
var c = _mesh.Triangles[t * 3 + 2];
segments.Add( (SlotOf( a ), SlotOf( b )) );
segments.Add( (SlotOf( b ), SlotOf( c )) );
segments.Add( (SlotOf( c ), SlotOf( a )) );
}
_wireSegments = segments.ToArray();
_wireVertexIndices = vertexIds.ToArray();
_wirePositions = new Vector3[_wireVertexIndices.Length];
}
void RedrawWire( bool bindPose )
{
if ( _mesh is null || _wireSegments is null )
return;
for ( var s = 0; s < _wireVertexIndices.Length; s++ )
{
var v = _wireVertexIndices[s];
var p = (!bindPose && _skinnedWiggle && _rig is not null)
? SkinVertex( v )
: _mesh.Positions[v];
_wirePositions[s] = ToVector3( p );
}
// Width relative to the model (a fixed width is dust on big models, a blob
// on small ones). During a skinned wiggle the wire is the star: brighter,
// and the static solid hides so the deformation reads.
var scale = _boundsLength;
var width = scale * 0.0012f;
// The solid model stays visible ALWAYS (an empty viewport is worse than an
// occluded bone); wire is a subtle shell, brighter while wiggling.
var color = bindPose
? new Color( 0.65f, 0.75f, 0.85f, 0.2f )
: new Color( 0.55f, 0.85f, 1f, 0.85f );
if ( _solid is not null )
{
_solid.RenderingEnabled = true; // the solid is the model, always shown
// With a rig: ghost the body so the skeleton reads through it.
var ghosted = _rig is not null;
_solid.ColorTint = ghosted
? new Color( 0.62f, 0.66f, 0.72f, 0.3f )
: new Color( 0.62f, 0.66f, 0.72f, 1f );
_solid.Flags.IsTranslucent = ghosted;
_solid.Flags.IsOpaque = !ghosted;
}
_wire.Clear();
foreach ( var (a, b) in _wireSegments )
{
_wire.StartLine();
_wire.AddLinePoint( _wirePositions[a], color, width );
_wire.AddLinePoint( _wirePositions[b], color, width );
_wire.EndLine();
}
}
/// <summary>Linear-blend skin of one vertex against the current wiggle pose.</summary>
VecN SkinVertex( int v )
{
var bind = _mesh.Positions[v];
var result = VecN.Zero;
float total = 0;
for ( var k = 0; k < 4; k++ )
{
var w = _rig.Weights.Weights[v * 4 + k];
if ( w <= 0f )
continue;
var bone = _rig.Weights.BoneIndices[v * 4 + k];
var local = bind - _bindWorld[bone];
result += (_posedWorld[bone] + VecN.Transform( local, _poseRotation[bone] )) * w;
total += w;
}
return total > 1e-4f ? result / total : bind;
}
SceneObject _skeletonObject; // joint octahedra (red, the traditional idiom)
SceneObject _boneObject; // bone bipyramids (blue)
SceneObject _highlightObject;
string _highlight = "";
/// <summary>Marks one joint (by name) with a bigger yellow marker - driven by
/// the adjust panel's selection.</summary>
public void SetHighlight( string jointName )
{
_highlight = jointName ?? "";
RebuildSkeletonGeometry();
}
/// <summary>Raised when the user clicks a joint in the viewport (joint name),
/// or clicks empty space (null - selection cleared).</summary>
public Action<string> JointPicked { get; set; }
/// <summary>The joint under the given widget-local position, or -1. Joints
/// are projected through the same orbit camera the viewport renders with;
/// the nearest projected joint within a forgiving radius wins.</summary>
int PickJoint( Vector2 local )
{
if ( _rig is null || _posedWorld is null || !Camera.IsValid() )
return -1;
var forward = Camera.WorldRotation.Forward;
var right = Camera.WorldRotation.Right;
var up = Camera.WorldRotation.Up;
var origin = Camera.WorldPosition;
// Vertical-FOV pinhole projection. Even if the engine's FOV convention
// differs slightly, nearest-projected-joint keeps picks on target.
var tanHalf = MathF.Tan( MathX.DegreeToRadian( Camera.FieldOfView ) * 0.5f );
var aspect = Width > 0 ? (float)Width / Math.Max( (float)Height, 1f ) : 1f;
var best = -1;
var bestDistance = float.MaxValue;
for ( var i = 0; i < _posedWorld.Length; i++ )
{
var world = ToVector3( _posedWorld[i] );
var toJoint = world - origin;
var depth = Vector3.Dot( toJoint, forward );
if ( depth <= 0.001f )
continue; // behind the camera
var ndcX = Vector3.Dot( toJoint, right ) / (depth * tanHalf * aspect);
var ndcY = Vector3.Dot( toJoint, up ) / (depth * tanHalf);
var px = (ndcX * 0.5f + 0.5f) * Width;
var py = (0.5f - ndcY * 0.5f) * Height;
var d = new Vector2( px, py ).Distance( local );
if ( d < bestDistance )
{
bestDistance = d;
best = i;
}
}
// Forgiving click target: ~4% of the viewport's smaller side, min 14px.
var tolerance = MathF.Max( MathF.Min( (float)Width, (float)Height ) * 0.04f, 14f );
return bestDistance <= tolerance ? best : -1;
}
/// <summary>Right-clicked a joint (name) - the dialog raises its context menu.</summary>
public Action<string> JointContextRequested { get; set; }
/// <summary>Re-applies the (edited) rig after a delete/rename, rebuilding all
/// preview geometry and clearing any live move-pose.</summary>
public void Reload( RigResult rig )
{
_posedActive = false;
SetRig( _mesh, rig );
}
/// <summary>Snaps the live move-pose back to the rest skeleton.</summary>
public void ResetPose()
{
if ( _rig is null || _bindWorld is null )
return;
for ( var i = 0; i < _posedWorld.Length; i++ )
{
_posedWorld[i] = _bindWorld[i];
_poseRotation[i] = QuatN.Identity;
}
_posedActive = false;
RebuildSolid();
RebuildSkeletonGeometry();
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
_lastMouse = e.LocalPosition;
if ( _rig is null )
return;
var picked = PickJoint( e.LocalPosition );
if ( e.RightMouseButton && picked >= 0 )
{
SetHighlight( _rig.Skeleton.Joints[picked].Name );
JointPicked?.Invoke( _rig.Skeleton.Joints[picked].Name );
JointContextRequested?.Invoke( _rig.Skeleton.Joints[picked].Name );
return;
}
if ( e.LeftMouseButton )
{
if ( picked >= 0 )
{
SetHighlight( _rig.Skeleton.Joints[picked].Name );
JointPicked?.Invoke( _rig.Skeleton.Joints[picked].Name );
_dragJoint = picked; // a left-drag from a joint MOVES it (deform preview)
_dragMoved = false;
}
else
{
_dragJoint = -1;
if ( _highlight.Length > 0 )
{
SetHighlight( "" );
JointPicked?.Invoke( null );
}
}
}
}
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
_dragJoint = -1;
_dragMoved = false;
}
/// <summary>Mesh-space delta from a scene-space delta (inverse of ToVector3).</summary>
static VecN FromVector3( Vector3 v ) => new( v.x, v.z, -v.y );
/// <summary>Translates a joint AND its whole subtree in the LIVE pose (not the
/// rest skeleton), so the mesh deforms via the existing skin path - a
/// translation analogue of the wiggle test. Not persisted: it is a
/// "does this joint drive the right vertices?" check.</summary>
void MoveJointSubtree( int root, VecN meshDelta )
{
if ( _rig is null || _posedWorld is null )
return;
if ( !_dragMoved )
{
BeforeJointMove?.Invoke(); // snapshot the pre-move pose for undo
_dragMoved = true;
}
var joints = _rig.Skeleton.Joints;
var stack = new Stack<int>();
stack.Push( root );
while ( stack.Count > 0 )
{
var j = stack.Pop();
_posedWorld[j] += meshDelta;
for ( var c = 0; c < joints.Count; c++ )
if ( joints[c].Parent == j )
stack.Push( c );
}
_posedActive = true;
if ( _skinnedWiggle )
RebuildSolid( posed: true );
RebuildSkeletonGeometry();
}
/// <summary>
/// The skeleton as REAL geometry (SceneLineObject never renders in this widget;
/// SceneObject+Model provably does): each bone an elongated bipyramid, each
/// joint an octahedron, ivory-tinted, rebuilt from the current pose.
/// </summary>
void RebuildSkeletonGeometry()
{
_skeletonObject?.Delete();
_skeletonObject = null;
_boneObject?.Delete();
_boneObject = null;
_highlightObject?.Delete();
_highlightObject = null;
if ( _rig is null || _mesh is null )
return;
try
{
var joints = _rig.Skeleton.Joints;
var scale = _boundsLength;
var jointRadius = scale * 0.008f;
var boneRadius = scale * 0.004f;
var jointVertices = new List<SimpleVertex>();
var boneVertices = new List<SimpleVertex>();
var vertices = jointVertices; // Triangle() writes into this target
void Triangle( Vector3 a, Vector3 b, Vector3 c )
{
var normal = Vector3.Cross( b - a, c - a ).Normal;
vertices.Add( new SimpleVertex( a, normal, Vector3.Zero, Vector2.Zero ) );
vertices.Add( new SimpleVertex( b, normal, Vector3.Zero, Vector2.Zero ) );
vertices.Add( new SimpleVertex( c, normal, Vector3.Zero, Vector2.Zero ) );
// Backface too - bones must read from every side.
vertices.Add( new SimpleVertex( a, -normal, Vector3.Zero, Vector2.Zero ) );
vertices.Add( new SimpleVertex( c, -normal, Vector3.Zero, Vector2.Zero ) );
vertices.Add( new SimpleVertex( b, -normal, Vector3.Zero, Vector2.Zero ) );
}
void Octahedron( Vector3 center, float radius )
{
foreach ( var sx in new[] { -1f, 1f } )
foreach ( var sy in new[] { -1f, 1f } )
foreach ( var sz in new[] { -1f, 1f } )
Triangle(
center + Vector3.Forward * radius * sx,
center + Vector3.Left * radius * sy,
center + Vector3.Up * radius * sz );
}
void Bone( Vector3 from, Vector3 to, float radius )
{
var direction = to - from;
if ( direction.Length < 1e-6f )
return;
var side = Vector3.Cross( direction.Normal, Vector3.Up );
if ( side.Length < 1e-4f )
side = Vector3.Cross( direction.Normal, Vector3.Forward );
var s1 = side.Normal * radius;
var s2 = Vector3.Cross( direction.Normal, side.Normal ).Normal * radius;
var hub = from + direction * 0.15f;
foreach ( var (a, b) in new[] { (s1, s2), (s2, -s1), (-s1, -s2), (-s2, s1) } )
{
Triangle( from, hub + a, hub + b );
Triangle( hub + a, to, hub + b );
}
}
var highlighted = _highlight.Length > 0
? joints.FindIndex( j => j.Name == _highlight ) : -1;
for ( var i = 0; i < joints.Count; i++ )
{
var pos = ToVector3( _posedWorld[i] );
if ( i != highlighted ) // the selected joint is drawn GREEN below
{
vertices = jointVertices;
Octahedron( pos, jointRadius );
}
if ( joints[i].Parent >= 0 )
{
vertices = boneVertices;
Bone( ToVector3( _posedWorld[joints[i].Parent] ), pos, boneRadius );
}
}
var material = Material.Load( "materials/dev/reflectivity_30.vmat" )
?? Material.Load( "materials/default/white.vmat" );
// Traditional rig colors: RED joints, BLUE bones (user request).
if ( jointVertices.Count > 0 )
{
var jointMesh = new Sandbox.Mesh( material );
jointMesh.CreateVertexBuffer( jointVertices.Count, SimpleVertex.Layout, jointVertices );
_skeletonObject = new SceneObject(
Scene.SceneWorld, Model.Builder.AddMesh( jointMesh ).Create(), Transform.Zero )
{
ColorTint = new Color( 0.92f, 0.18f, 0.15f ),
};
}
if ( boneVertices.Count > 0 )
{
var boneMesh = new Sandbox.Mesh( material );
boneMesh.CreateVertexBuffer( boneVertices.Count, SimpleVertex.Layout, boneVertices );
_boneObject = new SceneObject(
Scene.SceneWorld, Model.Builder.AddMesh( boneMesh ).Create(), Transform.Zero )
{
ColorTint = new Color( 0.20f, 0.45f, 0.95f ),
};
}
// The selected joint IS the marker: its own octahedron drawn in
// fluorescent green (slightly enlarged so it pops) instead of the red
// one - a highlight of the joint, not a second shape covering it.
if ( highlighted >= 0 )
{
vertices = new List<SimpleVertex>();
Octahedron( ToVector3( _posedWorld[highlighted] ), jointRadius * 1.35f );
var highlightMesh = new Sandbox.Mesh( material );
highlightMesh.CreateVertexBuffer( vertices.Count, SimpleVertex.Layout, vertices );
_highlightObject = new SceneObject( Scene.SceneWorld,
Model.Builder.AddMesh( highlightMesh ).Create(), Transform.Zero )
{
ColorTint = new Color( 0.30f, 1f, 0.15f ), // fluorescent green
};
}
}
catch ( Exception )
{
_skeletonObject?.Delete();
_skeletonObject = null;
_boneObject?.Delete();
_boneObject = null;
}
}
void RedrawBones()
{
_bones.Clear();
if ( _rig is null )
return;
var joints = _rig.Skeleton.Joints;
// Soft bone tones (no fluorescent green): warm ivory bones, blue joints.
var boneColor = new Color( 0.93f, 0.82f, 0.6f, 0.95f );
var jointColor = Theme.Blue.WithAlpha( 0.95f );
var activeColor = Theme.Yellow;
var scale = MathF.Max( _boundsLength, 1f );
var tick = scale * 0.006f;
for ( var i = 0; i < joints.Count; i++ )
{
var pos = ToVector3( _posedWorld[i] );
var parent = joints[i].Parent;
if ( parent >= 0 )
{
_bones.StartLine();
_bones.AddLinePoint( ToVector3( _posedWorld[parent] ), boneColor, tick * 0.9f );
_bones.AddLinePoint( pos, boneColor, tick * 0.9f );
_bones.EndLine();
}
// Joint = a 3-axis star so it reads as a node from any angle.
var isActive = joints[i].Name == WigglingJoint;
var markerColor = isActive ? activeColor : jointColor;
foreach ( var axis in new[] { Vector3.Up, Vector3.Left, Vector3.Forward } )
{
_bones.StartLine();
_bones.AddLinePoint( pos - axis * tick * 1.5f, markerColor, tick * 1.6f );
_bones.AddLinePoint( pos + axis * tick * 1.5f, markerColor, tick * 1.6f );
_bones.EndLine();
}
}
}
/// <summary>Mesh space (Y-up, as our whole pipeline assumes) → s&box scene
/// space (Z-up): rotate +90° about X. Without this everything lies sideways.</summary>
static Vector3 ToVector3( VecN v ) => new( v.X, -v.Z, v.Y );
void UpdateCamera()
{
if ( !Camera.IsValid() || _mesh is null )
return;
// Cached (ComputeBounds is O(verts) and this runs every frame).
var center = _boundsCenter;
var radius = MathF.Max( _boundsLength * 0.5f, 1f );
var distance = MathX.SphereCameraDistance( radius, Camera.FieldOfView ) * 1.1f * _zoom;
var yawRad = MathX.DegreeToRadian( _yaw );
var dir = new Vector3( MathF.Cos( yawRad ), MathF.Sin( yawRad ), 0.35f ).Normal;
Camera.WorldPosition = center + dir * distance;
Camera.WorldRotation = Rotation.LookAt( -dir, Vector3.Up );
}
protected override void OnWheel( WheelEvent e )
{
base.OnWheel( e );
_zoom = Math.Clamp( _zoom * (e.Delta > 0 ? 0.88f : 1.14f), 0.15f, 6f );
e.Accept();
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
var delta = e.LocalPosition - _lastMouse;
_lastMouse = e.LocalPosition;
if ( (e.ButtonState & MouseButtons.Left) == 0 )
return;
// Dragging a selected joint moves it in the camera-facing plane and the
// mesh follows; dragging empty space orbits.
if ( _dragJoint >= 0 && _posedWorld is not null && Camera.IsValid() )
{
var jointScene = ToVector3( _posedWorld[_dragJoint] );
var depth = Vector3.Dot( jointScene - Camera.WorldPosition, Camera.WorldRotation.Forward );
var tanHalf = MathF.Tan( MathX.DegreeToRadian( Camera.FieldOfView ) * 0.5f );
var worldPerPixel = 2f * tanHalf * MathF.Max( depth, 0.01f ) / MathF.Max( (float)Height, 1f );
var sceneDelta = Camera.WorldRotation.Right * (delta.x * worldPerPixel)
+ Camera.WorldRotation.Up * (-delta.y * worldPerPixel);
MoveJointSubtree( _dragJoint, FromVector3( sceneDelta ) );
return;
}
_yaw -= delta.x * 0.4f;
}
public override void OnDestroyed()
{
base.OnDestroyed();
_solid?.Delete();
_solid = null;
_skeletonObject?.Delete();
_skeletonObject = null;
_boneObject?.Delete();
_boneObject = null;
_highlightObject?.Delete();
_highlightObject = null;
Scene?.Destroy();
Scene = null;
}
}