Editor/EffigyEditor/EffigyViewport.Selection.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
namespace Marionette.EditorTools;
/// <summary>
/// Idle geometry selection — click a face or a part, then use a tool on it.
///
/// Separate from FacePickMode / BodyPickMode. Those are a dialog asking a question; this is the
/// selection that exists when nobody is asking. Onshape works the same way: you point at a face,
/// it lights up, you click it, then Draft/Hole/Sketch consume what you already picked instead of
/// making you pick it again inside a dialog.
/// </summary>
internal sealed partial class EffigyViewport
{
/// <summary>Faces currently selected while idle. Empty when the last click was a whole part
/// in the Parts list, or nothing.</summary>
public IReadOnlyList<FaceRef> IdleFaces => _idleFaces;
/// <summary>Edges currently selected while idle. Empty unless the last click landed near a
/// rim rather than in the middle of a face.</summary>
public IReadOnlyList<EdgeRef> IdleEdges => _idleEdges;
/// <summary>Bodies currently selected while idle. A face click names its owning body so the
/// Parts list can highlight the same row; a Parts-list click names the body and no faces.
/// </summary>
public IReadOnlyList<string> IdleBodyIds => _idleBodyIds;
/// <summary>Committed sketch currently selected while idle, or null. Extrude/Revolve read
/// this instead of waiting for a second click.</summary>
public string IdleSketchFeatureId => _idleSketchFeatureId;
/// <summary>Region seeds on <see cref="IdleSketchFeatureId"/>. Empty means the whole sketch.
/// </summary>
public IReadOnlyList<Vec2> IdleRegionSeeds => _idleRegionSeeds;
public bool HasIdleSelection =>
_idleFaces.Count > 0 || _idleEdges.Count > 0 || _idleBodyIds.Count > 0
|| _idleSketchFeatureId is not null;
/// <summary>Raised after a click in the viewport changes the idle selection, so the Parts
/// list can stay in step. Not raised when the list itself drove the change.</summary>
public Action IdleSelectionChanged { get; set; }
private readonly List<FaceRef> _idleFaces = new();
private readonly List<EdgeRef> _idleEdges = new();
private readonly List<string> _idleBodyIds = new();
private string _idleSketchFeatureId;
private readonly List<Vec2> _idleRegionSeeds = new();
/// <summary>How close a click must be to a rim, in screen pixels, before it counts as an
/// edge rather than the face. Wide enough to grab, tight enough that a click in the middle
/// of a face stays a face.</summary>
private const float EdgePickPixels = 10f;
/// <summary>Whether a left click on a face is a selection rather than an answer to a dialog.
/// Anything else with a click of its own owns the mouse while it is armed.</summary>
private bool IdlePickingAllowed =>
!IsSketching && !IsSculpting && !IsPainting && !IsMaterialBrushing && !IsNoting && !IsWeightPainting
&& !PlanePickMode && !SketchPickMode && !FacePickMode && !EdgePickMode && !BodyPickMode
&& !BoneToolActive
&& !_draggingOrigin && !_draggingLight && !_draggingFace;
/// <summary>
/// Replace the idle selection with whole bodies, clearing any face picks.
///
/// This is what the Parts list does: clicking a row selects that part, not one of its faces.
/// Fillet, shell, transform then act on the part; Sketch/Draft still need a face, so they
/// open asking for one.
/// </summary>
public void SelectBodies( IReadOnlyList<string> bodyIds )
{
_idleFaces.Clear();
_idleEdges.Clear();
_idleBodyIds.Clear();
ClearIdleSketch();
if ( bodyIds is not null )
{
foreach ( var id in bodyIds )
{
if ( !string.IsNullOrEmpty( id ) && !_idleBodyIds.Contains( id ) )
_idleBodyIds.Add( id );
}
}
IdleSelectionChanged?.Invoke();
}
public void ClearIdleSelection()
{
if ( !HasIdleSelection )
return;
_idleFaces.Clear();
_idleEdges.Clear();
_idleBodyIds.Clear();
ClearIdleSketch();
IdleSelectionChanged?.Invoke();
}
void ClearIdleSketch()
{
_idleSketchFeatureId = null;
_idleRegionSeeds.Clear();
}
/// <summary>Select a committed sketch, optionally a single closed region of it.</summary>
public void SelectIdleSketch( string featureId, Vec2? seed, bool add = false )
{
if ( string.IsNullOrEmpty( featureId ) )
{
if ( _idleSketchFeatureId is null )
return;
ClearIdleSketch();
IdleSelectionChanged?.Invoke();
return;
}
if ( add && _idleSketchFeatureId == featureId && seed is { } extra )
{
for ( var i = 0; i < _idleRegionSeeds.Count; i++ )
{
var existing = _idleRegionSeeds[i];
if ( MathF.Abs( existing.x - extra.x ) > 1e-4f || MathF.Abs( existing.y - extra.y ) > 1e-4f )
continue;
_idleRegionSeeds.RemoveAt( i );
if ( _idleRegionSeeds.Count == 0 )
_idleSketchFeatureId = null;
IdleSelectionChanged?.Invoke();
return;
}
_idleRegionSeeds.Add( extra );
IdleSelectionChanged?.Invoke();
return;
}
_idleFaces.Clear();
_idleEdges.Clear();
_idleBodyIds.Clear();
_idleSketchFeatureId = featureId;
_idleRegionSeeds.Clear();
if ( seed is { } one )
_idleRegionSeeds.Add( one );
IdleSelectionChanged?.Invoke();
}
/// <summary>Drop faces and parts whose bodies no longer exist after a rebuild. Called from
/// SetDisplayBodies so a deleted part cannot stay selected against nothing.</summary>
private void PruneIdleSelection()
{
if ( !HasIdleSelection )
return;
var ids = new HashSet<string>();
foreach ( var body in _displayBodies )
{
if ( body?.Id is { } id )
ids.Add( id );
}
var changed = _idleFaces.RemoveAll( f => !ids.Contains( f.BodyId ) ) > 0
| _idleEdges.RemoveAll( e => !ids.Contains( e.BodyId ) ) > 0
| _idleBodyIds.RemoveAll( id => !ids.Contains( id ) ) > 0;
if ( _idleSketchFeatureId is not null )
{
var found = false;
foreach ( var pickable in _pickableSketches )
{
if ( pickable.FeatureId == _idleSketchFeatureId )
{
found = true;
break;
}
}
if ( !found )
{
ClearIdleSketch();
changed = true;
}
}
if ( changed )
IdleSelectionChanged?.Invoke();
}
/// <summary>
/// Hover the face under the cursor, keep already-picked faces and parts lit, and take a click
/// as a selection when nothing else owns the mouse.
///
/// Runs after the origin handle so a click on the origin is not also a click on the face that
/// happens to sit behind it. Gizmo.HasHovered covers the origin, the plane-corner handles and
/// anything else that registered a hitbox this frame.
/// </summary>
private void IdleSelectionFrame()
{
DrawIdleSelection();
if ( !IdlePickingAllowed )
return;
if ( !_canvasHasCursor )
return;
// A bone under the cursor is a bone click, even when the mesh is in front of it.
// Taking the part instead is why clicking a 3D bone did nothing and Assign stayed grey.
if ( RigMode && TryPickBoneUnderCursor( out _ ) )
return;
// Sketches are a CAD question. In the rig workspace a click names a part (or a bone),
// and a profile lighting up under the cursor would steal the body you meant to pin.
if ( !RigMode
&& TryResolveSketchHover( out var sketchId, out var sketchSeed, out var sketchDistance )
&& SketchBeatsFace( sketchDistance ) )
{
_hoveredFaceBodyId = sketchId;
DrawSketchPickHighlight( sketchId, sketchSeed );
if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )
{
if ( OriginSelected )
DeselectOrigin();
SelectIdleSketch( sketchId, sketchSeed, Gizmo.IsShiftPressed );
}
return;
}
if ( TryPickFaceUnderCursor( out var hit ) )
{
_hoveredFaceBodyId = hit.Body.Id;
// Rigging assigns WHOLE parts. A face or an edge under the cursor is still the part,
// and lighting a single face would look like CAD selection in a workspace that has
// no face tools.
if ( RigMode )
{
DrawBodyHighlight( hit.Body, BodyPickHoverColor );
if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )
SelectIdleBody( hit.Body.Id, Gizmo.IsShiftPressed );
return;
}
if ( TryIdleEdge( hit, out var edge, out var key ) )
{
DrawEdge( hit.Body, key, FaceHighlightColor );
if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )
{
if ( OriginSelected )
DeselectOrigin();
SelectIdleEdge( hit.Body, edge, Gizmo.IsShiftPressed );
}
return;
}
DrawHoveredFace( hit.Body, hit.FaceIndex );
if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )
{
if ( OriginSelected )
DeselectOrigin();
SelectIdleFace( hit, Gizmo.IsShiftPressed );
}
return;
}
if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered && !Gizmo.IsHovered )
ClearIdleSelection();
}
/// <summary>
/// Chosen idle geometry, in the same amber the dialog uses for a committed pick.
///
/// Skipped when a dialog is already drawing its own SelectedFaces / SelectedBodyIds, so the
/// two never paint the same face twice in slightly different states.
/// </summary>
/// <summary>A sketch on a face sits at the same depth as that face. Prefer the sketch when
/// they tie; prefer a solid that is genuinely in front of the sketch plane.</summary>
private bool SketchBeatsFace( float sketchDistance )
{
if ( !TryPickFaceUnderCursor( out var hit ) )
return true;
var faceDistance = (hit.Reference.Point - _cursorRayOrigin).Length;
return sketchDistance <= faceDistance + 0.05f;
}
private void DrawIdleSelection()
{
// A BRUSH WANTS THE SURFACE, NOT THE SELECTION. An amber wash and wireframe over the part
// you are painting hides the very colour you are laying down. The selection survives — it
// comes back when the brush is put away — only the drawing of it stops. The shell is cleared
// too: it is a cached scene object, and a frame that simply stops drawing leaves it lit.
if ( IsPainting || IsSculpting || IsMaterialBrushing || IsWeightPainting )
{
SyncBodyHighlight( null, BodySelectedColor );
return;
}
if ( (SelectedSketchFeatureId is null || SelectedSketchFeatureId.Length == 0)
&& _idleSketchFeatureId is not null )
{
var seed = _idleRegionSeeds.Count == 1 ? _idleRegionSeeds[0] : (Vec2?)null;
if ( _idleRegionSeeds.Count <= 1 )
DrawSketchPickHighlight( _idleSketchFeatureId, seed );
else
{
foreach ( var region in _idleRegionSeeds )
DrawSketchPickHighlight( _idleSketchFeatureId, region );
}
}
var drawFaces = SelectedFaces is null || SelectedFaces.Count == 0;
var drawEdges = SelectedEdges is null || SelectedEdges.Count == 0;
var drawBodies = SelectedBodyIds is null || SelectedBodyIds.Count == 0;
if ( drawEdges && _idleEdges.Count > 0 )
{
foreach ( var edge in _idleEdges )
{
if ( FacePlane.TryResolveEdge( _displayBodies, edge, out var body, out var key ) )
DrawEdge( body, key, FaceSelectedColor );
}
}
if ( drawFaces && _idleFaces.Count > 0 )
{
foreach ( var face in _idleFaces )
{
if ( FacePlane.TryResolveFace( _displayBodies, face, out var body, out var index ) )
DrawFace( body, index, FaceSelectedColor );
}
// A face pick names its owning body, so _idleBodyIds is not empty here — but the thing
// selected is the face, and shelling the whole part around it would bury the pick.
SyncBodyHighlight( null, BodySelectedColor );
return;
}
// A DIALOG OWNS THE SHELL WHILE ONE IS PICKING BODIES. DrawChosenBodies drives the same
// cache from the other side, and clearing it here — which is what "no idle selection" would
// otherwise mean — would blank the picker's own highlight every frame.
if ( !drawBodies )
return;
// EVERY OTHER PATH OUT OF HERE ANSWERS THIS, including the ones with nothing to light. The
// shell is a cached scene object rather than lines drawn this frame (see
// EffigyViewport.Highlight), so a frame that simply stops drawing it leaves it on screen.
_highlightBodies.Clear();
foreach ( var body in _displayBodies )
{
if ( body?.Id is not { } id || !_idleBodyIds.Contains( id ) )
continue;
// THE SAME DRAW A VIEWPORT CLICK USES, face for face.
//
// Not a similar one, not a body-shaped variant of one — DrawFace with FaceSelectedColor,
// which is literally the call that lights a face up when you click the solid. Clicking a
// row in the Parts list means "that whole part", so it runs over every face of the body
// and the part comes up in the amber you already know. Every attempt at this that drew
// something of its own instead — the wash-and-outline body highlight, the cached shell —
// failed in a way that looked from the outside like the click doing nothing at all.
//
// The shell stays for the case that earns it: an import too dense to draw a face at a
// time, where this loop is hundreds of thousands of gizmo calls a frame.
// ONE PASS, NOT ONE PER FACE. Calling DrawFace in a loop was the obvious way to say "the
// same amber a face pick gives you" and it is quadratic: DrawFace resolves the COPLANAR
// SURFACE the face belongs to and draws every face in it, so a surface of forty faces is
// drawn forty times, and a part with a few thousand faces spends the frame redrawing
// itself. DrawBodyHighlight paints each face exactly once in the colour it is handed,
// which is the same picture for a fraction of the work.
if ( (body.Mesh?.FaceCount ?? 0) <= HoverHighlightFaceBudget )
DrawBodyHighlight( body, FaceSelectedColor );
else
_highlightBodies.Add( body );
}
SyncBodyHighlight( _highlightBodies, BodySelectedColor );
}
/// <summary>Scratch for the line above — reused rather than allocated, since it is rebuilt every
/// frame and thrown away on almost all of them.</summary>
private readonly List<Body> _highlightBodies = new();
/// <summary>
/// Select one face outright, from something other than a click — the right-click menu, which
/// has already resolved the face under the cursor and now wants a tool pointed at it.
///
/// Replaces the selection rather than adding to it: the face you just right-clicked is the face
/// you meant, and quietly bundling it with whatever was lit a moment ago would hand the tool
/// more than you asked for.
/// </summary>
public void SelectFace( EffigyFaceHit hit ) => SelectIdleFace( hit, add: false );
/// <summary>Click a part in the rig workspace: the whole body, never one of its faces.</summary>
private void SelectIdleBody( string bodyId, bool add )
{
if ( string.IsNullOrEmpty( bodyId ) )
return;
_idleFaces.Clear();
_idleEdges.Clear();
ClearIdleSketch();
if ( add )
{
if ( !_idleBodyIds.Remove( bodyId ) )
_idleBodyIds.Add( bodyId );
}
else
{
_idleBodyIds.Clear();
_idleBodyIds.Add( bodyId );
}
IdleSelectionChanged?.Invoke();
}
/// <summary>Click a face: replace the selection, or Shift-click to toggle it in.</summary>
private void SelectIdleFace( EffigyFaceHit hit, bool add )
{
var face = hit.Reference;
if ( add )
{
for ( var i = 0; i < _idleFaces.Count; i++ )
{
if ( !SameIdleFace( _idleFaces[i], face ) )
continue;
_idleFaces.RemoveAt( i );
RebuildIdleBodyIdsFromFaces();
IdleSelectionChanged?.Invoke();
return;
}
_idleEdges.Clear();
ClearIdleSketch();
_idleFaces.Add( face );
if ( !_idleBodyIds.Contains( hit.Body.Id ) )
_idleBodyIds.Add( hit.Body.Id );
}
else
{
_idleFaces.Clear();
_idleEdges.Clear();
_idleBodyIds.Clear();
ClearIdleSketch();
_idleFaces.Add( face );
_idleBodyIds.Add( hit.Body.Id );
}
IdleSelectionChanged?.Invoke();
}
private void SelectIdleEdge( Body body, EdgeRef edge, bool add )
{
if ( add )
{
for ( var i = 0; i < _idleEdges.Count; i++ )
{
if ( !SameIdleEdge( _idleEdges[i], edge ) )
continue;
_idleEdges.RemoveAt( i );
RebuildIdleBodyIdsFromFaces();
IdleSelectionChanged?.Invoke();
return;
}
_idleFaces.Clear();
ClearIdleSketch();
_idleEdges.Add( edge );
if ( !_idleBodyIds.Contains( body.Id ) )
_idleBodyIds.Add( body.Id );
}
else
{
_idleFaces.Clear();
_idleEdges.Clear();
_idleBodyIds.Clear();
ClearIdleSketch();
_idleEdges.Add( edge );
_idleBodyIds.Add( body.Id );
}
IdleSelectionChanged?.Invoke();
}
private bool TryIdleEdge( EffigyFaceHit hit, out EdgeRef edge, out EdgeKey key )
{
edge = default;
key = default;
if ( !TryClosestEdge( hit.Body.Mesh, hit.FaceIndex, hit.Reference.Point, out key, out var distance ) )
return false;
var point = hit.Reference.Point;
var threshold = WorldRadiusAt( new Vector3( point.x, point.y, point.z ), EdgePickPixels );
if ( distance > threshold )
return false;
edge = FacePlane.Capture( hit.Body, key );
return true;
}
private void RebuildIdleBodyIdsFromFaces()
{
_idleBodyIds.Clear();
foreach ( var face in _idleFaces )
{
if ( !_idleBodyIds.Contains( face.BodyId ) )
_idleBodyIds.Add( face.BodyId );
}
foreach ( var edge in _idleEdges )
{
if ( !_idleBodyIds.Contains( edge.BodyId ) )
_idleBodyIds.Add( edge.BodyId );
}
}
private bool SameIdleEdge( EdgeRef a, EdgeRef b )
{
if ( !FacePlane.TryResolveEdge( _displayBodies, a, out var bodyA, out var keyA )
|| !FacePlane.TryResolveEdge( _displayBodies, b, out var bodyB, out var keyB ) )
return false;
return bodyA.Id == bodyB.Id && keyA.Equals( keyB );
}
/// <summary>Match by resolved face, not by stored FaceRef equality — two clicks on the same
/// face produce two references with slightly different hit points.</summary>
private bool SameIdleFace( FaceRef a, FaceRef b )
{
if ( !FacePlane.TryResolveFace( _displayBodies, a, out var bodyA, out var indexA )
|| !FacePlane.TryResolveFace( _displayBodies, b, out var bodyB, out var indexB ) )
return false;
return bodyA.Id == bodyB.Id && indexA == indexB;
}
}