Editor/EffigyEditor/EffigyViewport.cs

Editor viewport widget for the Effigy editor. Renders a 3D scene with camera, reference planes, origin gizmo, plane corner handles, model and drag previews, lighting and UI overlay placement, plus camera framing and standard view controls.

File AccessNative Interop
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&amp;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>A second, temporary model rendered while a Transform drags a SUBSET of the bodies.
	/// The main model keeps the static bodies; this holds the moving ones and its transform is
	/// rewritten each frame, so the drag is a pure GPU transform instead of a per-frame upload.</summary>
	private GameObject _dragObject;
	private ModelRenderer _dragRenderer;

	/// <summary>The Model the renderer is currently drawing, or null. The window's preview keeps an
	/// in-place-updateable copy (see EffigyPreview.LivePreview) and checks this before rewriting it —
	/// a pose preview, a compiled export or a sculpt/paint session can swap the renderer to a
	/// different model, and updating the stale copy then would leave the screen showing nothing.</summary>
	public Model CurrentModel => _renderer.IsValid() ? _renderer.Model : null;

	/// <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>A render-side clip plane through the origin along +X. Nothing is cut in the
	/// history; only the preview is clipped, so a shelled interior can be looked at.</summary>
	public bool SectionEnabled { get; set; }

	public Vec3 SectionOrigin => new( OriginPosition.x, OriginPosition.y, OriginPosition.z );

	public Vec3 SectionNormal => new( 1f, 0f, 0f );

	/// <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>
	/// Set the drag mode for the selected bone, or do nothing when none is selected — the W/E/R
	/// shortcuts call this, and a mode with no bone to apply it to is a key that did nothing.
	/// </summary>
	public void SetBoneDragMode( BoneDragMode mode )
	{
		if ( _selectedBoneIndex < 0 )
			return;

		_boneDragMode = mode;
	}

	/// <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&amp;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; }

	/// <summary>
	/// Whether the skeleton is drawn — and, with it, whether bones can be hovered or clicked.
	///
	/// TIED TO THE RIG DOCK BY THE WINDOW, which passes its panel's visibility in here. Closing the
	/// tree is how you say "I am done looking at the rig"; leaving a cage of bones over the model
	/// after that is the tool ignoring you, and worse, the bones keep eating clicks meant for the
	/// mesh. Gated in one place each for drawing (DrawRigSkeleton) and picking
	/// (TryPickBoneUnderCursor) so nothing can see a bone the eye cannot.
	/// </summary>
	public Func<bool> RigVisible { get; set; }

	private bool BonesShown => RigVisible?.Invoke() ?? true;

	/// <summary>
	/// The Rig workspace is open, so BONES ARE THE ONLY THING IN HERE THAT CAN BE CLICKED.
	///
	/// WHY THIS HAS TO EXIST. A bone's hit target is a handful of spheres strung along a shape a
	/// few units thick. The part it sits on is a wall of triangles filling the screen behind it,
	/// and idle selection picks a face from wherever the cursor is, so the face wins essentially
	/// everywhere the bone is not. Worse, it wins INVISIBLY: the face lights up, the bone does not,
	/// and the click lands on the face - so the rig looks unresponsive rather than obstructed. The
	/// origin handle, the lamps and the face-drag arrow are the same problem in miniature.
	///
	/// Turning the competition off is the only fix that holds. Making the bone hitboxes bigger
	/// helps and is done too (see DrawBoneHandle), but it is a race that cannot be won by degrees:
	/// any bone inside the silhouette of the part still has a face behind every pixel of it.
	///
	/// NOT A NEW KIND OF STATE. It is the workspace, pushed down to the one place that has to act
	/// on it - see EffigyWindow.Workspaces.cs, which sets it from BarMode and nowhere else.
	/// </summary>
	public bool RigMode { 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>
	/// Build the studio lighting rig — one sun, a dim ambient, a cubemap, a pinned tonemapper —
	/// then apply whichever mode the viewport is in.
	///
	/// STUDIO is how a material looks in game: the values are the ones s&amp;box's own default
	/// scene ships with. FULL BRIGHT is how you model: even light from every side, so a face you
	/// are about to sketch on does not disappear into the unlit side of the sun. Full bright is
	/// the default; studio is the setting. See <see cref="ApplyLighting"/>.
	///
	/// The rig this first replaced was a light box (full-strength sun, a 0.6 fill, a 0.6 ambient)
	/// with no tonemapper, which is why a 0.5 grey rendered at about 0.88 and every pastel arrived
	/// as near-white. Studio keeps the tonemapper and drops the fill. Full bright drops the sun.
	/// </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.
		_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&amp;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;

		_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;

		// Honour the default (full bright) on the first frame, before Settings has a chance to
		// restore a saved choice. RestoreSettings overwrites this a moment later if it has to.
		ApplyLighting();
	}

	// --- 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 workspaceBar, Widget toolBar, Widget resultOverlay = null )
	{
		// Outermost first, then the bar, then the canvas taking everything left. Two rows of docked
		// chrome, in the order the questions get asked: which part of the pipeline (workspace),
		// then which handful of tools (stage), then the tools. One bar per question, 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 ( workspaceBar is not null )
			Layout.Add( workspaceBar );

		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 );

	// --- Model's chrome -----------------------------------------------------------------------

	private Widget _modelHeader, _modelPalette, _modelCatalog;

	/// <summary>
	/// Model's header, palette and catalog, all children of the canvas. Placed every frame by
	/// <see cref="PlaceModelChrome"/> because the canvas is the one that knows its own size, and
	/// counted as overlays so a camera drag cannot start under them. See EffigyModelChrome.cs.
	/// </summary>
	public void AddModelChrome( Widget header, Widget palette, Widget catalog )
	{
		_modelHeader = header;
		_modelPalette = palette;
		_modelCatalog = catalog;

		foreach ( var w in new[] { header, palette, catalog } )
			if ( w is not null )
				w.Visible = false;

		PlaceModelChrome();
	}

	/// <summary>Whether Model's chrome is up, which moves the floating bars off the palette.</summary>
	public bool ModelChromeShown => _modelHeader?.IsValid() == true && _modelHeader.Visible;

	private void PlaceModelChrome()
	{
		var size = _canvas.Size;

		if ( _modelHeader?.IsValid() == true )
		{
			if ( MathF.Abs( _modelHeader.Width - size.x ) > 0.5f )
				_modelHeader.FixedWidth = size.x;

			if ( _modelHeader.Position.Length > 0.5f )
				_modelHeader.Position = Vector2.Zero;
		}

		if ( _modelPalette?.IsValid() == true )
		{
			var at = new Vector2( OverlayMargin.x, EffigyModelChrome.HeaderHeight + OverlayMargin.y );
			if ( (_modelPalette.Position - at).Length > 0.5f )
				_modelPalette.Position = at;
		}

		if ( _modelCatalog?.IsValid() == true )
		{
			var at = new Vector2( 0f, EffigyModelChrome.HeaderHeight );
			var want = new Vector2( size.x, MathF.Max( size.y - EffigyModelChrome.HeaderHeight, 1f ) );

			if ( (_modelCatalog.Position - at).Length > 0.5f )
				_modelCatalog.Position = at;

			if ( (_modelCatalog.Size - want).Length > 0.5f )
			{
				_modelCatalog.FixedWidth = want.x;
				_modelCatalog.FixedHeight = want.y;
			}
		}
	}

	/// <summary>Where a floating bar goes: under the header and right of the palette while
	/// Model's chrome is up, the old top-left corner otherwise.</summary>
	private Vector2 FloatingBarOrigin =>
		ModelChromeShown
			? new Vector2( OverlayMargin.x + (_modelPalette?.Width ?? 0f) + 8f, EffigyModelChrome.HeaderHeight + OverlayMargin.y )
			: OverlayMargin + new Vector2( 0f, 46f );

	/// <summary>
	/// The grid switch that lives at the end of the tool row, so the frame loop can show it only
	/// while a sketch is open.
	///
	/// The bar owns its own position - it is a child of the tool row, not a floating overlay - so
	/// the only thing kept here is when it is on screen. That has to be driven from the frame loop
	/// rather than from EnterSketch and FinishSketch, because a sketch also ends by being deleted,
	/// undone, or rolled past, and every one of those would need its own line.
	/// </summary>
	public Widget SketchGridBar { get; set; }

	/// <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>
	/// Show a second model — the bodies a Transform is dragging — alongside the main preview. Null
	/// just clears the drag model. The main model is expected to hold the static bodies for the
	/// duration of the drag.
	/// </summary>
	public void BeginDragModel( Model model )
	{
		EndDragModel();

		if ( model is null )
			return;

		using var scope = _canvas.Scene.Push();

		_dragObject = new GameObject( true, "effigy_drag" );
		_dragRenderer = _dragObject.GetOrAddComponent<ModelRenderer>( false );
		_dragRenderer.Model = model;
		_dragRenderer.Enabled = true;
	}

	/// <summary>Move the drag model by a rigid-plus-uniform-scale transform, in world space. One
	/// transform write a frame — the mesh is never touched.</summary>
	public void SetDragModelTransform( Transform transform )
	{
		if ( _dragObject is not null && _dragObject.IsValid() )
			_dragObject.LocalTransform = transform;
	}

	/// <summary>Drop the drag model and the object holding it.</summary>
	public void EndDragModel()
	{
		_dragObject?.Destroy();
		_dragObject = null;
		_dragRenderer = null;
	}

	/// <summary>Move the main preview model by a transform, without touching its vertex data. The
	/// "move everything" Transform drag uses this: the GPU applies the transform, so dragging a dense
	/// part costs one write a frame no matter how many vertices it has.</summary>
	public void SetModelTransform( Transform transform )
	{
		if ( _modelObject is not null && _modelObject.IsValid() )
			_modelObject.LocalTransform = transform;
	}

	/// <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 );
				DeselectLight();
			}
		}
	}

	/// <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();
			DrawDatumPlanes();
			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();

		// AFTER the hitboxes, which is where the hover state comes from — drawn before them, a datum
		// plane would light up one frame behind the cursor. The same reason DrawHoveredPlaneHighlight
		// sits here rather than up with the outlines it washes over.
		DrawDatumPlanes();
		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.
	///
	/// SIZED AND FADED FROM THE FACE ITSELF, because "any face that can be sketched on" runs from
	/// a two-unit tab to a two-thousand-unit floor. A step that fits one is a solid sheet of ink on
	/// the other, so the step is widened until the face can hold the lines and the ink is thinned
	/// as the lines close up on screen. A face seen nearly edge-on fades out entirely, the way the
	/// reference planes already do - it is a bright smear across the middle of the view and never
	/// a ruler.
	/// </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;

		// The same measurement the snap uses - see TryFaceExtent, which exists so the lines drawn
		// and the intervals landed on cannot come from two different walks over the same points.
		if ( !TryFaceExtent( out var min, out var max, out var span, out var world ) )
			return true;

		var half = span * 0.5f;
		var step = FaceGridStep( world, span );

		if ( step <= 0f )
			return true;

		// The cap belongs here rather than inside the line walk, which used to answer "too many
		// lines" by drawing NONE of them - so a face big enough relative to the step simply had no
		// grid, with nothing on screen to say why. Doubling until it fits is the same answer the
		// reference planes give, and it always leaves something to read.
		//
		// Bounded, because this runs on geometry rather than on a promise: a face whose extent came
		// back infinite through a broken rebuild would otherwise spin here forever, and a viewport
		// that hangs is worse than one drawing a coarse grid.
		for ( var i = 0; i < 64 && (LinesAcross( min.x, max.x, step ) > MaxGridLines
			|| LinesAcross( min.y, max.y, step ) > MaxGridLines); i++ )
		{
			step *= 2f;
		}

		var units = WorldRadiusAt( world, 1f );

		// How far apart the lines land ON SCREEN, which is the only thing that decides whether a
		// grid reads as a ruler or as a wash. DrawnGridStep aims for GridPixels and misses it
		// whenever the cap above has taken over, or whenever the camera is a long way from a face
		// large enough to keep its own step.
		var spacing = units > 0f ? step / units : GridPixels;
		var density = MathF.Min( spacing / GridPixels, 1f );

		// Straight down the sketch plane's normal, so a face turned nearly edge-on fades out
		// instead of collapsing into a bright line across everything behind it.
		var normal = new Vector3( ActiveSketch.Plane.Normal.x, ActiveSketch.Plane.Normal.y,
			ActiveSketch.Plane.Normal.z );
		var facing = MathF.Abs( Vector3.Dot( normal, _camera.WorldRotation.Forward ) );
		var viewFade = MathF.Min( facing / EdgeOnFade, 1f );

		var alpha = color.a * 0.45f * density * viewFade;

		if ( alpha <= 0.01f )
			return true;

		Gizmo.Draw.LineThickness = 1f;
		Gizmo.Draw.Color = color.WithAlpha( alpha );

		DrawFaceGridLines( reference, min.x, max.x, step, true );
		DrawFaceGridLines( reference, min.y, max.y, step, false );

		return true;
	}

	/// <summary>How many grid lines at <paramref name="step"/> fall between two coordinates.
	/// Negative when the span is too narrow to hold one, which is a legitimate answer for the short
	/// axis of a long thin face.</summary>
	private static int LinesAcross( float low, float high, float step ) =>
		(int)MathF.Floor( high / step ) - (int)MathF.Ceiling( low / step ) + 1;

	/// <summary>
	/// Roughly how many squares the grid should put across a face, on Automatic.
	///
	/// A DIVISION COUNT, NOT A DISTANCE, because the faces this has to serve differ by three orders
	/// of magnitude in the same document — the 2.75-unit dial on this grill and the 72-unit slab it
	/// is set into. A fixed distance is graph paper on one and a solid wash on the other. Around a
	/// dozen squares reads as ruled paper at any size, which is what a grid is for.
	/// </summary>
	private const float FaceGridDivisions = 12f;

	/// <summary>
	/// The step to rule a FACE at: fitted to the face, then held back to what the screen can
	/// actually show.
	///
	/// The reference planes size their grid from the camera alone (see DrawnGridStep), which is
	/// right for a plane — it has no size of its own worth speaking of, it is a stand-in for an
	/// infinite one. A face does have a size, and it is the thing being drawn on, so the grid
	/// belongs to it: the step is the 1/2/5 round number nearest span/FaceGridDivisions, which puts
	/// about a dozen squares across whatever you picked and lands the lines on whole numbers rather
	/// than on whatever the face's width happened to leave over.
	///
	/// THEN THE CAMERA GETS A VETO, one way only. Fitting to the face alone would rule a 72-unit
	/// slab at 5 units and keep ruling it at 5 units as you zoom out to the whole grill, where those
	/// lines are a pixel apart and the face fills with ink. Taking whichever step is COARSER means
	/// the face decides while you are working on it and the camera takes over once the face is too
	/// small on screen to hold that many lines. It never goes the other way, so zooming in cannot
	/// make the grid finer than the face asked for — the squares you measured against stay the
	/// squares you measured against.
	///
	/// An explicit GridSpacing skips all of it. A number someone typed is not a suggestion.
	/// </summary>
	private float FaceGridStep( Vector3 centre, float span )
	{
		if ( GridSpacing > 0f )
			return GridSpacing;

		// AutoGridStep answers "what round step is about `pixels` across, at this scale" — feeding
		// it the face's own scale rather than the camera's asks the same question about the face.
		var fitted = SketchSnapper.AutoGridStep( span / FaceGridDivisions, 1f );
		var camera = SketchSnapper.AutoGridStep( WorldRadiusAt( centre, 1f ), GridPixels );

		return MathF.Max( fitted, camera );
	}

	/// <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 );

		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&amp;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;

	/// <summary>
	/// Called once a frame, before the scene ticks. The window's hook for work that has to happen
	/// on a clock rather than on an event.
	///
	/// THE VIEWPORT ALREADY HAS THE ONLY CLOCK IN THE TOOL. A DockWindow does not tick; the canvas
	/// does, because it is rendering. Anything else wanting a heartbeat - the settle timer that
	/// turns a preview rebuild back into a real one - would otherwise have to invent one.
	/// Re-assigned in RebuildStages like every other delegate here, so a hot reload does not leave
	/// it pointing into the dead assembly.
	/// </summary>
	public Action Ticked { get; set; }

	private void OnPreFrame()
	{
		Ticked?.Invoke();

		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.
		// Only while there is a sketch to grid. Outside one the switch still means something - the
		// reference planes read it too - but it would be a control on the sketch toolbar for a mode
		// nobody is in, and Edit > Settings is its home for that case.
		if ( SketchGridBar.IsValid() )
		{
			SketchGridBar.Visible = IsSketching;

			// The citizen switch shares this trailing slot and stays visible in every mode. When
			// the grid hides, the row has to shrink or it leaves a blank 210px of chrome hanging
			// off the right of the tools.
			if ( SketchGridBar.Parent is { IsValid: true } trailing )
			{
				var width = EffigySizeReferenceButton.BarWidth
					+ ( IsSketching ? 6f + EffigySketchGridBar.BarWidth : 0f );
				trailing.FixedWidth = width;
			}
		}

		PlaceModelChrome();

		// The sculpt bar keeps its floating spot, but not on top of the palette.
		if ( _sculptBarOverlay?.IsValid() == true && (_sculptBarOverlay.Position - FloatingBarOrigin).Length > 0.5f )
			_sculptBarOverlay.Position = FloatingBarOrigin;

		var overAnyOverlay = (_resultOverlay?.IsUnderMouse ?? false)
			|| (_modelHeader?.IsValid() == true && _modelHeader.Visible && _modelHeader.IsUnderMouse)
			|| (_modelPalette?.IsValid() == true && _modelPalette.Visible && _modelPalette.IsUnderMouse)
			|| (_modelCatalog?.IsValid() == true && _modelCatalog.Visible)
			|| (_sculptBarOverlay?.IsUnderMouse ?? false)
			|| (_meshEditBarOverlay?.IsValid() == true && _meshEditBarOverlay.IsUnderMouse)
			|| (_weightBarOverlay?.IsValid() == true && _weightBarOverlay.IsUnderMouse)
			|| _paintBarOverlays.Any( b => b.IsValid() && b.IsUnderMouse );
		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();
		PlaceReferenceImages();

		// Draw planes first (behind everything else)
		DrawReferencePlanes();
		DrawCommittedSketches();
		ShadeMaterialSlotsFrame();
		MaterialDropFrame();
		SketchPickFrame();
		FacePickFrame();
		EdgePickFrame();
		BodyPickFrame();
		// Before the draw, so what is drawn is this frame's solve rather than last frame's.
		SoftPreviewFrame();

		DrawRigSkeleton();
		BoneToolFrame();

		SketchFrame();
		SplineFrame();
		SculptFrame();
		MeshEditFrame();
		PaintFrame();
		MaterialBrushFrame();
		WeightPaintFrame();

		// AFTER the pick passes and after sculpting, so a note is drawn over everything it is about
		// and an erase click is resolved against a hover the other modes have already declined.
		// DrawNotes runs unconditionally where NoteFrame returns early: notes are visible whether or
		// not the pen is armed — see EffigyViewport.Notes.cs.
		NoteFrame();
		DrawNotes();
		DrawMarkers();

		// 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 and lamps on top of the planes. Hidden while sketching or picking anything - they
		// sit where first clicks land, and stealing them was the first thing that broke.
		// RigMode joins the list for the same reason every other entry is on it: these sit where
		// first clicks land, and in the rig workspace those clicks are meant for bones and parts.
		if ( !IsSketching && !IsMeshEditing && !PlanePickMode && !SketchPickMode && !FacePickMode && !EdgePickMode
			&& !BodyPickMode && !BoneToolActive && !RigMode )
		{
			DrawViewportLights();
			DrawOrigin();

			// HasHovered, not IsHovered: the move gizmo's arrows are Control hitboxes, and
			// IsHovered only sees Hitbox.Sphere. Clicking an arrow used to count as empty space.
			if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )
			{
				if ( OriginSelected )
					DeselectOrigin();

				if ( LightSelected )
					DeselectLight();
			}
		}

		// AFTER the origin, so a click on the origin handle is not also a click on the face sitting
		// behind it. Idle hover/click only runs when no dialog owns the mouse.
		IdleSelectionFrame();

		// AFTER the selection, because the handle belongs to whatever that pass just settled on, and
		// before nothing in particular - Gizmo.Control registers its own hitbox, which is what stops the
		// click that grabs an arrow from also landing on the face behind it.
		// Not in the rig workspace. The arrow hangs off the idle face selection, which RigMode has
		// already stopped happening, so this is belt and braces - but the handle is a Gizmo.Control
		// that would keep registering a hitbox over a selection made before the workspace changed,
		// and a stray arrow over the model is exactly the kind of thing that eats a bone click.
		if ( !RigMode )
		{
			FaceDragFrame();

			// Beside it because it is the same kind of handle - the open feature's number, answered
			// with the mouse - and it wants the same protection from a Gizmo.Control hitbox outliving
			// the workspace that put it there.
			PlaneOffsetHandleFrame();

			// And the third of the same family: the open Transform's Translate, answered with the
			// mouse instead of three number fields.
			BodyDragFrame();
		}

		// 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 || IsMeshEditing || IsPainting || IsMaterialBrushing || _hoveredSketchId is not null || _hoveredFaceBodyId is not null
			|| BoneToolActive || BodyPickMode || FacePickMode || EdgePickMode
			|| ( RigMode && TryPickBoneUnderCursor( out _ ) )
			? CursorShape.Finger : CursorShape.Arrow;
	}

	/// <summary>Draw all bones as dog-bone shapes with selection and pose gizmo.</summary>
	private void DrawRigSkeleton()
	{
		if ( !BonesShown )
			return;

		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;

		var hoverBone = TryPickBoneUnderCursor( out var hoveredIndex ) ? hoveredIndex : -1;

		for ( var i = 0; i < RigSkeleton.Count; i++ )
			DrawBoneHandle( i, i == hoverBone );

		// 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;

		if ( BoneToolActive || _boneDragging || !Gizmo.WasLeftMousePressed )
			return;

		// Bones are drawn IgnoreDepth so they show through the mesh they sit inside. Their
		// gizmo hitboxes are not — a click on the visible bone hits the solid in front of
		// it, the bone never selects, and every tool that needs a selected bone (Assign,
		// Paint Weights, Parent) stays grey. Pick the bone with the same cursor ray as the
		// mesh, ignoring occlusion, then let a miss fall through to part / empty space.
		if ( TryPickBoneUnderCursor( out var picked ) )
		{
			if ( picked != _selectedBoneIndex )
			{
				_selectedBoneIndex = picked;
				BoneSelectionChanged?.Invoke( picked );
				Update();
			}

			return;
		}

		if ( Gizmo.HasHovered || TryPickFaceUnderCursor( out _ ) )
			return;

		if ( _selectedBoneIndex >= 0 )
		{
			_selectedBoneIndex = -1;
			BoneSelectionChanged?.Invoke( -1 );
		}
	}

	/// <summary>
	/// The bone under the cursor, even when the mesh is in front of it. Bones live inside
	/// the parts they drive; drawing them through the mesh is the only way to see them, and
	/// a pick that lost to the mesh made clicking one a no-op.
	/// </summary>
	internal bool TryPickBoneUnderCursor( out int index )
	{
		index = -1;

		if ( !BonesShown || RigSkeleton is null || RigSkeleton.Count == 0 || !_cursorRayValid || BoneToolActive )
			return false;

		var origin = _cursorRayOrigin;
		var dir = _cursorRayDirection;
		var dirLen = dir.Length;

		if ( dirLen < 1e-8f )
			return false;

		dir = dir / dirLen;

		var best = float.MaxValue;

		for ( var i = 0; i < RigSkeleton.Count; i++ )
		{
			var world = BoneWorld( i );
			var bone = RigSkeleton.Bones[i];
			var head = world.Origin;
			var tail = world.TransformPoint( new Vec3( 0, bone.Length, 0 ) );
			var boneLen = (tail - head).Length;

			if ( boneLen < 0.01f )
				continue;

			var dist = RaySegmentDistance( origin, dir, head, tail, out var rayT );

			if ( rayT < 0f )
				continue;

			var mid = (head + tail) * 0.5f;
			var midWorld = new Vector3( mid.x, mid.y, mid.z );
			var radius = MathF.Max( boneLen * DogBoneKnobScale, MinBonePickRadius );
			radius = MathF.Max( radius, WorldRadiusAt( midWorld, 14f ) );

			if ( dist > radius || rayT >= best )
				continue;

			best = rayT;
			index = i;
		}

		return index >= 0;
	}

	static float RaySegmentDistance( Vec3 rayOrigin, Vec3 rayDir, Vec3 a, Vec3 b, out float rayT )
	{
		var v = b - a;
		var w0 = rayOrigin - a;
		var vv = Vec3.Dot( v, v );
		var uv = Vec3.Dot( rayDir, v );
		var uw = Vec3.Dot( rayDir, w0 );
		var vw = Vec3.Dot( v, w0 );
		var denom = vv - uv * uv;

		float segT;

		if ( denom < 1e-12f )
		{
			segT = 0f;
			rayT = -uw;
		}
		else
		{
			segT = (uv * uw - vw) / denom;
			rayT = uv * segT - uw;
		}

		if ( rayT < 0f )
		{
			rayT = 0f;
			segT = vv > 1e-12f ? Math.Clamp( -vw / vv, 0f, 1f ) : 0f;
		}
		else if ( segT < 0f )
		{
			segT = 0f;
			rayT = -uw;
		}
		else if ( segT > 1f )
		{
			segT = 1f;
			rayT = uv - uw;
		}

		var onRay = rayOrigin + rayDir * rayT;
		var onSeg = a + v * segT;
		return (onRay - onSeg).Length;
	}

	/// <summary>Base radius of a bone's head sphere in world units.</summary>
	private const float BoneHandleRadius = 0.8f;

	/// <summary>
	/// A dog bone's knob radius as a fraction of its length — the widest part of the drawn shape.
	///
	/// SHARED BY THE DRAWING AND THE HIT TEST, which is the point of it being a named constant
	/// rather than the bare 0.16 it used to be inside DrawDogBone. The two used different numbers
	/// for as long as both existed, so how much of a bone you could actually click depended on how
	/// long the bone was.
	/// </summary>
	private const float DogBoneKnobScale = 0.16f;

	/// <summary>
	/// The smallest a bone's hit target may get, whatever its length.
	///
	/// A very short bone draws a very small dog bone, and a hit target faithful to it would be a
	/// few pixels across. Fidelity is worth having right up to the point where the thing becomes
	/// unclickable; below that a slightly generous target is the lesser problem, and a short bone
	/// has little around it to steal from anyway.
	/// </summary>
	private const float MinBonePickRadius = 0.6f;

	/// <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, bool hovered )
	{
		// BoneWorld, not WorldBind: while the soft preview runs this is where the bone actually IS,
		// which is where it has to be drawn and where its hit sphere has to sit. See
		// EffigyViewport.SoftPreview.cs.
		var world = BoneWorld( 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 );

		// MEASURED, not bone.Length. The two agree for a well-formed bone, and where they do not
		// the drawing follows this one — DrawDogBone derives everything from the head-to-tail
		// vector — so the hit target has to follow it as well or the pair drift apart again.
		var boneLen = (tail - head).Length;

		var isSelected = index == _selectedBoneIndex;

		// SOFT BONES ARE BLUE. Which bones in a chain are simulated and which are welded is not
		// otherwise visible anywhere in the viewport - it is a tick in a panel on a bone you have to
		// select one at a time - and it is the first thing you want to know when a rig wobbles
		// wrongly. Selection still wins over it: yellow means "this is the one the gizmo will move",
		// which is a fact about right now, where softness is a fact about the rig.
		Gizmo.Draw.Color = isSelected
			? new Color( 1f, 0.85f, 0.2f, 1f )
			: bone.Soft is not null
				? new Color( 0.35f, 0.7f, 1f, 0.85f )
				: new Color( 0.95f, 0.35f, 0.2f, 0.8f );

		DrawDogBone( head, tail, xAxis, zAxis );

		// 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;

		// A degenerate bone draws nothing (DrawDogBone bails at the same threshold), and a hit
		// target for a shape that is not on screen is a click landing on nothing visible.
		if ( boneLen < 0.01f )
			return;

		// EVERY BONE NEEDS ITS OWN NAMED SCOPE, and the lack of one is why clicking a bone in this
		// viewport did nothing at all.
		//
		// Gizmo.IsHovered does not answer "is the cursor over the last shape I registered" - it
		// answers "is the cursor over THIS GIZMO OBJECT", and the object is the scope. Registering
		// every bone's hitbox at the root scope put all of them into one object, so the question
		// each bone asked was really "is the cursor over any bone at all", and the answer could not
		// pick one out. RigControlEditor's RigViewport has done this correctly since it was written
		// - see the $"Bone{bone.Index}" scope in its DrawBones.
		//
		// THE SCOPE CARRIES THE BONE'S ROTATION, not just its position, which is what lets the
		// hitbox below be a single box that lies along the bone instead of a string of spheres
		// approximating one. ExtractRotation maps the kernel's basis the same way the pose gizmo
		// does - the bone's own axis becomes the scope's FORWARD - so local +X runs head to tail.
		using ( Gizmo.Scope( $"EffigyBone{index}", new Transform( head, ExtractRotation( world ) ) ) )
		{
			// THE HITBOX IS SIZED FROM THE DRAWING, and that is the whole fix for a hit target that
			// felt arbitrary.
			//
			// DrawDogBone sizes its knobs off the bone's LENGTH (knobR = boneLen * 0.16), while the
			// hitbox was a fixed 0.8 units regardless. So the two agreed at exactly one bone length
			// and diverged in both directions from there: on a long bone the drawn shape was far
			// bigger than the target, so most of the bone you could see did nothing, and on a short
			// one the target stuck out well past the bone. Same constant, same expression, one
			// source of truth - the thing you can see is the thing you can hit.
			var knobR = MathF.Max( boneLen * DogBoneKnobScale, MinBonePickRadius );

			// THE SELECTED BONE KEEPS A HITBOX, just one that loses to its own pose gizmo.
			//
			// It used to have none at all, on the reasoning that the gizmo registers its own - and
			// that is the bug behind "the gizmo disappears". The deselect test at the end of
			// DrawRigSkeleton treats a press with nothing hovered as a click on empty space, so
			// clicking the selected bone anywhere off the gizmo's arrows read as empty space and
			// threw the selection away. The gizmo did not fail to appear; it was being dismissed by
			// the click aimed at it.
			//
			// The depth bias is the whole mechanism. Gizmo.Control's handles register at 0.01,
			// biased toward the camera; leaving this bone at 0 means the arrows win everywhere they
			// overlap it - which is RigViewport's finding in its own words, that a sphere "biased in
			// front, no less" beat the control and stopped the drag ever starting. Off the arrows,
			// this box is all there is, so the bone stays hovered and the selection survives.
			Gizmo.Hitbox.DepthBias = isSelected ? 0f : 0.01f;

			Gizmo.Hitbox.BBox( new BBox(
				new Vector3( 0f, -knobR, -knobR ),
				new Vector3( boneLen, knobR, knobR ) ) );
		}

		// The highlight is the BONE, not a blob near it, and it is drawn out here in world space
		// where DrawDogBone works. A sphere at the head was the old feedback and it was actively
		// misleading: it said the head was the target when the target is the whole bone.
		// Not on the selected bone: it is already drawn in the selection colour, and a hover
		// highlight over it would say the click is about to do something when it is about to do
		// nothing.
		if ( hovered && !isSelected )
		{
			Gizmo.Draw.Color = new Color( 1f, 0.85f, 0.2f, 0.9f );
			DrawDogBone( head, tail, xAxis, zAxis );
		}
	}

	/// <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 = BoneWorld( _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 * DogBoneKnobScale;
		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&amp;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
		{
			// WorldBind here, NOT BoneWorld, and this is the one place the difference matters. This
			// converts a world transform back into a parent-relative BIND transform to store on the
			// bone. Measuring it against a parent that is currently swinging would bake the wobble
			// into the bind pose itself - drag a bone while previewing and the rig would slowly
			// drift into whatever shape the springs happened to be in.
			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 );

		// The pose preview re-deforms the display mesh on the same per-frame cadence, so the arm
		// follows the drag rather than jumping at the release.
		if ( PosePreviewActive )
			PoseChanged?.Invoke();
	}

	/// <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 );

		// Same reason as SelectBone's: on demand, so a deselection nobody repaints stays on screen.
		Update();
	}

	/// <summary>
	/// Select a bone by index — called from the rig panel's tree view. Does not invoke the
	/// BoneSelectionChanged callback, to avoid feedback loops.
	///
	/// THE Update() IS THE POINT, not an afterthought. Without it this wrote a field and stopped:
	/// the viewport is repainted on demand rather than continuously, so clicking a bone in the tree
	/// changed nothing on screen - no yellow bone, no pose gizmo - until some unrelated thing
	/// happened to ask for a frame, usually the next mouse move over the canvas. The selection had
	/// in fact worked every time; there was simply nothing to look at that said so.
	/// </summary>
	public void SelectBone( int index )
	{
		_selectedBoneIndex = index >= 0 && index < RigSkeleton?.Count ? index : -1;

		Update();
	}

	/// <summary>Escape backs out of the half-drawn entity, then out of the tool - the same two
	/// stages every CAD sketcher uses. The sculpt, paint, note and bone-drag keys are [Shortcut]s
	/// now, registered on the window, so they never reach this method.</summary>
	protected override void OnKeyPress( KeyEvent e )
	{
		// Edit mode's Enter (accept the operation) and Escape (cancel it, or drop the selection).
		// First, because an open operation is the shallowest thing Escape can mean.
		if ( IsMeshEditing && MeshEditKeyPressed is not null
			&& (e.Key == KeyCode.Escape || e.Key == KeyCode.Enter || e.Key == KeyCode.Return)
			&& MeshEditKeyPressed( e.Key == KeyCode.Escape ? (char)27 : (char)13 ) )
		{
			e.Accepted = true;
			return;
		}

		// 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;

		// Drawing a 3D spline: Backspace takes the last point back, Enter finishes.
		if ( IsSplineEditing && (e.Key == KeyCode.Backspace || e.Key == KeyCode.Delete) )
		{
			RemoveLastSplinePoint();
			e.Accepted = true;
			return;
		}

		if ( IsSplineEditing && (e.Key == KeyCode.Enter || e.Key == KeyCode.Return) )
		{
			SplineFinishRequested?.Invoke();
			e.Accepted = true;
			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;

		if ( LightSelected && (e.Key == KeyCode.Delete || e.Key == KeyCode.Backspace) )
		{
			RemoveSelectedLight();
			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 || FacePickMode || EdgePickMode )
		{
			PlanePickMode = false;
			BodyPickMode = false;
			FacePickMode = false;
			EdgePickMode = 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 ( HasIdleSelection )
		{
			ClearIdleSelection();
			e.Accepted = true;
			return;
		}

		if ( LightSelected )
		{
			DeselectLight();
			e.Accepted = true;
			return;
		}

		if ( OriginSelected )
		{
			DeselectOrigin();
			e.Accepted = true;
		}
	}
}