Editor/EffigyEditor/EffigyViewport.Constraints.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Marionette.EditorTools;
/// <summary>
/// Selecting sketch geometry, so a constraint has something to act on.
///
/// The solver has been able to satisfy eleven kinds of rule for a while and there has been no way
/// to ask for one, because there was nothing in the sketch that a rule could be pointed AT — the
/// Select tool could drag a point and that was the whole of it. This adds a persistent selection of
/// points and curves, and hands it to ConstraintTools, which answers what may be applied.
///
/// CLICK ACCUMULATES, RATHER THAN CTRL-CLICK. Onshape wants a modifier and this deliberately does
/// not, for a reason that is about this repo rather than about taste: no modifier-key API is proven
/// anywhere in the corpus here, and an unproven member name is a compile error that takes the whole
/// editor assembly down. Accumulating on plain clicks needs nothing unproven and suits the job
/// anyway — a constraint is two or three picks and then a menu, so the common case is picking
/// several things in a row. Clicking a selected thing again drops it; clicking empty space or
/// pressing Escape clears.
/// </summary>
internal sealed partial class EffigyViewport
{
/// <summary>What is selected in the sketch right now. Read by the window to build the
/// constraint menu; the kernel's ConstraintTools takes exactly this type.</summary>
public SketchSelection SketchSelection { get; private set; } = new();
/// <summary>Raised when the right button asks for the constraint menu while a sketch is open.
/// The viewport resolves what is selected and stops there — WHICH constraints that allows is a
/// question for the kernel, and the window is what owns the answer.</summary>
public Action SketchConstraintMenuRequested { get; set; }
/// <summary>Raised after a constraint is applied and the sketch re-solved, so the window can
/// snapshot undo and rebuild.</summary>
public Action SketchConstraintApplied { get; set; }
/// <summary>Curve under the cursor while selecting, or null.</summary>
private string _hoverCurveId;
/// <summary>The point a press landed on, held until the release decides whether that press was
/// a click (select it) or the start of a drag (leave the selection alone).</summary>
private int _pressedPoint = -1;
/// <summary>
/// Where that point was when it was pressed, and how many points the sketch had.
///
/// The drag code already tracks whether it moved, in _dragMoved — and clears it in
/// EndPointDrag() on the same frame the button comes up, BEFORE this file gets to look. So the
/// press has to be classified against state this file owns. Comparing the position also handles
/// a drag that came back to where it started, which is a click by any useful definition.
///
/// The point count is the second half of it: dropping a point onto another MERGES them, which
/// renumbers everything after it, and a stale index would then select whatever moved up.
/// </summary>
private Vec2 _pressedAt;
private int _pressedPointCount;
private static readonly Color SketchSelectedColor = new( 0.35f, 0.78f, 1f, 1f );
private static readonly Color SketchHoverColor = new( 0.6f, 0.88f, 1f, 1f );
/// <summary>How close the cursor has to be to a curve to hit it, in SCREEN PIXELS — the same
/// treatment the point handles get, and for the same reason: a sketch can be one unit across or
/// a thousand, and a fixed distance in sketch units is either untouchable or catches everything.
/// </summary>
private const float CurvePickPixels = 6f;
public bool HasSketchSelection =>
SketchSelection is not null && !SketchSelection.IsEmpty;
public void ClearSketchSelection()
{
SketchSelection = new SketchSelection();
PushPrompt();
}
/// <summary>
/// Hover, click and draw the selection. Runs after the point handles, which have already had
/// first refusal on the cursor — a point sitting on a curve must select the point.
/// </summary>
private void SketchSelectionFrame()
{
_hoverCurveId = null;
if ( ActiveSketch is null || SketchTool != SketchToolKind.Select )
{
_pressedPoint = -1;
return;
}
PruneSelection();
DrawSketchSelection();
// A press that landed on a point is settled on RELEASE: moved means it was a drag and the
// selection is none of its business, still means it was a click.
if ( _pressedPoint >= 0 && !Gizmo.IsLeftMouseDown )
{
var stillThere = _pressedPointCount == ActiveSketch.Points.Count
&& _pressedPoint < ActiveSketch.Points.Count;
if ( stillThere && (ActiveSketch.Points[_pressedPoint] - _pressedAt).Length < 1e-6f )
Toggle( _pressedPoint );
_pressedPoint = -1;
}
// A live drag of either kind is none of the selection's business: the cursor leaves what it
// grabbed the moment it starts moving, and everything it crosses on the way would otherwise
// be selected behind it.
if ( _dragPoint >= 0 || DraggingCurveHandle )
return;
if ( !_canvasHasCursor || !_cursorOnPlaneValid )
return;
// A glyph under the cursor owns the click — it is drawn on top of the geometry, so without
// this, removing a rule would also select whatever line the mark was sitting over.
if ( CursorOnConstraintMark )
return;
if ( _hoverPoint >= 0 )
{
// The point handles own this cursor. Remember the press so the release can classify it.
if ( Gizmo.WasLeftMousePressed )
{
_pressedPoint = _hoverPoint;
_pressedAt = ActiveSketch.Points[_hoverPoint];
_pressedPointCount = ActiveSketch.Points.Count;
}
return;
}
// A grip on the curve owns the click the same way a constraint glyph does - it is drawn on
// the curve, so without this every grab would also toggle that curve into the selection.
if ( CursorOnCurveHandle )
return;
_hoverCurveId = CurveUnderCursor();
if ( !Gizmo.WasLeftMousePressed )
return;
if ( _hoverCurveId is not null )
{
Toggle( _hoverCurveId );
return;
}
// Empty plane. Clearing here is what makes the accumulating selection bearable — there is
// always somewhere to click that means "start again".
if ( HasSketchSelection )
ClearSketchSelection();
}
/// <summary>The curve nearest the cursor within the pick radius, or null. Measured against the
/// TESSELLATION rather than the ideal curve, so an arc is picked where it is drawn.</summary>
private string CurveUnderCursor()
{
var reach = UnitsPerPixel() * CurvePickPixels;
var best = reach;
string found = null;
foreach ( var curve in ActiveSketch.Curves )
{
var pts = curve.Tessellate( ActiveSketch, ActiveSketch.Tolerance );
for ( var i = 0; i < pts.Count - 1; i++ )
{
var d = DistanceToSegment( _cursorOnPlane, pts[i], pts[i + 1] );
if ( d >= best )
continue;
best = d;
found = curve.Id;
}
}
return found;
}
private static float DistanceToSegment( Vec2 p, Vec2 a, Vec2 b )
{
var along = b - a;
var lengthSquared = along.LengthSquared;
if ( lengthSquared < 1e-12f )
return (p - a).Length;
var t = Math.Clamp( Vec2.Dot( p - a, along ) / lengthSquared, 0f, 1f );
return (p - (a + along * t)).Length;
}
private void Toggle( int point )
{
if ( !SketchSelection.Points.Remove( point ) )
SketchSelection.Points.Add( point );
PushPrompt();
}
private void Toggle( string curveId )
{
if ( !SketchSelection.Curves.Remove( curveId ) )
SketchSelection.Curves.Add( curveId );
PushPrompt();
}
/// <summary>
/// Drop anything the selection refers to that is no longer there.
///
/// Undo, a deleted curve and a re-solve that merged two points all leave a selection pointing at
/// something gone. A stale POINT index is the dangerous one: indices are positional, so a
/// removed point does not leave a hole, it renumbers everything after it — a stale index still
/// resolves, to the wrong point, and the constraint would be applied to whatever moved up.
/// </summary>
private void PruneSelection()
{
SketchSelection.Points.RemoveAll( p => p < 0 || p >= ActiveSketch.Points.Count );
SketchSelection.Curves.RemoveAll( id => ActiveSketch.Curves.All( c => c.Id != id ) );
}
private void DrawSketchSelection()
{
Gizmo.Draw.IgnoreDepth = true;
foreach ( var curve in ActiveSketch.Curves )
{
var selected = SketchSelection.Curves.Contains( curve.Id );
var hovered = curve.Id == _hoverCurveId;
if ( !selected && !hovered )
continue;
Gizmo.Draw.Color = selected ? SketchSelectedColor : SketchHoverColor;
Gizmo.Draw.LineThickness = selected ? 4f : 3f;
var pts = curve.Tessellate( ActiveSketch, ActiveSketch.Tolerance );
for ( var i = 0; i < pts.Count - 1; i++ )
Gizmo.Draw.Line( PlaneToWorld( pts[i] ), PlaneToWorld( pts[i + 1] ) );
}
Gizmo.Draw.LineThickness = 2f;
Gizmo.Draw.Color = SketchSelectedColor;
var radius = UnitsPerPixel() * 4.5f;
foreach ( var point in SketchSelection.Points )
Gizmo.Draw.SolidSphere( PlaneToWorld( ActiveSketch.Points[point] ), radius, 10, 10 );
Gizmo.Draw.IgnoreDepth = false;
}
/// <summary>
/// Apply one offer, re-solve, and report.
///
/// PINNED ON THE FIRST SELECTED POINT where there is one. The solver has to hold something still
/// or the whole sketch is free to slide, and its default is point 0 — which is wherever the user
/// first clicked, possibly a long time ago and nowhere near what they are working on. Pinning
/// something they just selected makes the sketch resolve around their attention.
/// </summary>
public bool ApplySketchConstraint( ConstraintOffer offer )
{
if ( ActiveSketch is null || offer is null )
return false;
var pin = SketchSelection.Points.Count > 0 ? SketchSelection.Points[0] : 0;
if ( pin < 0 || pin >= ActiveSketch.Points.Count )
pin = 0;
var result = ConstraintTools.ApplyAndSolve( ActiveSketch, offer, pin );
if ( !result.Applied )
{
SketchPromptChanged?.Invoke( result.Message );
return false;
}
SketchConstraintApplied?.Invoke();
// The degrees of freedom are the one number a CAD user actually wants after a constraint,
// and the solver has been reporting them all along with nothing to show them.
SketchPromptChanged?.Invoke( result.Solve.DegreesOfFreedom == 0
? $"{offer.Label} applied — the sketch is fully defined"
: $"{offer.Label} applied — {result.Solve.DegreesOfFreedom} degree{(result.Solve.DegreesOfFreedom == 1 ? "" : "s")} of freedom left" );
return true;
}
// --- the marks on the sketch ------------------------------------------------------------------
/// <summary>Where each glyph ended up this frame, so a click can find one. Rebuilt every frame
/// rather than cached: the geometry moves under them constantly, and a stale hit box that
/// deletes the wrong rule is worse than no hit box at all.</summary>
private readonly List<(ConstraintMarker Marker, Vec2 At)> _markerHits = new();
private ConstraintMarker _hoverMarker;
/// <summary>How far off the geometry a glyph sits, and how close a click has to land, both in
/// SCREEN PIXELS — the kernel gives an anchor and a direction and deliberately leaves the
/// distance alone, because a sketch can be one unit across or a thousand.</summary>
private const float MarkerOffsetPixels = 14f;
private const float MarkerPickPixels = 9f;
private static readonly Color ConstraintMarkColor = new( 0.45f, 0.85f, 0.6f, 1f );
private static readonly Color DimensionMarkColor = new( 1f, 0.82f, 0.35f, 1f );
private static readonly Color MarkHoverColor = new( 1f, 0.42f, 0.38f, 1f );
/// <summary>Whether the rules that hold the sketch together are drawn on it. On by default —
/// a constraint you cannot see is a constraint you fight.</summary>
public bool ShowConstraintMarks { get; set; } = true;
/// <summary>
/// Draw a glyph per rule, and let one be clicked away.
///
/// DELETING IS ON THE GLYPH rather than in a list somewhere, because "why will this line not
/// move" is a question about a specific place on the drawing, and the answer should be sitting
/// there next to it.
/// </summary>
private void ConstraintMarkFrame()
{
_markerHits.Clear();
_hoverMarker = null;
if ( ActiveSketch is null || !ShowConstraintMarks )
return;
var units = UnitsPerPixel();
var offset = units * MarkerOffsetPixels;
var reach = units * MarkerPickPixels;
// The point handles get first refusal on the cursor, and now that they are picked on the
// plane rather than by a depth-tested hitbox they and the glyphs can genuinely both be in
// reach at once. A glyph sits a few pixels off the geometry it belongs to, so the overlap is
// small - but a click landing on both would grab the point AND delete the rule.
var hoverable = SketchTool == SketchToolKind.Select && _canvasHasCursor && _cursorOnPlaneValid
&& _hoverPoint < 0 && _dragPoint < 0;
foreach ( var marker in ConstraintTools.Markers( ActiveSketch ) )
{
var at = marker.Anchor + marker.Away * offset;
// A mark with no side to sit on — a coincidence, a symmetry — is nudged up and right so
// it clears the point it belongs to instead of being drawn on top of it.
if ( marker.Away.Length < 1e-6f )
at = marker.Anchor + new Vec2( 1f, 1f ).Normal * offset;
_markerHits.Add( (marker, at) );
if ( hoverable && _hoverMarker is null && (_cursorOnPlane - at).Length < reach )
_hoverMarker = marker;
var color = _hoverMarker == marker ? MarkHoverColor
: marker.IsDimension ? DimensionMarkColor : ConstraintMarkColor;
DrawDimensionText( PlaneToWorld( at ), marker.Label, color, 0f );
}
if ( _hoverMarker is null )
return;
// Only once it is actually under the cursor, so the prompt is not shouting the whole time.
SketchPromptChanged?.Invoke( $"{Name( _hoverMarker.Kind )} — click to remove it" );
if ( Gizmo.WasLeftMousePressed )
RemoveConstraint( _hoverMarker.Constraint );
}
/// <summary>
/// Delete one rule and re-solve.
///
/// The sketch does NOT spring back to where it was before the rule was added — nothing recorded
/// that, and inventing it would be worse than leaving the geometry alone. Removing a constraint
/// only ever gives freedom back; what the shape does with that freedom is up to the next drag.
/// </summary>
private void RemoveConstraint( SketchConstraint constraint )
{
if ( ActiveSketch is null || constraint is null )
return;
SketchEditing?.Invoke();
if ( !ActiveSketch.Constraints.Remove( constraint ) )
return;
var result = SketchSolver.Solve( ActiveSketch );
SketchConstraintApplied?.Invoke();
SketchPromptChanged?.Invoke( result.DegreesOfFreedom == 0
? "Constraint removed — the sketch is still fully defined"
: $"Constraint removed — {result.DegreesOfFreedom} degree{(result.DegreesOfFreedom == 1 ? "" : "s")} of freedom" );
}
/// <summary>Whether a click this frame landed on a glyph, so the selection code can leave it
/// alone. Without this, clicking a mark to delete it would ALSO clear the selection, or pick
/// whatever curve happened to be under the glyph.</summary>
private bool CursorOnConstraintMark => _hoverMarker is not null;
new static string Name( SketchConstraintKind kind ) => kind switch
{
SketchConstraintKind.Horizontal => "Horizontal",
SketchConstraintKind.Vertical => "Vertical",
SketchConstraintKind.Coincident => "Coincident",
SketchConstraintKind.Distance => "Distance",
SketchConstraintKind.EqualLength => "Equal length",
SketchConstraintKind.Parallel => "Parallel",
SketchConstraintKind.Perpendicular => "Perpendicular",
SketchConstraintKind.Angle => "Angle",
SketchConstraintKind.PointOnLine => "Point on line",
SketchConstraintKind.Symmetric => "Symmetric",
SketchConstraintKind.Radius => "Radius",
SketchConstraintKind.Diameter => "Diameter",
SketchConstraintKind.Midpoint => "Midpoint",
SketchConstraintKind.Concentric => "Concentric",
SketchConstraintKind.Fixed => "Fix",
SketchConstraintKind.Tangent => "Tangent",
SketchConstraintKind.TangentArcs => "Tangent",
_ => "Constraint",
};
/// <summary>What the status bar says about the selection, appended to the tool's own prompt.</summary>
private string SelectionPrompt()
{
if ( !HasSketchSelection )
return null;
var parts = new List<string>();
if ( SketchSelection.Points.Count > 0 )
parts.Add( $"{SketchSelection.Points.Count} point{(SketchSelection.Points.Count == 1 ? "" : "s")}" );
if ( SketchSelection.Curves.Count > 0 )
parts.Add( $"{SketchSelection.Curves.Count} curve{(SketchSelection.Curves.Count == 1 ? "" : "s")}" );
var offers = ConstraintTools.Offers( ActiveSketch, SketchSelection ).Count;
return offers > 0
? $"{string.Join( " and ", parts )} selected — right-click for {offers} constraint{(offers == 1 ? "" : "s")}"
: $"{string.Join( " and ", parts )} selected — nothing can be constrained from this; click empty space to start again";
}
}