Editor/EffigyEditor/EffigyViewport.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Marionette.EditorTools;
/// <summary>
/// The 3D viewport for Effigy — a live view of the PartStudio's output, with Onshape-style
/// reference planes (Top/Front/Right intersecting at the origin), a selectable origin point,
/// and a fly camera.
///
/// PLANES ARE DRAWN AS WIREFRAME RECTANGLES using Gizmo.Draw.Line, oriented to s&box's
/// coordinate system (+x forward, +y left, +z up):
///
/// Top = XY plane at z=0, horizontal, normal +Z
/// Front = XZ plane at y=0, vertical facing camera, normal +Y
/// Right = YZ plane at x=0, vertical to the right, normal +X
///
/// The origin point can be selected by clicking it, then moved with a position gizmo (three
/// colored arrows for X/Y/Z), matching Onshape's interactable origin. Double-click to reset.
/// </summary>
internal sealed partial class EffigyViewport : Widget
{
private readonly SceneRenderingWidget _canvas;
private readonly CameraComponent _camera;
private readonly Gizmo.Instance _gizmoInstance;
private GameObject _modelObject;
private ModelRenderer _renderer;
/// <summary>Half-width a reference plane starts at, in world units. Each plane can be dragged
/// to its own size from there — see <see cref="_planeHalfSize"/>.</summary>
private const float PlaneSize = 128f;
/// <summary>
/// Half-width of each reference plane — Top, Front, Right — in world units.
///
/// PER PLANE rather than the single shared constant this used to be. A plane is a drawing
/// surface, and the one you are about to sketch on wants to be big enough to work on while the
/// other two want to be out of the way. One number could not do both.
/// </summary>
private readonly float[] _planeHalfSize = { PlaneSize, PlaneSize, PlaneSize };
/// <summary>How small a plane may be dragged. Below this the corner handles land on top of
/// each other and there is no way to grab one to make it big again.</summary>
private const float MinPlaneHalfSize = 8f;
/// <summary>
/// Radius of the origin handle dot, in SCREEN PIXELS.
///
/// It was four WORLD units, which is a different object at every scale: a boulder sitting in
/// the middle of a thirty-unit part, and invisible on a thousand-unit one. Onshape's origin is
/// a few pixels across at any zoom and that is what makes it a marker rather than geometry.
/// Same reasoning, and the same conversion, as the sketch snapping tolerances.
/// </summary>
private const float OriginHandlePixels = 2.5f;
/// <summary>The dot's radius in world units at its current distance from the camera.</summary>
private float OriginHandleRadius() => WorldRadiusAt( OriginPosition, OriginHandlePixels );
/// <summary>What a screen-pixel radius is worth in world units at some point in the scene. The
/// origin dot wanted this first; the plane corner handles want it at twelve more places, each
/// at its own distance from the camera.</summary>
private float WorldRadiusAt( Vector3 point, float pixels )
{
var distance = MathF.Max( (point - _camera.WorldPosition).Length, 0.01f );
var halfHeight = MathF.Tan( _camera.FieldOfView.DegreeToRadian() * 0.5f ) * distance;
return halfHeight / MathF.Max( _canvas.Size.y * 0.5f, 1f ) * pixels;
}
// --- origin state -----------------------------------------------------------------------
/// <summary>The origin's current world position. Reference planes and axis lines are drawn
/// relative to this, so dragging it shifts the whole coordinate frame.</summary>
public Vector3 OriginPosition { get; private set; } = Vector3.Zero;
/// <summary>True while the user is dragging the origin gizmo.</summary>
private bool _draggingOrigin;
/// <summary>Position the origin was at when a drag started, so the accumulated delta can be
/// added to the correct base — same pattern as RigViewport's _propDragStart.</summary>
private Vector3 _originDragStart;
/// <summary>Accumulated movement since drag began — the Position gizmo at Vector3.Zero
/// returns a per-frame displacement, so total drag is the sum.</summary>
private Vector3 _originDragDelta;
/// <summary>Whether the origin is selected (showing the position gizmo).</summary>
public bool OriginSelected { get; private set; }
/// <summary>Raised when the origin is moved, so the parameter panel can update.</summary>
public Action OriginMoved { get; set; }
/// <summary>Raised when the loaded model changes, so the status bar can update.</summary>
public Action<string> ModelInfoChanged { get; set; }
/// <summary>Raised when the origin selection state changes.</summary>
public Action<bool> OriginSelectionChanged { get; set; }
/// <summary>Current model stats for the status bar.</summary>
public string ModelInfo { get; private set; } = "";
// --- bone selection / drag state --------------------------------------------------------
/// <summary>Index of the currently selected bone in the rig skeleton, or -1 for none.</summary>
private int _selectedBoneIndex = -1;
/// <summary>What dragging the selected bone does. Rotate is the default because skeletal
/// animation rotates joints. Move translates, Scale adjusts bone length.</summary>
public enum BoneDragMode { Rotate, Move, Scale }
/// <summary>Current drag mode — E flips to the other mode while held.</summary>
private BoneDragMode _boneDragMode = BoneDragMode.Rotate;
/// <summary>True while a drag is in progress (mouse down and control reporting).</summary>
private bool _boneDragging;
/// <summary>The bone's world pose when the drag started — position, rotation, and length
/// are all captured here so live values are never fed back into themselves.</summary>
private Vector3 _dragStartPos;
private Rotation _dragStartRot;
private float _dragStartLength;
/// <summary>Accumulated position delta since drag began (for Move mode).</summary>
private Vector3 _moveDelta;
/// <summary>Raised when the selected bone changes from viewport interaction, so the rig
/// panel can sync its tree selection. The int is the bone index, or -1 for deselected.</summary>
public Action<int> BoneSelectionChanged { get; set; }
/// <summary>The cubemap the reflection probe lights off. s&box's own default scene uses this
/// one, which is the point — the reflections in the viewport are the reflections in game.</summary>
private const string SkyCubemap = "textures/cubemaps/default2.vtex";
private Color _backgroundColor = new( 0.82f, 0.84f, 0.86f, 1f );
/// <summary>
/// Viewport background, driven by the active palette.
///
/// This was an auto-property, and the camera read it exactly once - in this constructor,
/// before any palette had been applied. So every palette in the View menu changed this field
/// and nothing else, and all four themes rendered identically. The setter is the whole fix.
/// </summary>
public Color BackgroundColor
{
get => _backgroundColor;
set
{
_backgroundColor = value;
if ( _camera.IsValid() )
_camera.BackgroundColor = value;
}
}
/// <summary>
/// Chrome colour drawn over the viewport, driven by the active palette so it stays legible
/// against whatever the background happens to be.
///
/// This used to be the reference planes' grid colour, which is where the name comes from. The
/// planes are outlines only now and their outlines keep their per-axis hues — Top orange, Front
/// blue, Right green — because that is how you tell them apart. What is left on this is the
/// faded interior grid.
/// </summary>
public Color PlaneColor { get; set; } = new( 0.55f, 0.58f, 0.61f, 1f );
public bool OriginVisible { get; set; } = true;
public bool TopPlaneVisible { get; set; } = true;
public bool FrontPlaneVisible { get; set; } = true;
public bool RightPlaneVisible { get; set; } = true;
public Effigy.Skeleton RigSkeleton { get; set; }
public EffigyViewport( Widget parent ) : base( parent )
{
MinimumSize = 200;
Layout = Layout.Column();
_canvas = new SceneRenderingWidget( this );
_canvas.OnPreFrame += OnPreFrame;
_canvas.FocusMode = FocusMode.Click;
_canvas.Scene = Scene.CreateEditorScene();
using ( _canvas.Scene.Push() )
{
_camera = new GameObject( true, "camera" ).GetOrAddComponent<CameraComponent>( false );
_camera.BackgroundColor = BackgroundColor;
_camera.ZNear = 0.5f;
_camera.ZFar = 8192;
_camera.FieldOfView = 45f;
// Post processing is what runs the tonemapper below. Explicit rather than relying on
// the default, because everything under it is chosen to match a runtime scene and
// silently losing the tonemapper puts the washed-out look straight back.
_camera.EnablePostProcessing = true;
_camera.Enabled = true;
BuildRuntimeLighting();
_canvas.Camera = _camera;
}
_gizmoInstance = _canvas.GizmoInstance;
// Materials dragged out of the browser land here - see EffigyViewport.MaterialDrop.cs.
EnableMaterialDrops();
// The canvas is NOT added to the layout here - the tool strip has to go above it and does
// not exist yet. BuildToolbar calls CompleteLayout to fill this widget's existing column
// layout in the right order.
FrameCamera();
}
/// <summary>
/// Light the viewport the way a runtime scene is lit, so a material looks here like it looks
/// in game.
///
/// The rig this replaced was a light box: a full-strength white sun, a 0.6 fill from behind,
/// and a flat 0.6 ambient on top. That is roughly 2.2x white light on a face pointing at the
/// sun, with no tonemapper after it to roll the top end off, so a 0.5 grey rendered at about
/// 0.88 and every pastel arrived at the screen as near-white. Materials could be told apart in
/// it but not judged, which is no use once the preview is wearing the real vmats.
///
/// The values are the ones s&box's own default scene ships with — one sun slightly cool, a
/// dim neutral ambient, a cubemap for reflections — so "how it looks in Effigy" and "how it
/// looks in game" are answers to the same question.
/// </summary>
private void BuildRuntimeLighting()
{
// Key light. Kept at the viewport's own 45/45 rather than the template's angle: this one is
// aimed to read a part sitting on the origin from the default camera, and the colour is
// what makes the difference to a material, not the direction.
var sun = new GameObject( true, "sun" ).GetOrAddComponent<DirectionalLight>( false );
sun.WorldRotation = Rotation.From( 45, 45, 0 );
sun.LightColor = new Color( 0.914f, 0.980f, 1f, 1f );
// The legacy sky term, off. Ambient comes from the AmbientLight below — s&box's own
// tooltip on this property says to do it that way, and doubling the two is how the old rig
// ended up over-lit.
sun.SkyColor = Color.Black;
sun.Enabled = true;
// NO FILL LIGHT. A second sun with no shadow is what a photographer does to a subject, not
// what a game does to a prop, and it was the single biggest reason the unlit side of a part
// read a completely different colour here than in game.
var ambient = new GameObject( true, "ambient" ).GetOrAddComponent<AmbientLight>( false );
ambient.Color = new Color( 0.237f, 0.237f, 0.237f, 1f );
ambient.Enabled = true;
// The sky, for REFLECTIONS ONLY — deliberately a probe and not a SkyBox2D. A 2D sky takes
// over the camera's background (its own docs say the background colour applies only when
// there is no 2D sky in the scene), and the background here belongs to the View menu's
// palette. A probe pointed at the same cubemap lights off it without drawing it.
var sky = new GameObject( true, "sky" ).GetOrAddComponent<EnvmapProbe>( false );
sky.Texture = Texture.Load( SkyCubemap );
// Bounds are the probe's reach, and they have to cover wherever the camera can fly, which is
// the far plane. Everything else about the probe is left at its shipped default on purpose.
sky.Bounds = new BBox( new Vector3( -8192f ), new Vector3( 8192f ) );
sky.Enabled = true;
// Tonemapping, WITH AUTO EXPOSURE OFF. The tonemapper is the half that has to match: runtime
// rolls its highlights off and the viewport used to clip them, which is why a pastel came
// out white. Auto exposure is the half deliberately not copied — it re-exposes the shot as
// geometry appears and disappears, so the same material would render a different colour
// depending on what else happened to be on screen. That is the exact complaint this rig
// exists to answer, so exposure is pinned.
var tonemap = _camera.GameObject.GetOrAddComponent<Tonemapping>( false );
tonemap.AutoExposureEnabled = false;
tonemap.MinimumExposure = 1f;
tonemap.MaximumExposure = 1f;
tonemap.ExposureCompensation = 0f;
tonemap.Enabled = true;
}
// --- layout helpers ---------------------------------------------------------------------
/// <summary>The 3D canvas, exposed so the window can parent floating overlays (the ADD/REMOVE
/// strip, the sculpt number bar) onto it rather than into the layout.</summary>
public Widget Canvas => _canvas;
/// <summary>
/// Stack <paramref name="toolBar"/> above the canvas and give the canvas everything left, then
/// float <paramref name="resultOverlay"/> on top of it at the top-left. Called once from
/// BuildToolbar, after the bar is built.
///
/// THE TOOL BAR IS A LAYOUT ROW ABOVE THE CANVAS, and it took an argument to get there. The
/// note that used to sit here said a row "takes a band off the top of the viewport and paints
/// window chrome across it" and that parenting to the canvas instead let the 3D scene fill the
/// widget with the buttons sitting on it. The first half was true and the second half was not:
/// a widget that declines to paint keeps whatever was in the frame buffer, so the floating
/// strip had to fill its own rect with the viewport's clear colour and was an opaque band over
/// the model the whole time — a band that also sat exactly where a part's top-left corner is.
/// It cost the same pixels and covered the geometry as well. See EffigyStageBar.
///
/// The overlays that ARE still parented to the canvas are the ones that belong to the model
/// rather than to the toolset: the ADD/REMOVE strip and the sculpt number bar.
///
/// Note this fills the layout the constructor already made rather than assigning a fresh one.
/// It runs after DockManager.SetCentralWidget has sized the viewport, and replacing the layout
/// at that point orphans the canvas: it keeps whatever tiny geometry it had and renders the
/// whole 3D scene into a sliver, leaving the rest of the viewport black.
/// </summary>
public void CompleteLayout( Widget toolBar, Widget resultOverlay = null )
{
// The bar first, the canvas taking everything left. One bar, so there is no longer any
// question of two pieces of tool chrome being visible at once — that used to be enforced
// by three Visible flags nobody could see the state of.
if ( toolBar is not null )
Layout.Add( toolBar );
Layout.Add( _canvas, 1 );
// Still floating, and still parented to the canvas: this one is about the feature being
// EDITED rather than about which tool is armed, so it belongs next to the model.
if ( resultOverlay is not null )
{
_resultOverlay = resultOverlay;
resultOverlay.Position = OverlayMargin;
}
}
/// <summary>Inset of a floating overlay from the canvas's top-left corner.</summary>
private static readonly Vector2 OverlayMargin = new( 10f, 10f );
/// <summary>The floating overlays, so the frame loop can keep camera drags out of them. The
/// tool bar is not among them any more — it is a layout row outside the canvas, so the canvas
/// never reports the cursor as being over it in the first place.</summary>
private Widget _resultOverlay;
// --- model management -------------------------------------------------------------------
/// <summary>
/// Load a compiled .vmdl model into the viewport. Null clears the viewport.
///
/// Uses Model.Load on an asset path, same pattern as RigControlWindow's LoadAsset and
/// EffigyTool's own export path. The ModelRenderer (not SkinnedModelRenderer) is correct
/// here because Effigy produces static meshes — no bones, no animation.
/// </summary>
/// <param name="model">The model to show, or null to clear the viewport.</param>
/// <param name="frameCamera">Reframe to fit the new model. Off for a live rebuild: the
/// preview is regenerated on every slider tick, and snapping the camera back mid-drag makes
/// the part impossible to look at while you adjust it.</param>
public void SetModel( Model model, bool frameCamera = true )
{
using var scope = _canvas.Scene.Push();
_modelObject?.Destroy();
_modelObject = null;
_renderer = null;
if ( model is null )
{
ModelInfo = "";
ModelInfoChanged?.Invoke( ModelInfo );
return;
}
_modelObject = new GameObject( true, "effigy_model" );
_renderer = _modelObject.GetOrAddComponent<ModelRenderer>( false );
_renderer.Model = model;
_renderer.Enabled = true;
var meshCount = model.MeshCount;
var bounds = model.Bounds;
var size = bounds.Size;
// Say "units" outright and keep the fractions. This is the only place the part's real
// size is stated, so it is what settles an argument with whatever the surface happens
// to look like it is.
ModelInfo = $"{meshCount} mesh{(meshCount != 1 ? "es" : "")} · "
+ $"{size.x:0.##} × {size.y:0.##} × {size.z:0.##} units";
ModelInfoChanged?.Invoke( ModelInfo );
if ( frameCamera )
FrameCamera();
}
/// <summary>
/// Frame whatever is on screen from an isometric-ish front-right-top angle, like a fresh
/// Onshape document.
///
/// It has to FIT the model rather than sit at a fixed distance. Effigy's units are
/// dimensionless — a default Box is one unit on a side, next to reference planes 128 units
/// wide — so a fixed 320-unit pullback renders a freshly added primitive as a speck, which
/// reads as the button having done nothing.
/// </summary>
public void FrameCamera()
{
var dir = new Vector3( 1f, -1f, 0.65f ).Normal;
var center = Vector3.Zero;
// No model: frame the reference planes, which is all there is to look at.
var radius = PlaneSize * 1.25f;
if ( _renderer.IsValid() && _renderer.Model is { } model )
{
var bounds = model.Bounds;
center = bounds.Center;
// Half the diagonal, so the part fits from any angle. Floored because a zero-size
// body (a degenerate feature) would otherwise put the camera inside it.
radius = MathF.Max( bounds.Size.Length * 0.5f, 1f );
}
// Fit the bounding sphere in the vertical FOV, with a margin so it is not edge to edge.
var distance = radius / MathF.Tan( _camera.FieldOfView.DegreeToRadian() * 0.5f ) * 1.4f;
_camera.WorldPosition = center + dir * distance;
_camera.WorldRotation = Rotation.LookAt( -dir, Vector3.Up );
// A one-unit part needs to be able to get closer than the 0.5 near plane the planes want.
_camera.ZNear = Math.Clamp( distance * 0.01f, 0.01f, 8f );
}
// --- size reference ---------------------------------------------------------------------
/// <summary>
/// The stand-in body: the citizen from the base addon, so it is there for everyone rather than
/// being something of mine, and it is the same figure anything built here ends up standing next
/// to in a scene.
/// </summary>
public const string SizeReferenceModelPath = "models/citizen/citizen.vmdl";
private GameObject _referenceObject;
private bool _showSizeReference;
/// <summary>
/// Whether the citizen stands at the origin as a ruler.
///
/// EFFIGY'S UNITS DO NOT SAY HOW BIG ANYTHING IS. A default Box is one unit on a side and the
/// reference planes are 128 across, so a part on its own fills the view at every scale and looks
/// the same doing it — the status bar's numbers are the only answer to "how big is this", and a
/// number is not a size. A body of known height beside it is one: a door is a head taller than
/// the citizen, a mug is lost at its feet, and you can see which you have made.
/// </summary>
public bool ShowSizeReference
{
get => _showSizeReference;
set
{
if ( _showSizeReference == value )
return;
_showSizeReference = value;
UpdateSizeReference();
}
}
/// <summary>Height of the loaded reference in world units, or zero when none is loaded. Read by
/// the settings window, which prints it under the switch — the figure is only a ruler if it says
/// what it measures.</summary>
public float SizeReferenceHeight { get; private set; }
/// <summary>
/// Build or tear down the stand-in.
///
/// Destroyed rather than hidden when it is off. A hidden character still sits in a scene that
/// ticks every frame, and rebuilding it is one Model.Load off the asset cache the next time the
/// switch goes on.
/// </summary>
private void UpdateSizeReference()
{
using var scope = _canvas.Scene.Push();
if ( !_showSizeReference )
{
_referenceObject?.Destroy();
_referenceObject = null;
SizeReferenceHeight = 0f;
return;
}
if ( _referenceObject.IsValid() )
return;
var model = Model.Load( SizeReferenceModelPath );
if ( model is null || model.IsError )
{
// The citizen is a base addon, so this is close to impossible — but an unmounted one
// would otherwise leave the switch sitting on with nothing on screen and no reason
// given for it.
Log.Warning( $"Effigy: size reference model '{SizeReferenceModelPath}' could not be loaded." );
_showSizeReference = false;
SizeReferenceHeight = 0f;
return;
}
_referenceObject = new GameObject( true, "effigy_size_reference" );
// SKINNED, unlike the part. SetModel is right to use a plain ModelRenderer — Effigy makes
// static meshes — but citizen.vmdl is a rigged character, and the renderer that poses bones
// is the one that draws a rigged character standing up. Same component RigViewport uses on
// the same model.
var renderer = _referenceObject.GetOrAddComponent<SkinnedModelRenderer>( false );
renderer.Model = model;
renderer.Enabled = true;
SizeReferenceHeight = model.Bounds.Size.z;
PlaceSizeReference();
}
/// <summary>
/// Stand the figure on the origin, feet on the Top plane.
///
/// Every frame rather than once at load, because the origin handle is draggable and the planes
/// are drawn relative to it. A reference that stayed at the world origin while the coordinate
/// frame moved out from under it would be measuring against nothing.
/// </summary>
private void PlaceSizeReference()
{
if ( !_referenceObject.IsValid() )
return;
_referenceObject.WorldPosition = OriginPosition;
}
// --- origin interaction -----------------------------------------------------------------
/// <summary>Reset the origin back to (0,0,0). Called from double-click or parameter panel.</summary>
public void ResetOrigin()
{
OriginPosition = Vector3.Zero;
OriginMoved?.Invoke();
}
/// <summary>Set origin programmatically (from parameter panel number fields).</summary>
public void SetOrigin( Vector3 position )
{
OriginPosition = position;
OriginMoved?.Invoke();
}
/// <summary>
/// Draw the origin handle: a colored dot at the origin with a hitbox for selection, and a
/// position gizmo (three axis arrows) when selected.
///
/// Clicking the dot selects the origin, showing the gizmo. Dragging an arrow moves the origin
/// along that axis. The reference planes follow. Click empty space or press Escape to deselect.
/// </summary>
private void DrawOrigin()
{
if ( !OriginVisible )
return;
var radius = OriginHandleRadius();
using var scope = Gizmo.Scope( "origin", new Transform( OriginPosition ) );
// --- when selected: position gizmo first, so its handles take priority over the dot ---
if ( OriginSelected )
{
// Position gizmo: three colored arrows for X/Y/Z, world-aligned.
// Same pattern as RigViewport's DragReferenceProp — gizmo at Vector3.Zero,
// accumulate the per-frame displacement, add to the drag-start base position.
using var ctrlScope = Gizmo.Scope( "origin-control", new Transform( Vector3.Zero ) );
Gizmo.Hitbox.DepthBias = 0.01f;
if ( Gizmo.Control.Position( "origin-move", Vector3.Zero, out var displacement, Rotation.Identity ) )
{
if ( !_draggingOrigin )
{
_draggingOrigin = true;
_originDragStart = OriginPosition;
_originDragDelta = Vector3.Zero;
}
_originDragDelta += displacement;
OriginPosition = _originDragStart + _originDragDelta;
OriginMoved?.Invoke();
}
else if ( _draggingOrigin )
{
// Drag ended — the position is already final from the last frame's update
_draggingOrigin = false;
}
// Draw the dot larger and brighter when selected
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = new Color( 1f, 0.85f, 0.2f, 1f ); // bright yellow
Gizmo.Draw.SolidSphere( 0f, radius * 1.4f, 12, 12 );
Gizmo.Draw.IgnoreDepth = false;
return;
}
// --- not selected: draw the dot and check for click ---
// Draw origin dot — Onshape-style small circle
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = new Color( 1f, 0.85f, 0.2f, 0.85f ); // warm yellow
Gizmo.Draw.SolidSphere( 0f, radius, 10, 10 );
Gizmo.Draw.IgnoreDepth = false;
// Hitbox for selection — slightly larger than the visual dot for easier clicking
Gizmo.Hitbox.DepthBias = 0.01f;
Gizmo.Hitbox.Sphere( new Sphere( Vector3.Zero, radius * 2.8f ) );
if ( Gizmo.IsHovered )
{
// Highlight on hover
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = new Color( 1f, 0.85f, 0.2f, 0.35f );
Gizmo.Draw.SolidSphere( 0f, radius * 2.8f, 10, 10 );
Gizmo.Draw.IgnoreDepth = false;
if ( Gizmo.WasLeftMousePressed )
{
OriginSelected = true;
OriginSelectionChanged?.Invoke( true );
}
}
}
/// <summary>Deselect the origin — called from the window when clicking empty space or pressing
/// Escape.</summary>
public void DeselectOrigin()
{
if ( !OriginSelected )
return;
OriginSelected = false;
OriginSelectionChanged?.Invoke( false );
}
// --- reference planes -------------------------------------------------------------------
/// <summary>
/// Draws the three Onshape-style reference planes as wireframe rectangles intersecting at
/// the origin. Each plane gets its own faint color so you can tell them apart at a glance,
/// matching Onshape's convention:
///
/// Top (XY) — orange tint
/// Front (XZ) — blue tint
/// Right (YZ) — green tint
///
/// All three are drawn as outlined rectangles with edge subdivisions, like Onshape's
/// default plane visualization — faint enough not to compete with the model.
///
/// Planes follow OriginPosition — they are drawn relative to it, not at the world origin,
/// so dragging the origin shifts the entire coordinate frame.
/// </summary>
private void DrawReferencePlanes()
{
// Before anything is drawn, so a plane being dragged this frame is drawn at the size the
// cursor is asking for rather than one frame behind it.
UpdatePlaneResize();
var center = OriginPosition;
var s = PlaneSize;
// DEPTH-TESTED. The reference planes are 128 units across and were drawn straight through
// whatever the part is, so a finished solid had a grid laid over it and read as a glass
// box rather than as material. A plane behind the part now goes behind the part.
Gizmo.Draw.IgnoreDepth = false;
// OUTLINES BY DEFAULT, GRID ON REQUEST. Each plane used to be filled with an 8x8 lattice
// unconditionally, and three of those overlapping at the origin was most of what you saw on
// opening the editor: the part sat inside a wire cage. The outline alone says where a plane
// is and how big it is, which is all it has to say most of the time — but the lattice is a
// ruler when you want one, so it is a setting rather than a deletion. Edit > Settings.
var grid = PlaneColor.WithAlpha( PlaneColor.a * 0.5f );
for ( var index = 0; index < 3; index++ )
{
if ( !PlaneVisible( index ) )
continue;
var (right, up, colour) = PlaneAxes( index );
var half = _planeHalfSize[index];
DrawPlaneOutline( center, right, up, half, colour );
if ( !ShowPlaneGrid )
continue;
// A plane seen edge-on is a line, and its grid collapses into that line as a bright
// smear across everything behind it. Three planes meet at right angles, so from any
// camera angle at least one of them is close to edge-on and it was always the one
// making the middle of the view unreadable. Fading it out by how square-on it is means
// you only ever see the grids you are actually looking at.
var facing = MathF.Abs( Vector3.Dot( Vector3.Cross( right, up ), _camera.WorldRotation.Forward ) );
var viewFade = MathF.Min( facing / EdgeOnFade, 1f );
if ( viewFade <= 0.01f )
continue;
DrawPlaneGrid( center, right, up, half, DrawnGridStep( center, half ),
grid.WithAlpha( grid.a * viewFade ) );
}
// An offset sketch lives on a parallel plane, not on the origin reference plane. Keep the
// normal reference planes visible, but draw the active sketch plane where the sketch math
// actually places its geometry so the user never has to infer why it appears to float.
if ( IsSketching && ActiveSketch?.Plane is { } sketchPlane )
{
// The one plane that still draws through everything: you are working on it, and a
// sketch plane you cannot see because a body is in front of it is not usable.
Gizmo.Draw.IgnoreDepth = true;
var sketchCenter = center + new Vector3( sketchPlane.Origin.x, sketchPlane.Origin.y, sketchPlane.Origin.z );
var sketchX = new Vector3( sketchPlane.XAxis.x, sketchPlane.XAxis.y, sketchPlane.XAxis.z );
var sketchY = new Vector3( sketchPlane.YAxis.x, sketchPlane.YAxis.y, sketchPlane.YAxis.z );
var sketchColor = new Color( 0.95f, 0.82f, 0.25f, 0.65f );
// A SKETCH ON A FACE FILLS THAT FACE, not a rectangle around it. The plane is
// infinite; the square was only ever a stand-in for it, and on a face it was the
// wrong square - a fixed 128 units across, so it hung out past a small face and was
// swallowed by a large one. Either way the ruled paper stopped somewhere that means
// nothing, and the eye reads that edge as a boundary of the work. The face already
// has a boundary, drawn in green a few lines from here, so the grid is clipped to
// it and the two say one thing.
if ( !DrawSketchFaceGrid( sketchColor ) )
{
DrawPlaneOutline( sketchCenter, sketchX, sketchY, s, sketchColor );
if ( ShowPlaneGrid )
{
DrawPlaneGrid( sketchCenter, sketchX, sketchY, s, DrawnGridStep( sketchCenter, s ),
sketchColor.WithAlpha( 0.3f ) );
}
}
Gizmo.Draw.IgnoreDepth = false;
}
DrawPlaneCornerHandles();
if ( !OriginVisible )
{
Gizmo.Draw.IgnoreDepth = false;
DrawPlaneHitboxes();
DrawHoveredPlaneHighlight();
return;
}
// --- Origin axes (colored lines) ---
var axisLen = s * 0.35f;
Gizmo.Draw.LineThickness = 2f;
// X axis — red (forward)
Gizmo.Draw.Color = new Color( 0.9f, 0.25f, 0.25f, 0.7f );
Gizmo.Draw.Line( center, center + Vector3.Forward * axisLen );
// Y axis — green (left)
Gizmo.Draw.Color = new Color( 0.25f, 0.8f, 0.35f, 0.7f );
Gizmo.Draw.Line( center, center + Vector3.Left * axisLen );
// Z axis — blue (up)
Gizmo.Draw.Color = new Color( 0.3f, 0.45f, 0.9f, 0.7f );
Gizmo.Draw.Line( center, center + Vector3.Up * axisLen );
// Axis labels at the ends — using WorldText for 3D placement
Gizmo.Draw.Color = new Color( 0.9f, 0.25f, 0.25f, 0.8f );
Gizmo.Draw.WorldText( "X", new Transform( center + Vector3.Forward * (axisLen + 8f) ), "Roboto", 10f, TextFlag.Center );
Gizmo.Draw.Color = new Color( 0.25f, 0.8f, 0.35f, 0.8f );
Gizmo.Draw.WorldText( "Y", new Transform( center + Vector3.Left * (axisLen + 8f) ), "Roboto", 10f, TextFlag.Center );
Gizmo.Draw.Color = new Color( 0.3f, 0.45f, 0.9f, 0.8f );
Gizmo.Draw.WorldText( "Z", new Transform( center + Vector3.Up * (axisLen + 8f) ), "Roboto", 10f, TextFlag.Center );
Gizmo.Draw.LineThickness = 1f;
Gizmo.Draw.IgnoreDepth = false;
DrawPlaneHitboxes();
DrawHoveredPlaneHighlight();
}
/// <summary>Draw the four edges of a plane rectangle as a wireframe outline.</summary>
private static void DrawPlaneOutline( Vector3 center, Vector3 right, Vector3 up, float halfSize, Color color )
{
Gizmo.Draw.Color = color;
var a = center + right * halfSize + up * halfSize;
var b = center - right * halfSize + up * halfSize;
var c = center - right * halfSize - up * halfSize;
var d = center + right * halfSize - up * halfSize;
Gizmo.Draw.Line( a, b );
Gizmo.Draw.Line( b, c );
Gizmo.Draw.Line( c, d );
Gizmo.Draw.Line( d, a );
}
/// <summary>
/// The most grid lines a plane may draw across itself in one direction.
///
/// A CAP, NOT A DENSITY. Spacing is now a real distance in units rather than a count of
/// subdivisions, which means a fine grid on a plane dragged out to a thousand units asks for
/// tens of thousands of lines and takes the frame rate with it. Past this the step is widened
/// until it fits, so a grid that would be an unreadable smear is drawn coarse instead.
/// </summary>
private const int MaxGridLines = 160;
/// <summary>How square-on a plane has to be before its grid is at full strength — the cosine of
/// the angle between its normal and the view. Below this it fades out proportionally, reaching
/// nothing when exactly edge-on. 0.35 is about twenty degrees of tilt.</summary>
private const float EdgeOnFade = 0.35f;
/// <summary>
/// The step to draw a plane's lattice at — the same one the cursor snaps to, so the lines mean
/// something, widened if that would put more than <see cref="MaxGridLines"/> across the plane.
///
/// On Automatic the step comes from the camera: WorldRadiusAt with a one-pixel radius IS the
/// units-per-pixel at that point, which is exactly what AutoGridStep wants. That is why the
/// reference planes can have an adaptive grid outside a sketch, where there is no sketch plane
/// to measure against.
/// </summary>
private float DrawnGridStep( Vector3 center, float halfSize )
{
var step = GridStep( WorldRadiusAt( center, 1f ) );
if ( step <= 0f )
step = halfSize * 0.25f;
return MathF.Max( step, halfSize * 2f / MaxGridLines );
}
/// <summary>
/// Whether planes draw a grid inside their outline — the three reference planes AND the active
/// sketch plane, together.
///
/// ONE SWITCH FOR ALL FOUR. It governed only the sketch plane at first, which made it look
/// broken: the sketch plane is drawn only while a sketch is open, so flipping the setting
/// anywhere else changed nothing on screen and there was no way to tell that from a dead
/// control.
///
/// Snapping is unaffected either way — SketchSnapper rounds to a step it works out for itself
/// and never consults this — so turning the grid off means drawing against an invisible ruler.
/// </summary>
public bool ShowPlaneGrid { get; set; }
/// <summary>
/// Draw a plane's lattice, stepping OUT FROM THE CENTRE rather than in from one edge.
///
/// That is not cosmetic. Starting at -halfSize put the lines at whatever the plane's width
/// happened to leave over, so with a 1-unit spacing on a 128.5-unit plane none of them landed on
/// a whole number — the grid was half a unit off the coordinates the cursor was snapping to.
/// Walking out from zero puts every line on an exact multiple of the step, which is what makes
/// it the same grid the snap uses.
///
/// Two things keep three overlapping planes from reading as a wire cage. The centre lines are
/// skipped, because those are the origin axes and they are already drawn in their own colours —
/// three planes meeting at the origin were putting six coincident grey lines over three
/// coloured ones. And the lines fade as they get further out, so the lattice thins toward the
/// edge instead of ending in a hard grid to the last row.
/// </summary>
private static void DrawPlaneGrid( Vector3 center, Vector3 right, Vector3 up,
float halfSize, float step, Color color )
{
if ( step <= 0f || halfSize <= 0f )
return;
var count = (int)(halfSize / step);
for ( var i = 1; i <= count; i++ )
{
var offset = i * step;
// Quadratic rather than linear: a linear ramp still reads as a solid sheet most of the
// way out and then stops. This is near full weight around the origin, where the work
// happens, and a quarter of it at the rim.
var t = offset / halfSize;
var faded = color.WithAlpha( color.a * (1f - 0.75f * t * t) );
Gizmo.Draw.Color = faded;
foreach ( var sign in Signs )
{
var d = offset * sign;
Gizmo.Draw.Line( center + up * d - right * halfSize, center + up * d + right * halfSize );
Gizmo.Draw.Line( center + right * d - up * halfSize, center + right * d + up * halfSize );
}
}
}
/// <summary>
/// Fill the face a sketch is sitting on with a grid, clipped to that face's own outline.
///
/// THE FACE IS THE PAPER. A sketch derived from a face is about that face, and the ruled grid
/// is only useful where the face is - so it stops where the face stops, including around a
/// hole through it, rather than at the edge of an arbitrary rectangle. Crossings are counted
/// even-odd along each grid line, which is why a hole comes out as a gap for free: its rim is
/// in the outline like any other edge.
///
/// SAME STEP AS THE SNAP, through DrawnGridStep, and aligned to the sketch plane's origin
/// rather than to the face - so an intersection you can see is an intersection the cursor
/// lands on, which is the only reason to draw the lines at all.
/// </summary>
/// <returns>False when there is no face underneath - a sketch on a global plane - in which
/// case the caller falls back to drawing the plane as a rectangle.</returns>
private bool DrawSketchFaceGrid( Color color )
{
if ( ActiveSketchReference is not { IsEmpty: false } reference || ActiveSketch?.Plane is null )
return false;
// The face is handled, and the rectangle stays gone even with the grid switched off: the
// green outline already says where the face is, and the yellow square said nothing the
// switch was ever about.
if ( !ShowPlaneGrid )
return true;
var min = reference.Points[0];
var max = min;
foreach ( var p in reference.Points )
{
min = new Vec2( MathF.Min( min.x, p.x ), MathF.Min( min.y, p.y ) );
max = new Vec2( MathF.Max( max.x, p.x ), MathF.Max( max.y, p.y ) );
}
var half = MathF.Max( MathF.Max( max.x - min.x, max.y - min.y ) * 0.5f, 1e-4f );
var centre = (min + max) * 0.5f;
var step = DrawnGridStep( PlaneToWorld( centre ), half );
if ( step <= 0f )
return true;
Gizmo.Draw.LineThickness = 1f;
Gizmo.Draw.Color = color.WithAlpha( 0.3f );
DrawFaceGridLines( reference, min.x, max.x, step, true );
DrawFaceGridLines( reference, min.y, max.y, step, false );
return true;
}
/// <summary>One family of grid lines across a face - the ones at constant u when
/// <paramref name="alongX"/>, at constant v otherwise. Split out because the two directions
/// are the same walk with the components swapped, and writing it twice is how one of them
/// ends up subtly different.</summary>
private void DrawFaceGridLines( SketchReference reference, float low, float high, float step, bool alongX )
{
var first = (int)MathF.Ceiling( low / step );
var last = (int)MathF.Floor( high / step );
if ( last - first > MaxGridLines )
return;
var crossings = new List<float>();
for ( var i = first; i <= last; i++ )
{
var line = i * step;
crossings.Clear();
for ( var e = 0; e < reference.Edges.Count; e++ )
{
var (a, b) = reference.Segment( e );
var from = alongX ? a.x : a.y;
var to = alongX ? b.x : b.y;
// Half-open, so a vertex sitting exactly on the line is counted by one of its two
// edges rather than by both or neither. Both would pair the crossing with itself
// and leave the span beyond it unfilled.
if ( from <= line == to <= line )
continue;
var t = (line - from) / (to - from);
crossings.Add( alongX
? a.y + (b.y - a.y) * t
: a.x + (b.x - a.x) * t );
}
if ( crossings.Count < 2 )
continue;
crossings.Sort();
// In pairs: inside the face between the first and second crossing, outside between the
// second and third, and so on around a hole and back.
for ( var c = 0; c + 1 < crossings.Count; c += 2 )
{
var a = alongX ? new Vec2( line, crossings[c] ) : new Vec2( crossings[c], line );
var b = alongX ? new Vec2( line, crossings[c + 1] ) : new Vec2( crossings[c + 1], line );
Gizmo.Draw.Line( PlaneToWorld( a ), PlaneToWorld( b ) );
}
}
}
/// <summary>Both sides of the centre line, walked together so each pair shares one alpha.
/// </summary>
private static readonly float[] Signs = { 1f, -1f };
/// <summary>Whether a plane index — 0 Top, 1 Front, 2 Right — is currently shown.</summary>
private bool PlaneVisible( int index ) => index switch
{
0 => TopPlaneVisible,
1 => FrontPlaneVisible,
_ => RightPlaneVisible,
};
/// <summary>
/// The two in-plane axes and the edge colour for a plane index, in Onshape's convention:
/// Top (XY) orange, Front (XZ) blue, Right (YZ) green.
///
/// One definition rather than the three switch statements the outline, the hover wash and the
/// corner handles each used to carry. They disagreed once already — the hover wash is the
/// reason DrawPlaneHitboxes lives beside the wireframe rather than apart from it.
/// </summary>
private static (Vector3 Right, Vector3 Up, Color Colour) PlaneAxes( int index ) => index switch
{
0 => (Vector3.Forward, Vector3.Left, new Color( 0.85f, 0.55f, 0.25f, 0.55f )),
1 => (Vector3.Forward, Vector3.Up, new Color( 0.25f, 0.5f, 0.85f, 0.55f )),
_ => (Vector3.Left, Vector3.Up, new Color( 0.25f, 0.78f, 0.45f, 0.55f )),
};
// --- resizing a plane by its corners --------------------------------------------------------
/// <summary>Radius of a plane's corner handle in SCREEN PIXELS, for the same reason the origin
/// dot is measured that way: a world-unit handle is a boulder on a small part and invisible on
/// a large one.</summary>
private const float PlaneCornerPixels = 5f;
/// <summary>Which plane is being resized right now, or -1. Held across frames because a drag
/// is a gesture, not an event — the cursor leaves the handle the moment it starts moving.
/// </summary>
private int _resizingPlane = -1;
/// <summary>
/// A grab handle at each corner of each plane, shown only when the cursor is on it.
///
/// HOVER-ONLY because twelve permanent dots around the origin is the clutter the grid was just
/// taken out for. The hitbox is always registered — it has to be, or there would be nothing to
/// hover — but nothing is drawn until the cursor finds it, and then the corner being dragged
/// stays lit for as long as the drag lasts.
///
/// Not while sketching or while a plane is armed for picking: in both of those a click on a
/// plane already means something, and a handle sitting on the corner would eat it.
/// </summary>
private void DrawPlaneCornerHandles()
{
if ( IsSketching || PlanePickMode )
return;
var center = OriginPosition;
for ( var index = 0; index < 3; index++ )
{
if ( !PlaneVisible( index ) )
continue;
var (right, up, colour) = PlaneAxes( index );
var half = _planeHalfSize[index];
for ( var corner = 0; corner < 4; corner++ )
{
// 0 (+,+), 1 (-,+), 2 (-,-), 3 (+,-) — the same walk around the rectangle
// DrawPlaneOutline makes, so a handle always sits on a drawn corner.
var x = corner is 0 or 3 ? half : -half;
var y = corner is 0 or 1 ? half : -half;
var position = center + right * x + up * y;
var radius = WorldRadiusAt( position, PlaneCornerPixels );
using var scope = Gizmo.Scope( $"plane-corner-{index}-{corner}", new Transform( position ) );
Gizmo.Hitbox.DepthBias = 0.01f;
Gizmo.Hitbox.Sphere( new Sphere( Vector3.Zero, radius * 2f ) );
var dragging = _resizingPlane == index;
if ( !Gizmo.IsHovered && !dragging )
continue;
// Through everything, including the part. A handle you cannot see because the solid
// you are building sits in front of it is a handle you cannot grab.
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = colour.WithAlpha( dragging ? 0.9f : 0.5f );
Gizmo.Draw.SolidSphere( 0f, radius, 10, 10 );
Gizmo.Draw.IgnoreDepth = false;
if ( Gizmo.IsHovered && Gizmo.WasLeftMousePressed )
_resizingPlane = index;
}
}
}
/// <summary>
/// Carry a corner drag, sizing the plane to wherever the cursor is on it.
///
/// The cursor is put back on the plane rather than tracked in screen space, so the corner stays
/// under the pointer at any camera angle — the same ray-into-plane projection sketching uses to
/// place a point (CursorToPlane). The new half-size is the LARGER of the two in-plane distances,
/// which keeps the plane square the way it has always been drawn; the corner therefore tracks
/// the cursor exactly along a diagonal and approximately elsewhere, which is what a square
/// constraint costs.
/// </summary>
private void UpdatePlaneResize()
{
if ( _resizingPlane < 0 )
return;
// Released anywhere, over the handle or not. A drag that only ended when the cursor
// happened to be back on the corner would never end.
if ( !Gizmo.IsLeftMouseDown )
{
_resizingPlane = -1;
return;
}
var (right, up, _) = PlaneAxes( _resizingPlane );
var normal = Vector3.Cross( right, up );
var ray = Gizmo.CurrentRay;
var denom = Vector3.Dot( ray.Forward, normal );
// Edge-on: the plane is a line from here and there is no meaningful hit. Hold the size it
// had rather than snapping it to something arbitrary.
if ( MathF.Abs( denom ) < 1e-5f )
return;
var t = Vector3.Dot( OriginPosition - ray.Position, normal ) / denom;
if ( t <= 0f )
return;
var offset = ray.Position + ray.Forward * t - OriginPosition;
var half = MathF.Max( MathF.Abs( Vector3.Dot( offset, right ) ), MathF.Abs( Vector3.Dot( offset, up ) ) );
_planeHalfSize[_resizingPlane] = MathF.Max( half, MinPlaneHalfSize );
}
// --- standard views ----------------------------------------------------------------------
/// <summary>Named camera poses, reachable from the View menu. A fly camera does not need a
/// corner cube to stay oriented, but snapping to a plane is still useful.</summary>
public enum StandardView
{
Top,
Bottom,
Front,
Back,
Left,
Right,
Isometric,
}
/// <summary>
/// Point the camera down a named axis, keeping whatever the current framing distance is.
///
/// s&box is +x forward, +y left, +z up, so "Front" looks along -x at the XZ plane and "Right"
/// looks along +y at the YZ plane — matching how DrawReferencePlanes names the same three.
/// </summary>
public void SetStandardView( StandardView view )
{
var dir = view switch
{
StandardView.Top => new Vector3( 0f, 0f, 1f ),
StandardView.Bottom => new Vector3( 0f, 0f, -1f ),
StandardView.Front => new Vector3( 1f, 0f, 0f ),
StandardView.Back => new Vector3( -1f, 0f, 0f ),
StandardView.Left => new Vector3( 0f, 1f, 0f ),
StandardView.Right => new Vector3( 0f, -1f, 0f ),
_ => new Vector3( 1f, -1f, 0.65f ).Normal,
};
// Looking straight down needs an up vector that is not also straight down.
var up = MathF.Abs( dir.z ) > 0.99f ? Vector3.Forward : Vector3.Up;
PointCameraAt( CurrentFocus(), dir, up );
}
/// <summary>
/// Look square at the active sketch plane — Onshape's N.
///
/// This is bound to a key rather than fired on sketch entry on purpose. Onshape does NOT
/// rotate the view when you pick a plane (automating it is a standing request on their forum,
/// not shipped behaviour), and taking the camera away from someone who deliberately set up a
/// three-quarter view to sketch against existing geometry is worse than one keypress.
/// </summary>
public void ViewNormalToSketchPlane()
{
if ( ActiveSketch?.Plane is not { } plane )
return;
var normal = ToWorldDir( plane.Normal );
var up = ToWorldDir( plane.YAxis );
var centre = OriginPosition + ToWorldDir( plane.Origin );
// Second press flips to the far side, the way Onshape's N does.
if ( Vector3.Dot( _camera.WorldPosition - centre, normal ) > 0f )
normal = -normal;
PointCameraAt( centre, normal, up );
}
/// <summary>What the camera is currently looking at, so a view change rotates around the part
/// rather than throwing it off screen.</summary>
private Vector3 CurrentFocus()
{
if ( _renderer.IsValid() && _renderer.Model is { } model )
return model.Bounds.Center;
return Vector3.Zero;
}
private void PointCameraAt( Vector3 focus, Vector3 direction, Vector3 up )
{
var distance = (_camera.WorldPosition - focus).Length;
// A camera sitting exactly on the focus has no distance to preserve, which happens before
// anything has been framed. Fall back to the reference planes' own scale.
if ( distance < 1f )
distance = PlaneSize * 1.25f;
_camera.WorldPosition = focus + direction.Normal * distance;
_camera.WorldRotation = Rotation.LookAt( -direction.Normal, up );
}
// --- per-frame tick ---------------------------------------------------------------------
/// <summary>Whether the cursor is over the 3D canvas and not driving the camera. Read by the
/// sketch pass, which has no hitbox of its own to hover.</summary>
private bool _canvasHasCursor;
private void OnPreFrame()
{
if ( _canvas.Scene is { } scene )
scene.EditorTick( RealTime.Now, RealTime.Delta );
// The floating overlays sit inside the canvas, so "cursor over the canvas" is true while
// you are aiming at one. Without excluding them, pressing a control also grabs the orbit
// camera, the click drags the view, and sketch tools place points on the plane.
var overAnyOverlay = (_resultOverlay?.IsUnderMouse ?? false)
|| (_sculptBarOverlay?.IsUnderMouse ?? false);
var overCanvas = _canvas.IsUnderMouse && !overAnyOverlay;
_gizmoInstance.Input.IsHovered = IsActiveWindow && overCanvas;
var flying = _gizmoInstance.FirstPersonCamera( _camera, _canvas );
if ( flying )
_gizmoInstance.Input.IsHovered = false;
// Whether this right-press has actually moved the view yet — see EffigyViewport.FaceMenu.cs,
// which uses it to tell a right-click apart from the end of an orbit.
NoteCameraFlight( flying );
// After FirstPersonCamera has had its say this means "the cursor is over the canvas and we
// are not flying the camera" - which is exactly the condition a sketch click needs. Without
// it, left-dragging to orbit scatters points across the plane.
_canvasHasCursor = _gizmoInstance.Input.IsHovered;
_canvas.UpdateGizmoInputs( _gizmoInstance.Input.IsHovered );
// Held for the right-click menu, which has no frame of its own to build a ray in.
CaptureCursorRay();
// BEFORE the planes, not with the rest of the picking below. The planes decide whether to
// take this click by comparing against the face under the cursor, so the face has to be
// known by the time they ask — see ResolveFacePick.
ResolveFacePick();
// The stand-in follows the origin handle, so it moves in the same frame the handle does.
PlaceSizeReference();
// Draw planes first (behind everything else)
DrawReferencePlanes();
DrawCommittedSketches();
ShadeMaterialSlotsFrame();
MaterialDropFrame();
SketchPickFrame();
FacePickFrame();
BodyPickFrame();
DrawRigSkeleton();
BoneToolFrame();
SketchFrame();
SculptFrame();
// AFTER SketchFrame and outside it, because SketchFrame returns early when no sketch is
// open and "no sketch is open" is one of the answers the probe exists to give. Off unless
// `effigy_probe_sketch 1` has been run.
SketchProbe();
// Origin on top of the planes. Hidden while sketching or picking anything - it sits at the
// exact spot most first clicks land, and stealing them was the first thing that broke.
if ( !IsSketching && !PlanePickMode && !SketchPickMode && !BodyPickMode && !BoneToolActive )
{
DrawOrigin();
// Click empty space to deselect origin
if ( Gizmo.WasLeftMousePressed && !Gizmo.IsHovered && OriginSelected )
DeselectOrigin();
}
// BoneToolActive and BodyPickMode: the same "you can click here" signal every other live
// pick mode already gets from Gizmo.HasHovered/_hoveredSketchId/_hoveredFaceBodyId. Without
// it, placing a bone or assigning a body was the only click-to-act mode in the whole tool
// that left the cursor a plain arrow the entire time.
Cursor = Gizmo.HasHovered || IsSketching || _hoveredSketchId is not null || _hoveredFaceBodyId is not null
|| BoneToolActive || BodyPickMode
? CursorShape.Finger : CursorShape.Arrow;
}
/// <summary>Draw all bones as dog-bone shapes with selection and pose gizmo.</summary>
private void DrawRigSkeleton()
{
if ( RigSkeleton is null || RigSkeleton.Count == 0 )
return;
// The selected bone's gizmo needs IgnoreDepth=false to match normal editor gizmos.
Gizmo.Draw.IgnoreDepth = true;
for ( var i = 0; i < RigSkeleton.Count; i++ )
DrawBoneHandle( i );
// The selected bone's gizmo runs after the loop, in its own scope, so its hitboxes
// do not fight with the bone hitboxes.
if ( _selectedBoneIndex >= 0 && _selectedBoneIndex < RigSkeleton.Count )
DrawSelectedBoneGizmo();
Gizmo.Draw.IgnoreDepth = false;
// Click empty space to deselect — AFTER the gizmo so Gizmo.HasHovered covers both
// our bone hitboxes AND the gizmo control's own hitboxes. Using !Gizmo.IsHovered
// here was the bug: IsHovered only sees Hitbox.Sphere calls, not Control hitboxes,
// so clicking the gizmo counted as empty space and deselected immediately.
if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered && _selectedBoneIndex >= 0 && !_boneDragging )
{
_selectedBoneIndex = -1;
BoneSelectionChanged?.Invoke( -1 );
}
}
/// <summary>Base radius of a bone's head sphere in world units.</summary>
private const float BoneHandleRadius = 0.8f;
/// <summary>Draw one bone as a dog-bone: a knobby ball at the head, a knobby ball at the
/// tail, and a thin shaft between them.</summary>
private void DrawBoneHandle( int index )
{
var world = RigSkeleton.WorldBind( index );
var bone = RigSkeleton.Bones[index];
var head = new Vector3( world.Origin.x, world.Origin.y, world.Origin.z );
var tailVec = world.TransformPoint( new Vec3( 0, bone.Length, 0 ) );
var tail = new Vector3( tailVec.x, tailVec.y, tailVec.z );
// Cross-section axes from the Xform basis — shows roll of the bone.
var xAxis = new Vector3( world.X.x, world.X.y, world.X.z );
var zAxis = new Vector3( world.Z.x, world.Z.y, world.Z.z );
var isSelected = index == _selectedBoneIndex;
Gizmo.Draw.Color = isSelected
? new Color( 1f, 0.85f, 0.2f, 1f )
: new Color( 0.95f, 0.35f, 0.2f, 0.8f );
DrawDogBone( head, tail, xAxis, zAxis );
// No hitbox on the selected bone — its own gizmo handles registration.
if ( isSelected )
return;
// While placing new bones, an existing bone's hitbox would steal the click instead of
// letting it land on the mesh underneath.
if ( BoneToolActive )
return;
Gizmo.Hitbox.DepthBias = 0.01f;
Gizmo.Hitbox.Sphere( new Sphere( head, BoneHandleRadius * 2.5f ) );
if ( Gizmo.IsHovered )
{
Gizmo.Draw.Color = new Color( 1f, 0.85f, 0.2f, 0.35f );
Gizmo.Draw.SolidSphere( head, BoneHandleRadius * 2.5f, 8, 8 );
if ( Gizmo.WasLeftMousePressed )
{
_selectedBoneIndex = index;
BoneSelectionChanged?.Invoke( index );
}
}
}
/// <summary>
/// Pose gizmo for the selected bone. Mode is set by W (move), E (rotate), R (scale).
/// Follows RigViewport's pattern: Position gives per-frame delta (accumulate), Rotate
/// gives cumulative-since-grab (assign). The start pose is captured once and everything
/// is applied to that — the live transform is never fed back.
/// </summary>
private void DrawSelectedBoneGizmo()
{
var world = RigSkeleton.WorldBind( _selectedBoneIndex );
var bone = RigSkeleton.Bones[_selectedBoneIndex];
var head = new Vector3( world.Origin.x, world.Origin.y, world.Origin.z );
var headRot = ExtractRotation( world );
var startPos = _boneDragging ? _dragStartPos : head;
var startRot = _boneDragging ? _dragStartRot : headRot;
using var scope = Gizmo.Scope( $"BoneCtrl{_selectedBoneIndex}", new Transform( startPos, startRot ) );
Gizmo.Hitbox.DepthBias = 0.01f;
switch ( _boneDragMode )
{
case BoneDragMode.Rotate:
{
if ( !Gizmo.Control.Rotate( $"bone{_selectedBoneIndex}-rot", Rotation.Identity, out var rotation ) )
{
EndBoneDragIfReleased();
return;
}
BeginBoneDrag( head, headRot, bone.Length );
_boneDragging = true;
// Rotate is CUMULATIVE since the grab — assign, don't accumulate.
var newRot = rotation * _dragStartRot;
ApplyBoneTransform( _selectedBoneIndex, _dragStartPos, newRot, _dragStartLength );
break;
}
case BoneDragMode.Move:
{
if ( !Gizmo.Control.Position( $"bone{_selectedBoneIndex}-pos", Vector3.Zero, out var delta, Rotation.Identity ) )
{
EndBoneDragIfReleased();
return;
}
BeginBoneDrag( head, headRot, bone.Length );
_boneDragging = true;
// Position is PER-FRAME DELTA — accumulate.
_moveDelta += delta;
ApplyBoneTransform( _selectedBoneIndex, _dragStartPos + _moveDelta, _dragStartRot, _dragStartLength );
break;
}
case BoneDragMode.Scale:
{
// Scale adjusts bone length via a vertical drag.
if ( !Gizmo.Control.Position( $"bone{_selectedBoneIndex}-scl", Vector3.Zero, out var delta, Rotation.Identity ) )
{
EndBoneDragIfReleased();
return;
}
BeginBoneDrag( head, headRot, bone.Length );
_boneDragging = true;
// Use the vertical (Y) component of the drag as the length change.
var localDelta = _dragStartRot.Inverse * delta;
var newLength = MathF.Max( _dragStartLength + localDelta.y, 0.5f );
ApplyBoneTransform( _selectedBoneIndex, _dragStartPos, _dragStartRot, newLength );
break;
}
}
}
private void BeginBoneDrag( Vector3 head, Rotation headRot, float length )
{
if ( _boneDragging )
return;
_boneDragging = true;
_dragStartPos = head;
_dragStartRot = headRot;
_dragStartLength = length;
_moveDelta = Vector3.Zero;
}
private void EndBoneDragIfReleased()
{
if ( Gizmo.IsLeftMouseDown )
return;
_boneDragging = false;
}
/// <summary>
/// A literal dog-bone: a knobby ball at each end, joined by a thin shaft. This is the
/// shape a "bone" reads as at a glance, unlike Blender's tapering-diamond convention
/// which this replaces.
/// </summary>
private static void DrawDogBone( Vector3 head, Vector3 tail, Vector3 xAxis, Vector3 zAxis )
{
var boneDir = tail - head;
var boneLen = boneDir.Length;
if ( boneLen < 0.01f )
return;
var axis = boneDir / boneLen;
// The knobs are wider than the shaft — that contrast is what makes the shape read
// as a bone rather than a dumbbell bar. Inset the shaft so it disappears inside the
// knobs rather than poking out past them.
var knobR = boneLen * 0.16f;
var shaftR = knobR * 0.35f;
var inset = knobR * 0.6f;
Gizmo.Draw.SolidSphere( head, knobR, 8, 8 );
Gizmo.Draw.SolidSphere( tail, knobR, 8, 8 );
var shaftStart = head + axis * inset;
var shaftEnd = tail - axis * inset;
if ( (shaftEnd - shaftStart).Length > 0.01f )
DrawShaft( shaftStart, shaftEnd, xAxis, zAxis, shaftR );
}
/// <summary>A thin cylinder between two points, wound both ways per face so it reads
/// solid from either side — same trick the old diamond body used.</summary>
private static void DrawShaft( Vector3 a, Vector3 b, Vector3 xAxis, Vector3 zAxis, float radius, int segments = 8 )
{
for ( var i = 0; i < segments; i++ )
{
var t0 = i / (float)segments * MathF.Tau;
var t1 = (i + 1) / (float)segments * MathF.Tau;
var o0 = xAxis * (MathF.Cos( t0 ) * radius) + zAxis * (MathF.Sin( t0 ) * radius);
var o1 = xAxis * (MathF.Cos( t1 ) * radius) + zAxis * (MathF.Sin( t1 ) * radius);
var a0 = a + o0;
var a1 = a + o1;
var b0 = b + o0;
var b1 = b + o1;
Gizmo.Draw.SolidTriangle( a0, b0, a1 );
Gizmo.Draw.SolidTriangle( a1, b0, b1 );
Gizmo.Draw.SolidTriangle( a0, a1, b0 );
Gizmo.Draw.SolidTriangle( a1, b1, b0 );
}
}
/// <summary>Extract an s&box Rotation from an Effigy Xform's basis columns.
/// Xform.Y is bone forward (+Y convention), Xform.Z is bone up.</summary>
private static Rotation ExtractRotation( Xform xform )
{
var forward = new Vector3( xform.Y.x, xform.Y.y, xform.Y.z );
var up = new Vector3( xform.Z.x, xform.Z.y, xform.Z.z );
return Rotation.LookAt( forward, up );
}
/// <summary>
/// Write a world-space pose back into the skeleton. Updates position, orientation, and
/// optionally length of the bone, converting back to parent-local space. Children follow
/// automatically because their Local transforms are relative.
/// </summary>
private void ApplyBoneTransform( int index, Vector3 newHeadWorld, Rotation newWorldRot, float newLength )
{
var bone = RigSkeleton.Bones[index];
// Decompose the new world rotation into the Xform basis columns.
var fwd = newWorldRot.Forward;
var right = newWorldRot.Right;
var up = newWorldRot.Up;
var newX = new Vec3( right.x, right.y, right.z );
var newY = new Vec3( fwd.x, fwd.y, fwd.z );
var newZ = new Vec3( up.x, up.y, up.z );
var newOrigin = new Vec3( newHeadWorld.x, newHeadWorld.y, newHeadWorld.z );
if ( bone.Parent < 0 )
{
bone.Local = new Xform( newX, newY, newZ, newOrigin );
}
else
{
var parentWorld = RigSkeleton.WorldBind( bone.Parent );
var inv = parentWorld.Inverse;
bone.Local = new Xform(
inv.TransformDirection( newX ),
inv.TransformDirection( newY ),
inv.TransformDirection( newZ ),
inv.TransformPoint( newOrigin ) );
}
bone.Length = newLength;
// Fires every frame of a drag, not just on release — a numeric inspector reading these
// same bones live is the reason: without this it goes stale the instant a drag starts and
// stays wrong until the bone is reselected, which is worse than not showing numbers at all.
BonePosed?.Invoke( index );
}
/// <summary>Raised whenever the pose gizmo writes a new transform into a bone — see
/// ApplyBoneTransform. Carries the bone's index so a listener only watching one bone (an
/// inspector panel, say) can ignore edits to any other.</summary>
public Action<int> BonePosed { get; set; }
/// <summary>Deselect the bone — called from the rig panel or Escape key.</summary>
public void DeselectBone()
{
if ( _selectedBoneIndex < 0 )
return;
_selectedBoneIndex = -1;
_boneDragging = false;
BoneSelectionChanged?.Invoke( -1 );
}
/// <summary>Select a bone by index — called from the rig panel's tree view. Does not
/// invoke the BoneSelectionChanged callback to avoid feedback loops.</summary>
public void SelectBone( int index )
{
_selectedBoneIndex = index >= 0 && index < RigSkeleton?.Count ? index : -1;
}
/// <summary>Escape backs out of the half-drawn entity, then out of the tool - the same two
/// stages every CAD sketcher uses. W/E/R switch bone drag modes when a bone is selected.</summary>
protected override void OnKeyPress( KeyEvent e )
{
// A dimension box up on screen owns the keyboard first - digits, Enter and its own Escape.
// It has to come before the Escape branch below or dismissing the number would also back
// out of the tool you are drawing with.
if ( HandleDimensionKey( e ) )
return;
// Sculpting owns X and M while it is running, and owns nothing at all when it is not.
if ( HandleSculptKey( e ) )
return;
// The spline is the one tool with no fixed number of clicks, so Enter is how it ends. After
// the dimension box, which owns Enter whenever it is up.
if ( HandleSketchToolKey( e ) )
return;
// W/E/R switch bone drag mode while a bone is selected.
if ( _selectedBoneIndex >= 0 )
{
switch ( e.Key )
{
case KeyCode.W:
_boneDragMode = BoneDragMode.Move;
e.Accepted = true;
return;
case KeyCode.E:
_boneDragMode = BoneDragMode.Rotate;
e.Accepted = true;
return;
case KeyCode.R:
_boneDragMode = BoneDragMode.Scale;
e.Accepted = true;
return;
}
}
if ( e.Key != KeyCode.Escape )
{
base.OnKeyPress( e );
return;
}
if ( IsSketching )
{
// A selection is the shallower thing to back out of, so Escape drops it first and only
// cancels the tool on a second press. Otherwise picking three things and hitting Escape
// to undo the third would abandon the tool as well.
if ( HasSketchSelection )
ClearSketchSelection();
else
CancelSketchTool();
e.Accepted = true;
return;
}
// Escape stands down an armed selection box. The viewport owns the key press; the dialog
// owns the boxes' painted state, so it is told through PickModeCancelled. Sketch picking
// itself stays live while a consumer dialog is open — the dialog turns it off.
if ( PlanePickMode || SketchPickMode || BodyPickMode )
{
PlanePickMode = false;
BodyPickMode = false;
PickModeCancelled?.Invoke();
e.Accepted = true;
return;
}
// Same two-stage back-out as the sketch tools: the panel owns which stage it is, since it
// is the one holding whether a chain is currently open.
if ( BoneToolActive )
{
BoneToolEscape?.Invoke();
e.Accepted = true;
return;
}
if ( _selectedBoneIndex >= 0 )
{
DeselectBone();
e.Accepted = true;
return;
}
if ( OriginSelected )
{
DeselectOrigin();
e.Accepted = true;
}
}
}