Editor/EffigyEditor/EffigyViewport.SketchTools.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Marionette.EditorTools;
/// <summary>
/// The six sketch tools whose kernel half was finished long before their UI: ellipse, spline, trim,
/// extend, fillet and offset.
///
/// THEY LIVE IN THEIR OWN FILE AND INTERCEPT BEFORE THE EXISTING SWITCHES, deliberately. Twelve
/// drawing tools already share the state machine in EffigyViewport.Sketching.cs and all twelve work.
/// Threading six more cases through its click, preview and prompt switches means editing three
/// places in code that is known-good, blind, to add tools that are not. So instead there are three
/// one-line hooks at the top of those three methods, and everything else is here: if a hook returns
/// true the new tool owned the interaction and the old machine never sees it.
///
/// TWO KINDS OF TOOL, and the split is what makes them simple:
///
/// - **Ellipse and spline DRAW.** They collect clicks in _pending exactly like line and arc do.
/// - **Trim, extend, fillet and offset EDIT.** They act on geometry that is already there, so they
/// pick a curve or a point under the cursor rather than placing anything. The kernel does the
/// work and hands back a reason when it will not — SketchEdit was written that way, and every
/// refusal here is its words rather than ours.
///
/// AND ONE THAT IS NEITHER: **Cut DRAGS.** It is the only tool in the sketcher driven by a held
/// button, so it never reaches ClickTool at all — CutStrokeFrame takes the mouse a frame earlier,
/// which is what keeps one stroke to one undo step instead of one per press. See it at the bottom
/// of this file.
/// </summary>
internal sealed partial class EffigyViewport
{
/// <summary>Points placed so far by the spline tool, which is the one tool with no fixed count.</summary>
private readonly List<Vec2> _splinePoints = new();
/// <summary>How close, in SCREEN PIXELS, a click has to be to a point for the fillet tool to
/// take it as the corner. Same treatment as every other pick in this viewport, and for the same
/// reason: a sketch can be one unit across or a thousand.</summary>
private const float CornerPickPixels = 10f;
/// <summary>True when this tool is one of the six here rather than one of the original twelve.
/// Use joined them later - it is the same shape of thing, a pick rather than a placement - and
/// so did Cut, which is a THIRD kind again: it is neither a click nor a pick but a drag, and it
/// is here because the alternative was a fourth branch through the twelve-tool machine.</summary>
private static bool IsNewSketchTool( SketchToolKind tool ) => tool
is SketchToolKind.Ellipse or SketchToolKind.Spline or SketchToolKind.Trim
or SketchToolKind.Extend or SketchToolKind.Fillet or SketchToolKind.Offset
or SketchToolKind.Use or SketchToolKind.Cut;
// --- clicks ---------------------------------------------------------------------------------
/// <summary>
/// Handle a click for one of the six. Returns true when it owned the click, which is what keeps
/// the original switch from also seeing it.
///
/// The caller has already pushed the point onto _pending, so a tool that does not want it there
/// clears it.
/// </summary>
private bool HandleNewSketchToolClick( Vec2 p )
{
if ( ActiveSketch is null || !IsNewSketchTool( SketchTool ) )
return false;
switch ( SketchTool )
{
case SketchToolKind.Ellipse when _pending.Count == 3:
{
var centre = _pending[0];
var majorEnd = _pending[1];
// The third click sets the MINOR radius by how far it sits off the major axis, so the
// ellipse follows the cursor instead of needing a fourth number typed at it.
var minor = PerpendicularDistance( centre, majorEnd, _pending[2] );
if ( (majorEnd - centre).Length > 1e-4f && minor > 1e-4f )
Track( new SketchEllipse( PointIndex( centre ), PointIndex( majorEnd ), minor ) );
_pending.Clear();
Edited();
return true;
}
case SketchToolKind.Ellipse:
return true;
case SketchToolKind.Spline:
{
// A spline has no fixed number of points, so the click never commits - Enter does.
// See HandleSketchToolKey.
_splinePoints.Add( p );
_pending.Clear();
PushPrompt();
return true;
}
case SketchToolKind.Use:
_pending.Clear();
ApplyUse();
return true;
// A click never reaches here: CutStrokeFrame takes the press a step earlier in the frame
// and SketchFrame returns. The case exists so that if that order is ever disturbed the
// click is swallowed rather than falling through to the twelve-tool machine, which would
// answer a press of the cut tool by placing a point.
case SketchToolKind.Cut:
_pending.Clear();
return true;
case SketchToolKind.Trim:
_pending.Clear();
ApplyTrim( p );
return true;
case SketchToolKind.Extend:
_pending.Clear();
ApplyExtend( p );
return true;
case SketchToolKind.Fillet when _pending.Count == 1:
{
// The first click names the corner. It has to BE a point - a fillet is defined on one,
// and guessing the nearest would round a corner nobody pointed at.
if ( NearestPoint( p ) is not { } corner )
{
_pending.Clear();
SetSketchPrompt( "Fillet - click nearer a corner point of the sketch." );
return true;
}
_filletCorner = corner;
PushPrompt();
return true;
}
case SketchToolKind.Fillet:
{
var radius = (p - ActiveSketch.Points[_filletCorner]).Length;
if ( !SketchEdit.Fillet( ActiveSketch, _filletCorner, radius, out var error ) )
SetSketchPrompt( $"Fillet - {error}" );
else
Edited();
_filletCorner = -1;
_pending.Clear();
return true;
}
case SketchToolKind.Offset when _pending.Count == 1:
{
var id = CurveUnderCursor();
if ( id is null )
{
_pending.Clear();
SetSketchPrompt( "Offset - click on a curve to offset." );
return true;
}
_offsetCurveId = id;
PushPrompt();
return true;
}
case SketchToolKind.Offset:
{
ApplyOffset( p );
_offsetCurveId = null;
_pending.Clear();
return true;
}
}
return false;
}
private int _filletCorner = -1;
private string _offsetCurveId;
// --- Use ------------------------------------------------------------------------------------
/// <summary>
/// Copy the face edge under the cursor into the sketch, turning it from scenery into geometry.
///
/// THE PICK IS ALREADY DONE. Every non-Select tool runs the cursor through SnapPoint each frame,
/// and that reports which reference edge it slid onto - the same one drawn lit. So this tool
/// needs no hit test of its own, and what you get is exactly the edge you can see highlighted,
/// which is the property that makes a picking tool trustworthy.
/// </summary>
private void ApplyUse()
{
if ( ActiveSketchReference is not { } reference )
{
SetSketchPrompt( "Use - this sketch is on a global plane, so there is nothing underneath it to use." );
return;
}
if ( _snapReferenceEdge < 0 )
{
SetSketchPrompt( "Use - hover one of the green edges of the face until it lights up, then click it." );
return;
}
// NO SketchEditing HERE. ClickTool already took the undo snapshot before handing the click
// on - the same as Trim and Extend, which is why neither of them takes one either. A second
// snapshot for one action makes the first Ctrl+Z look like it did nothing.
if ( reference.UseEdge( ActiveSketch, _snapReferenceEdge ) is null )
{
SetSketchPrompt( "Use - that edge is already in the sketch." );
return;
}
Edited();
}
/// <summary>
/// Take the WHOLE outline in one press, which is what the tool is nearly always for: four edges
/// and then a line across them is the shape of the job, and clicking each edge in turn is four
/// chances to miss one and wonder why the region will not close.
///
/// Not a mode - it happens on the press and there is nothing to aim, so it is a plain button
/// rather than a tool that arms.
/// </summary>
public void UseAllReferenceEdges()
{
if ( ActiveSketch is null )
return;
if ( ActiveSketchReference is not { } reference )
{
SetSketchPrompt( "Use all - this sketch is on a global plane, so there is nothing underneath it to use." );
return;
}
// This one DOES snapshot: it comes from a button rather than a viewport click, so ClickTool
// never ran and nothing else has recorded the state to go back to.
SketchEditing?.Invoke();
var added = reference.UseAll( ActiveSketch );
if ( added == 0 )
{
SetSketchPrompt( "Use all - the face's outline is already in the sketch." );
return;
}
SetSketchPrompt( added == 1
? "Use all - took 1 edge of the face into the sketch."
: $"Use all - took {added} edges of the face into the sketch." );
Edited();
}
// --- the four edits -------------------------------------------------------------------------
private void ApplyTrim( Vec2 p )
{
if ( CurveUnderCursor() is not { } id || FindCurve( id ) is not { } curve )
{
SetSketchPrompt( "Trim - click on the piece of a curve you want gone." );
return;
}
if ( !SketchEdit.Trim( ActiveSketch, curve, p, out var error ) )
{
SetSketchPrompt( $"Trim - {error}" );
return;
}
Edited();
}
private void ApplyExtend( Vec2 p )
{
if ( CurveUnderCursor() is not { } id || FindCurve( id ) is not { } curve )
{
SetSketchPrompt( "Extend - click on the end of a curve you want stretched." );
return;
}
// WHICH END was clicked, by tessellating and comparing against both ends. A tool that always
// extended the start would be right half the time, which is worse than being wrong: it would
// look like the tool sometimes not working.
var points = curve.Tessellate( ActiveSketch, ActiveSketch.Tolerance );
if ( points.Count < 2 )
{
SetSketchPrompt( "Extend - that curve has no ends to stretch." );
return;
}
var atStart = (p - points[0]).Length < (p - points[^1]).Length;
if ( !SketchEdit.Extend( ActiveSketch, curve, atStart, out var error ) )
{
SetSketchPrompt( $"Extend - {error}" );
return;
}
Edited();
}
private void ApplyOffset( Vec2 p )
{
if ( FindCurve( _offsetCurveId ) is not { } curve )
return;
// The whole selection when there is one, so offsetting a closed profile is one gesture rather
// than one per side. The clicked curve alone otherwise.
var chain = HasSketchSelection && SketchSelection.Curves.Count > 0
? SketchSelection.Curves.Select( FindCurve ).Where( c => c is not null ).ToList()
: new List<SketchCurve> { curve };
var distance = SignedOffset( curve, p );
if ( !SketchEdit.Offset( ActiveSketch, chain, distance, out var created, out var error ) )
{
SetSketchPrompt( $"Offset - {error}" );
return;
}
// The kernel returns what it made rather than adding it, so construction mode is applied here
// the way Track applies it to everything else drawn.
foreach ( var made in created )
made.Construction = ConstructionMode;
Edited();
}
/// <summary>
/// How far the click sits from the curve, and WHICH SIDE it is on.
///
/// The sign is the whole of "which way does it go", and taking a magnitude would make offset a
/// coin flip that lands on the wrong side half the time.
/// </summary>
private float SignedOffset( SketchCurve curve, Vec2 p )
{
var points = curve.Tessellate( ActiveSketch, ActiveSketch.Tolerance );
if ( points.Count < 2 )
return 0f;
var best = float.MaxValue;
var sign = 1f;
for ( var i = 0; i < points.Count - 1; i++ )
{
var a = points[i];
var b = points[i + 1];
var along = b - a;
var lengthSquared = along.LengthSquared;
if ( lengthSquared < 1e-12f )
continue;
var t = Math.Clamp( ((p - a).x * along.x + (p - a).y * along.y) / lengthSquared, 0f, 1f );
var closest = new Vec2( a.x + along.x * t, a.y + along.y * t );
var distance = (p - closest).Length;
if ( distance >= best )
continue;
best = distance;
sign = along.x * (p.y - a.y) - along.y * (p.x - a.x) < 0f ? -1f : 1f;
}
return best == float.MaxValue ? 0f : best * sign;
}
private SketchCurve FindCurve( string id ) =>
id is null ? null : ActiveSketch.Curves.FirstOrDefault( c => c.Id == id );
/// <summary>The sketch point nearest the click, within reach, or null.</summary>
private int? NearestPoint( Vec2 p )
{
var reach = UnitsPerPixel() * CornerPickPixels;
var best = reach;
int? found = null;
for ( var i = 0; i < ActiveSketch.Points.Count; i++ )
{
var d = (ActiveSketch.Points[i] - p).Length;
if ( d >= best )
continue;
best = d;
found = i;
}
return found;
}
private static float PerpendicularDistance( Vec2 a, Vec2 b, Vec2 p )
{
var along = b - a;
var length = along.Length;
if ( length < 1e-9f )
return (p - a).Length;
return MathF.Abs( along.x * (p.y - a.y) - along.y * (p.x - a.x) ) / length;
}
// --- the spline's own ending ------------------------------------------------------------------
/// <summary>
/// Enter finishes a spline, and Escape abandons it.
///
/// It is the one tool here with no fixed number of clicks, so it needs a way to say "that is all
/// of them". Enter is what every other tool in this editor uses to commit a typed number, which
/// makes it the least surprising key for the job.
/// </summary>
public bool HandleSketchToolKey( KeyEvent e )
{
if ( ActiveSketch is null || SketchTool != SketchToolKind.Spline || _splinePoints.Count == 0 )
return false;
switch ( e.Key )
{
case KeyCode.Return:
case KeyCode.Enter:
CommitSpline();
e.Accepted = true;
return true;
case KeyCode.Escape:
_splinePoints.Clear();
PushPrompt();
e.Accepted = true;
return true;
}
return false;
}
private void CommitSpline()
{
if ( _splinePoints.Count >= 2 )
{
SketchEditing?.Invoke();
var indices = new List<int>( _splinePoints.Count );
foreach ( var p in _splinePoints )
indices.Add( PointIndex( p ) );
Track( new SketchSpline( indices ) );
Edited();
}
_splinePoints.Clear();
PushPrompt();
}
// --- preview ----------------------------------------------------------------------------------
/// <summary>Draw the six tools' own previews. True means the original preview switch should not
/// also run.</summary>
private bool DrawNewSketchToolPreview()
{
if ( ActiveSketch is null || !IsNewSketchTool( SketchTool ) )
return false;
switch ( SketchTool )
{
case SketchToolKind.Ellipse when _pending.Count == 1:
Gizmo.Draw.Line( PlaneToWorld( _pending[0] ), PlaneToWorld( _cursorOnPlane ) );
LiveLength( _pending[0], _cursorOnPlane );
break;
case SketchToolKind.Ellipse when _pending.Count == 2:
DrawEllipsePreview( _pending[0], _pending[1],
PerpendicularDistance( _pending[0], _pending[1], _cursorOnPlane ) );
break;
case SketchToolKind.Spline:
{
// The placed points and the leg to the cursor, so the shape is visible before Enter.
for ( var i = 0; i < _splinePoints.Count - 1; i++ )
Gizmo.Draw.Line( PlaneToWorld( _splinePoints[i] ), PlaneToWorld( _splinePoints[i + 1] ) );
if ( _splinePoints.Count > 0 )
Gizmo.Draw.Line( PlaneToWorld( _splinePoints[^1] ), PlaneToWorld( _cursorOnPlane ) );
break;
}
case SketchToolKind.Fillet when _filletCorner >= 0 && _filletCorner < ActiveSketch.Points.Count:
{
var corner = ActiveSketch.Points[_filletCorner];
DrawCirclePreview( corner, (corner - _cursorOnPlane).Length );
LiveRadius( corner, _cursorOnPlane );
break;
}
case SketchToolKind.Offset when _offsetCurveId is not null:
{
if ( FindCurve( _offsetCurveId ) is { } curve )
LiveLength( ClosestOn( curve, _cursorOnPlane ), _cursorOnPlane );
break;
}
case SketchToolKind.Cut:
DrawCutStroke();
break;
}
return true;
}
private Vec2 ClosestOn( SketchCurve curve, Vec2 p )
{
var points = curve.Tessellate( ActiveSketch, ActiveSketch.Tolerance );
var best = float.MaxValue;
var found = p;
foreach ( var q in points )
{
var d = (q - p).Length;
if ( d >= best )
continue;
best = d;
found = q;
}
return found;
}
/// <summary>An ellipse as a polyline. There is no ellipse primitive to draw with, and at preview
/// resolution the segments are invisible.</summary>
private void DrawEllipsePreview( Vec2 centre, Vec2 majorEnd, float minorRadius )
{
var major = majorEnd - centre;
var majorLength = major.Length;
if ( majorLength < 1e-6f )
return;
var axis = new Vec2( major.x / majorLength, major.y / majorLength );
var perpendicular = new Vec2( -axis.y, axis.x );
const int Segments = 48;
var previous = Vector3.Zero;
for ( var i = 0; i <= Segments; i++ )
{
var angle = i / (float)Segments * MathF.PI * 2f;
var offset = new Vec2(
axis.x * MathF.Cos( angle ) * majorLength + perpendicular.x * MathF.Sin( angle ) * minorRadius,
axis.y * MathF.Cos( angle ) * majorLength + perpendicular.y * MathF.Sin( angle ) * minorRadius );
var point = PlaneToWorld( new Vec2( centre.x + offset.x, centre.y + offset.y ) );
if ( i > 0 )
Gizmo.Draw.Line( previous, point );
previous = point;
}
}
// --- prompts ------------------------------------------------------------------------------------
/// <summary>What the six tools say they want next, or null when the active tool is not one of
/// them and the original prompt switch should answer.</summary>
private string NewSketchToolPrompt()
{
if ( !IsNewSketchTool( SketchTool ) )
return null;
return SketchTool switch
{
SketchToolKind.Ellipse when _pending.Count == 0 => "Ellipse - click the centre",
SketchToolKind.Ellipse when _pending.Count == 1 => "Ellipse - click the end of the long axis",
SketchToolKind.Ellipse => "Ellipse - click to set how far it bulges the other way",
SketchToolKind.Spline when _splinePoints.Count == 0 => "Spline - click to place points",
SketchToolKind.Spline when _splinePoints.Count == 1 => "Spline - click another point (Enter finishes)",
SketchToolKind.Spline => $"Spline - {_splinePoints.Count} points, Enter finishes, right-click or Escape abandons",
SketchToolKind.Use when ActiveSketchReference is null
=> "Use - this sketch is on a global plane; there is no face underneath it to use",
SketchToolKind.Use
=> "Use - click a green edge of the face to copy it into the sketch",
SketchToolKind.Trim => "Trim - click the piece of a curve you want gone",
SketchToolKind.Extend => "Extend - click the end of a curve you want stretched",
SketchToolKind.Fillet when _filletCorner < 0 => "Fillet - click a corner point",
SketchToolKind.Fillet => "Fillet - click to set the radius",
SketchToolKind.Offset when _offsetCurveId is null => "Offset - click the curve to offset",
SketchToolKind.Offset => "Offset - click which side, and how far",
SketchToolKind.Cut => "Cut - hold the left button and drag a line through what you want gone",
_ => null,
};
}
/// <summary>Say something immediately, without waiting for the next PushPrompt. Used to carry a
/// refusal from the kernel straight to the status line.</summary>
private void SetSketchPrompt( string text ) => SketchPromptChanged?.Invoke( text );
/// <summary>Drop whatever the six tools were part-way through. Called when the tool changes, so
/// a half-placed spline does not reappear on a tool that has nothing to do with it.</summary>
private void ResetNewSketchTools()
{
_splinePoints.Clear();
_filletCorner = -1;
_offsetCurveId = null;
_cutStroke.Clear();
_cutting = false;
_cutBroken = false;
_cutCount = 0;
_cutSnapshot = false;
}
// --- the cut stroke ---------------------------------------------------------------------------
/// <summary>The path of the stroke in sketch coordinates, from the press to the last sample.
/// Empty when no stroke is running. It is never geometry - nothing here ever reaches the sketch
/// - it exists to be drawn, and to be handed to the kernel one segment at a time.</summary>
private readonly List<Vec2> _cutStroke = new();
/// <summary>True between the press and the release.</summary>
private bool _cutting;
/// <summary>Set while the cursor is off the canvas or off the plane mid-stroke, so the path
/// starts again where it comes back rather than joining up across the gap.</summary>
private bool _cutBroken;
/// <summary>How many curves this stroke has cut, for the line the status bar shows when it
/// ends.</summary>
private int _cutCount;
/// <summary>Whether this stroke has taken its undo snapshot yet - see CutAlong for why it is
/// taken on the first cut rather than on the press.</summary>
private bool _cutSnapshot;
/// <summary>How far, in SCREEN PIXELS, the cursor has to travel before the stroke takes another
/// sample. Small enough that a fast drag still traces the path it looks like it traced, large
/// enough that a hand resting on a held button does not hand the kernel a hundred zero-length
/// segments a second.</summary>
private const float CutSamplePixels = 2f;
/// <summary>Half the width of the X drawn at the cursor, in SCREEN PIXELS.</summary>
private const float CutCursorPixels = 5f;
/// <summary>The colour of a stroke that is about to remove things. Warm, and close to the one
/// the profile inspector paints a gap in, because both are saying the same word.</summary>
private static readonly Color SketchCutColor = new( 1f, 0.4f, 0.3f, 0.95f );
/// <summary>
/// The cut tool's whole interaction: press, drag, release.
///
/// THE ONLY TOOL IN THIS SKETCHER DRIVEN BY A HELD BUTTON, which is why it returns a bool the
/// way the click hook does. SketchFrame stops at a true and never runs the click machine, and
/// it has to: ClickTool takes an undo snapshot on every press, and the press here is the start
/// of a drag whose whole stroke is meant to be one step.
///
/// EACH SAMPLE IS CUT AS IT ARRIVES rather than the path being cut on release, because the
/// point of dragging a line through geometry is watching the geometry go. The sketch is drawn
/// straight from ActiveSketch every frame, so a curve cut on the fourth sample is gone on the
/// fourth frame. What waits for the release is the REBUILD - exactly as a dragged point waits,
/// and for the same reason: a solid re-extruded forty times during one stroke is thirty-nine
/// rebuilds nobody asked for.
/// </summary>
private bool CutStrokeFrame()
{
if ( ActiveSketch is null || SketchTool != SketchToolKind.Cut )
{
// The tool changed, or the sketch closed, with the button still down. The cuts already
// made are real and the sketch on screen already shows them, so this FINISHES the stroke
// rather than dropping it - the alternative is a rebuild that never happens and a solid
// that disagrees with the sketch it was made from.
if ( _cutting )
EndCutStroke();
return false;
}
if ( !_cutting )
{
if ( _canvasHasCursor && _cursorOnPlaneValid && Gizmo.WasLeftMousePressed )
BeginCutStroke( _cursorOnPlane );
return true;
}
if ( Gizmo.IsLeftMouseDown )
{
// The cursor leaving the canvas does NOT end the stroke, the same as the sculpt brush:
// a drag that swings out over the tool strip and back is one gesture. It stops new
// samples, and the path starts again where it returns - joined up, it would cut a
// straight line through everything the cursor flew over on the way.
if ( !_canvasHasCursor || !_cursorOnPlaneValid )
_cutBroken = true;
else if ( _cutBroken )
RestartCutStroke( _cursorOnPlane );
else
CutAlong( _cursorOnPlane );
return true;
}
// Released. There is no WasLeftMouseReleased in the Gizmo input this editor uses, so the end
// of a stroke is the frame the button is no longer down - SculptFrame infers it the same way
// and says so at more length.
EndCutStroke();
return true;
}
private void BeginCutStroke( Vec2 at )
{
_cutting = true;
_cutBroken = false;
_cutCount = 0;
_cutSnapshot = false;
_cutStroke.Clear();
_cutStroke.Add( at );
}
private void RestartCutStroke( Vec2 at )
{
_cutBroken = false;
_cutStroke.Clear();
_cutStroke.Add( at );
}
/// <summary>
/// Extend the stroke to here, and cut whatever the new piece of path went through.
///
/// All of the geometry is in <see cref="SketchCut"/>, where a test can hold both ends of a
/// segment still. What is left here is the sampling rate and the undo step, which are the two
/// things that only mean anything with a hand on a mouse.
/// </summary>
private void CutAlong( Vec2 to )
{
var from = _cutStroke[^1];
// The sample threshold is in SCREEN PIXELS, like every other reach in this viewport and for
// the same reason: a sketch can be one unit across or a thousand, so a fixed distance in
// sketch units is either every frame or never.
if ( (to - from).Length < UnitsPerPixel() * CutSamplePixels )
return;
_cutStroke.Add( to );
var crossings = SketchCut.Crossings( ActiveSketch, from, to );
if ( crossings.Count == 0 )
return;
// THE UNDO SNAPSHOT IS TAKEN ON THE FIRST CUT, not on the press. A stroke swept across empty
// space changes nothing, and an undo step that restores an identical sketch is a Ctrl+Z that
// looks broken. Once per stroke after that, so the whole drag goes back in one press - the
// same rule the point drag follows.
if ( !_cutSnapshot )
{
SketchEditing?.Invoke();
_cutSnapshot = true;
}
_cutCount += SketchCut.Apply( ActiveSketch, crossings );
}
private void EndCutStroke()
{
var cut = _cutCount;
_cutting = false;
_cutBroken = false;
_cutCount = 0;
_cutSnapshot = false;
_cutStroke.Clear();
if ( cut == 0 )
{
// Worth saying rather than leaving silent. A stroke drawn NEXT TO a line instead of
// through it looks identical while it is being drawn, and "nothing happened" is the one
// outcome the user cannot tell apart from the tool being broken.
SetSketchPrompt( "Cut - nothing was under that stroke; drag the line through a curve to cut it." );
return;
}
SetSketchPrompt( cut == 1
? "Cut - took 1 piece out of the sketch."
: $"Cut - took {cut} pieces out of the sketch." );
Edited();
}
/// <summary>The stroke so far, plus the leg to the cursor so the line reaches the hand rather
/// than the last sample - a couple of pixels behind is small, but a stroke that lags the cursor
/// reads as the tool dropping input.</summary>
private void DrawCutStroke()
{
Gizmo.Draw.Color = SketchCutColor;
Gizmo.Draw.LineThickness = 2.5f;
for ( var i = 0; i < _cutStroke.Count - 1; i++ )
Gizmo.Draw.Line( PlaneToWorld( _cutStroke[i] ), PlaneToWorld( _cutStroke[i + 1] ) );
if ( _cutStroke.Count > 0 && !_cutBroken )
Gizmo.Draw.Line( PlaneToWorld( _cutStroke[^1] ), PlaneToWorld( _cursorOnPlane ) );
DrawCutCursor();
}
/// <summary>An X at the cursor. Every drawing tool marks the cursor with a dot, and a dot means
/// "a point will land here" - this tool places nothing, so it gets a mark that says otherwise.
/// </summary>
private void DrawCutCursor()
{
var size = UnitsPerPixel() * CutCursorPixels;
var c = _cursorOnPlane;
Gizmo.Draw.Color = SketchCutColor;
Gizmo.Draw.LineThickness = 2f;
Gizmo.Draw.Line( PlaneToWorld( new Vec2( c.x - size, c.y - size ) ),
PlaneToWorld( new Vec2( c.x + size, c.y + size ) ) );
Gizmo.Draw.Line( PlaneToWorld( new Vec2( c.x - size, c.y + size ) ),
PlaneToWorld( new Vec2( c.x + size, c.y - size ) ) );
}
}