Editor/EffigyEditor/EffigyViewport.Rig.cs

Editor viewport code for the Effigy rigging tool. It draws a preview and handles clicks when the bone-placement tool is active, computing placement either on the surface or the material midpoint, snapping to a mirror plane, drawing ghost bones and measured spans, and invoking callbacks to the owning panel when a point is picked or escape is requested.

Native Interop
using Editor;
using Effigy;
using Sandbox;
using System;

namespace Marionette.EditorTools;

/// <summary>
/// Placing bones by clicking the model — separate from EffigyViewport.Sketching.cs's picking
/// modes because this one writes new geometry into a skeleton rather than selecting existing
/// geometry, and separate from the pose gizmo in EffigyViewport.cs because that drags a bone
/// that already exists.
///
/// The Skeleton itself is owned by EffigyRigPanel, not the viewport — same division as
/// BodyPickMode, where the viewport only ever reports what was clicked and the owner decides
/// what that means. Chaining (each click extending the previous point into a new bone, parented
/// to it) is Blender's armature-extrude gesture, and the reason the panel — not the viewport —
/// tracks the pending head and parent: it is the one deciding when a chain resets.
/// </summary>
internal sealed partial class EffigyViewport
{
	/// <summary>Where along the cursor ray a click puts the joint.</summary>
	internal enum BonePlacement
	{
		/// <summary>On the skin, where the ray first meets the model.</summary>
		Surface,

		/// <summary>Halfway through the material the ray crosses — see MeshRaycast.SolidSpan.</summary>
		Middle,
	}

	/// <summary>While true, left-clicking the model reports the point via BonePointPicked instead
	/// of selecting or posing a bone.</summary>
	public bool BoneToolActive { get; set; }

	/// <summary>
	/// How deep a click places the joint. Owned by the rig panel, which puts the control on screen;
	/// mirrored here because the PREVIEW has to agree with what the click will do, and the preview
	/// is drawn from this file.
	///
	/// MIDDLE IS THE DEFAULT. A bone belongs on the medial line of what it drives, and surface is
	/// the special case — an attachment point, a prop mount — rather than the ordinary one. Surface
	/// was the only option for as long as this tool has existed, which is why a spine placed with it
	/// runs down the front of the chest rather than through it.
	/// </summary>
	public BonePlacement BonePlacementMode { get; set; } = BonePlacement.Middle;

	/// <summary>
	/// Force every placed joint onto y = 0.
	///
	/// Y BECAUSE THAT IS THIS PROJECT'S MIRROR PLANE — EffigyRigPanel.MirrorSelectedBone reflects
	/// across it, and a mirrored rig is only clean if the chain it hangs off sits exactly on it. A
	/// spine joint at y = 0.004 mirrors an arm to 0.008 from where its twin should be, which is
	/// invisible until something animates.
	/// </summary>
	public bool SnapBonesToCentre { get; set; }

	/// <summary>World point of the chain's last placed joint, for the preview line. Set by the
	/// panel — null means the next click starts a fresh chain rather than extending one.</summary>
	public Vec3? PendingBoneHead { get; set; }

	/// <summary>Fires with the world-space point clicked while the tool is active.</summary>
	public Action<Vec3> BonePointPicked { get; set; }

	/// <summary>Escape while the tool is active. The panel decides whether that closes the current
	/// chain (if one is open) or turns the tool off entirely (if not) — the viewport has no notion
	/// of which, since PendingBoneHead is the panel's state mirrored here for drawing only.</summary>
	public Action BoneToolEscape { get; set; }

	private static readonly Color BoneToolPreviewColor = new( 1f, 0.85f, 0.2f, 0.55f );

	/// <summary>The measured run of material, drawn cooler and thinner than the bone itself so it
	/// reads as a measurement rather than as geometry.</summary>
	private static readonly Color BoneSpanColor = new( 0.55f, 0.8f, 1f, 0.7f );

	/// <summary>Length of the ghost bone shown before the first click of a chain has a real tail to
	/// aim at — a guess, since nothing here knows the model's scale. Matches DogBone's own knob
	/// math (knobR = boneLen * 0.16) against BoneHandleRadius (0.8) closely enough that the ghost
	/// reads as the same size class as a committed bone rather than conspicuously different.</summary>
	private const float PendingBonePreviewLength = 5f;

	/// <summary>
	/// Highlight the point under the cursor with the actual dog-bone shape the click would commit
	/// — not a placeholder dot, the real DrawDogBone — so what you see before clicking is what you
	/// get after, for BOTH clicks of the gesture:
	///
	/// - Second click of a segment: head is the pending point from the first click, tail is the
	///   cursor. Exact preview — the real head, the real tail, the real orientation.
	/// - First click of a chain: there is no head yet, so the tail is a guess — a fixed length
	///   standing off the surface along its normal. Direction and exact length are not meaningful
	///   yet (only the second click fixes those); what this answers is "a bone will appear roughly
	///   here, roughly this size," which a bare dot didn't.
	///
	/// IN MIDDLE MODE THE FIRST CLICK LOSES THAT GHOST BONE and gets a knob instead. The guess stood
	/// the bone off the surface along the surface NORMAL, which meant something while joints lived on
	/// the skin and means nothing from a point inside the model — it would draw a bone pushing back
	/// out through the face you were pointing at. A knob at the placement point plus the measured run
	/// is the honest version: it says where, and says nothing about a direction only the second click
	/// decides.
	///
	/// Drawn depth-ignoring like the committed skeleton (DrawRigSkeleton) so the preview reads
	/// through the mesh the same way placed bones already do, rather than disappearing the moment it
	/// points away from the camera — which in Middle mode is every time, since the point is behind
	/// the surface by construction.
	///
	/// Same raycast MeshRaycast/_displayBodies pairing as the face and body picks.
	/// </summary>
	private void BoneToolFrame()
	{
		if ( !BoneToolActive || !_canvasHasCursor )
			return;

		var ray = Gizmo.CurrentRay;
		var origin = new Vec3( ray.Position.x, ray.Position.y, ray.Position.z );
		var direction = new Vec3( ray.Forward.x, ray.Forward.y, ray.Forward.z );

		if ( MeshRaycast.Raycast( _displayBodies, origin, direction, PickTreeFor ) is not { } hit )
			return;

		// MEASURED IN THE BODY THE HIT NAMES, not across all of them. Raycast above has already
		// decided which solid the cursor is on — including discarding surfaces buried inside another
		// body — and asking that question twice would both cost a second full scan every frame and
		// leave two answers that could disagree.
		var span = BonePlacementMode == BonePlacement.Middle
			? MeshRaycast.FirstSolidSpan( hit.Body.Mesh, origin, direction )
			: null;

		var point = PlacementPoint( hit.Hit, span );
		var world = new Vector3( point.x, point.y, point.z );

		Gizmo.Draw.IgnoreDepth = true;

		if ( span is { } run )
			DrawBoneSpan( run, world );

		Gizmo.Draw.Color = BoneToolPreviewColor;

		if ( PendingBoneHead is { } head && (point - head).Length > 0.01f )
		{
			var headWorld = new Vector3( head.x, head.y, head.z );
			var (xAxis, zAxis) = PreviewBasis( head, point );
			DrawDogBone( headWorld, world, xAxis, zAxis );
		}
		else if ( PendingBoneHead is null && span is null )
		{
			var tailGuess = point + hit.Hit.Normal * PendingBonePreviewLength;

			if ( (tailGuess - point).Length > 0.01f )
			{
				var tailWorld = new Vector3( tailGuess.x, tailGuess.y, tailGuess.z );
				var (xAxis, zAxis) = PreviewBasis( point, tailGuess );
				DrawDogBone( world, tailWorld, xAxis, zAxis );
			}
		}
		else
		{
			Gizmo.Draw.SolidSphere( world, BoneHandleRadius * 0.4f, 8, 8 );
		}

		Gizmo.Draw.IgnoreDepth = false;

		if ( Gizmo.WasLeftMousePressed )
			BonePointPicked?.Invoke( point );
	}

	/// <summary>
	/// Where the click lands: the surface, or the middle of the material behind it, then pulled onto
	/// the mirror plane if that is asked for.
	///
	/// THE SNAP IS APPLIED LAST, and it moves the point off the ray. That is the point of it — an
	/// exact zero is worth more than staying under the cursor, because it is what makes a mirrored
	/// rig line up. The preview draws the result rather than the ray, so the jump is visible before
	/// the click rather than discovered in the inspector afterwards.
	///
	/// A run that could not be measured falls back to the surface silently. It means the ray found no
	/// way out of the material — an open mesh, a single-sided surface — and refusing the click there
	/// would leave the tool dead on such a model with nothing said about why.
	/// </summary>
	private Vec3 PlacementPoint( MeshHit hit, SolidSpan? span )
	{
		var point = span is { } run ? run.Midpoint : hit.Point;

		return SnapBonesToCentre ? new Vec3( point.x, 0f, point.z ) : point;
	}

	/// <summary>
	/// The run of material the placement was measured through: a line from the near face to the far
	/// one, capped at both ends, with its thickness written beside the point it produced.
	///
	/// WITHOUT THIS THE TOOL LOOKS BROKEN. A click in Middle mode puts the joint somewhere the cursor
	/// is not, behind a surface you cannot see through — so the first honest question is "why did it
	/// go there?" The line answers it by showing the two faces the depth was measured between, and it
	/// makes the fallback legible too: no line means no run was found and the click will land on the
	/// skin.
	/// </summary>
	private static void DrawBoneSpan( SolidSpan span, Vector3 placement )
	{
		var entry = new Vector3( span.Entry.x, span.Entry.y, span.Entry.z );
		var exit = new Vector3( span.Exit.x, span.Exit.y, span.Exit.z );

		Gizmo.Draw.Color = BoneSpanColor;
		Gizmo.Draw.LineThickness = 1f;
		Gizmo.Draw.Line( entry, exit );

		// Ends marked, or the line reads as a bone shaft rather than as a measurement.
		var tick = BoneHandleRadius * 0.25f;
		Gizmo.Draw.SolidSphere( entry, tick, 6, 6 );
		Gizmo.Draw.SolidSphere( exit, tick, 6, 6 );

		// The thickness, beside the point it produced. That number is the whole argument for the mode:
		// it says how far off the surface the joint is about to sit.
		Gizmo.Draw.WorldText( $"{span.Thickness:0.##}",
			new Transform( placement + Vector3.Up * (BoneHandleRadius * 1.2f) ), "Roboto", 9f, TextFlag.Center );
	}

	/// <summary>
	/// The same head→tail aim-and-perpendicular construction Skeleton.LocalFromWorldPoints uses,
	/// kept here only far enough to get two axes for the PREVIEW's cross-section — the kernel
	/// owns the real thing once the click commits. Not shared code because it can't be: the kernel
	/// has no notion of a Vector3/Gizmo and shouldn't grow one for a rendering concern. Copied
	/// exactly, including the same seed-axis threshold, so the preview's roll matches the bone
	/// AddBoneFromPoints actually creates rather than merely resembling it.
	/// </summary>
	private static (Vector3 xAxis, Vector3 zAxis) PreviewBasis( Vec3 head, Vec3 tail )
	{
		var along = tail - head;
		var y = along / along.Length;

		var seed = MathF.Abs( y.x ) < 0.9f ? new Vec3( 1, 0, 0 ) : new Vec3( 0, 0, 1 );
		var x = Vec3.Cross( seed, y ).Normal;
		var z = Vec3.Cross( x, y );

		return (new Vector3( x.x, x.y, x.z ), new Vector3( z.x, z.y, z.z ));
	}
}