Editor/Effigy/Sketch/SketchEdit.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy;
/// <summary>Where two curves cross, and how far along each of them the crossing is.</summary>
public readonly struct CurveHit
{
public readonly Vec2 Point;
/// <summary>Position along the first curve, 0 at its start and 1 at its end.</summary>
public readonly float TA;
/// <summary>Position along the second curve, on the same scale.</summary>
public readonly float TB;
public CurveHit( Vec2 point, float ta, float tb )
{
Point = point;
TA = ta;
TB = tb;
}
}
/// <summary>
/// Where sketch curves cross each other.
///
/// ANALYTIC FOR THE PAIRS THAT HAVE A CLOSED FORM, sampled for the rest. Line/line, line/circle and
/// circle/circle are exact, and arcs are their circle with the hits outside the sweep thrown away.
/// Splines and ellipses have no closed form worth carrying, so they fall back to walking their
/// tessellations — which is as accurate as the tessellation tolerance and no more, and is marked as
/// such by <see cref="CurveHit"/> parameters that come from segment indices rather than from
/// geometry.
///
/// Why exactness matters here at all: trim moves a real endpoint onto one of these, and a fillet
/// puts an arc tangent to two lines through one. A sampled intersection would leave a sketch whose
/// corners are a tessellation-tolerance away from meeting, and the loop walk matches endpoints by
/// index rather than position — so a curve trimmed to nearly the right place is a curve that
/// silently stops closing a region.
/// </summary>
public static class SketchIntersect
{
const float Eps = 1e-6f;
/// <summary>Every place two curves cross, ordered along the first of them.</summary>
public static List<CurveHit> Between( Sketch sketch, SketchCurve a, SketchCurve b )
{
if ( ReferenceEquals( a, b ) )
return new List<CurveHit>();
var hits = Compute( sketch, a, b );
hits.Sort( ( x, y ) => x.TA.CompareTo( y.TA ) );
return hits;
}
static List<CurveHit> Compute( Sketch sketch, SketchCurve a, SketchCurve b )
{
if ( a is SketchLine la && b is SketchLine lb )
return LineLine( sketch, la, lb );
if ( a is SketchLine line && IsCircular( b ) )
return LineCircular( sketch, line, b, flip: false );
if ( IsCircular( a ) && b is SketchLine other )
return LineCircular( sketch, other, a, flip: true );
if ( IsCircular( a ) && IsCircular( b ) )
return CircularCircular( sketch, a, b );
return Sampled( sketch, a, b );
}
/// <summary>A circle, or an arc — anything whose geometry is a centre and a radius.</summary>
public static bool IsCircular( SketchCurve curve ) => curve is SketchCircle or SketchArc;
/// <summary>The centre and radius behind a circular curve.</summary>
public static (Vec2 Centre, float Radius) CircleOf( Sketch sketch, SketchCurve curve ) => curve switch
{
SketchCircle c => (sketch.Points[c.Center], c.Radius),
SketchArc arc => (sketch.Points[arc.Center], arc.Radius( sketch )),
_ => throw new InvalidOperationException( $"{curve.GetType().Name} is not a circle or an arc" )
};
static List<CurveHit> LineLine( Sketch sketch, SketchLine a, SketchLine b )
{
var result = new List<CurveHit>();
var p = sketch.Points[a.Start];
var d = sketch.Points[a.End] - p;
var q = sketch.Points[b.Start];
var e = sketch.Points[b.End] - q;
var denom = Vec2.Cross( d, e );
// Parallel, including collinear. Collinear overlap is a real state and has no single
// crossing point, so it is reported as no crossing rather than as an arbitrary one.
if ( MathF.Abs( denom ) < Eps )
return result;
var t = Vec2.Cross( q - p, e ) / denom;
var u = Vec2.Cross( q - p, d ) / denom;
if ( t is < -Eps or > 1f + Eps || u is < -Eps or > 1f + Eps )
return result;
result.Add( new CurveHit( p + d * t, t, u ) );
return result;
}
static List<CurveHit> LineCircular( Sketch sketch, SketchLine line, SketchCurve circular, bool flip )
{
var result = new List<CurveHit>();
var (centre, radius) = CircleOf( sketch, circular );
if ( radius < Eps )
return result;
var p = sketch.Points[line.Start];
var d = sketch.Points[line.End] - p;
var lengthSq = d.LengthSquared;
if ( lengthSq < Eps * Eps )
return result;
// |p + t d - c|^2 = r^2, expanded into a quadratic in t.
var f = p - centre;
var bq = 2f * Vec2.Dot( f, d );
var cq = f.LengthSquared - radius * radius;
var disc = bq * bq - 4f * lengthSq * cq;
if ( disc < 0f )
return result;
var root = MathF.Sqrt( disc );
foreach ( var t in new[] { (-bq - root) / (2f * lengthSq), (-bq + root) / (2f * lengthSq) } )
{
if ( t is < -Eps or > 1f + Eps )
continue;
var point = p + d * t;
if ( !OnCurve( sketch, circular, point, out var tc ) )
continue;
result.Add( flip ? new CurveHit( point, tc, t ) : new CurveHit( point, t, tc ) );
}
return result;
}
static List<CurveHit> CircularCircular( Sketch sketch, SketchCurve a, SketchCurve b )
{
var result = new List<CurveHit>();
var (ca, ra) = CircleOf( sketch, a );
var (cb, rb) = CircleOf( sketch, b );
var delta = cb - ca;
var dist = delta.Length;
// Concentric, or too far apart, or one swallowed by the other.
if ( dist < Eps || dist > ra + rb + Eps || dist < MathF.Abs( ra - rb ) - Eps )
return result;
var x = (dist * dist + ra * ra - rb * rb) / (2f * dist);
var hSq = ra * ra - x * x;
var h = hSq > 0f ? MathF.Sqrt( hSq ) : 0f;
var along = delta / dist;
var across = new Vec2( -along.y, along.x );
var mid = ca + along * x;
foreach ( var point in h < Eps
? new[] { mid }
: new[] { mid + across * h, mid - across * h } )
{
if ( !OnCurve( sketch, a, point, out var ta ) )
continue;
if ( !OnCurve( sketch, b, point, out var tb ) )
continue;
result.Add( new CurveHit( point, ta, tb ) );
}
return result;
}
/// <summary>
/// Crossings found by walking two tessellations against each other. The fallback for curves
/// with no closed form, and only as accurate as the tolerance they were sampled at.
/// </summary>
static List<CurveHit> Sampled( Sketch sketch, SketchCurve a, SketchCurve b )
{
var result = new List<CurveHit>();
var pa = a.Tessellate( sketch, sketch.Tolerance );
var pb = b.Tessellate( sketch, sketch.Tolerance );
for ( var i = 0; i + 1 < pa.Count; i++ )
{
for ( var j = 0; j + 1 < pb.Count; j++ )
{
var p = pa[i];
var d = pa[i + 1] - p;
var q = pb[j];
var e = pb[j + 1] - q;
var denom = Vec2.Cross( d, e );
if ( MathF.Abs( denom ) < Eps )
continue;
var t = Vec2.Cross( q - p, e ) / denom;
var u = Vec2.Cross( q - p, d ) / denom;
if ( t is < 0f or > 1f || u is < 0f or > 1f )
continue;
result.Add( new CurveHit( p + d * t,
(i + t) / (pa.Count - 1),
(j + u) / (pb.Count - 1) ) );
}
}
return result;
}
/// <summary>
/// Whether a point that is already known to be on a curve's underlying circle is on the curve
/// ITSELF — inside an arc's sweep rather than on the part of the circle it does not cover — and
/// if so, how far along it is.
/// </summary>
public static bool OnCurve( Sketch sketch, SketchCurve curve, Vec2 point, out float t )
{
t = 0f;
switch ( curve )
{
case SketchLine line:
{
var p = sketch.Points[line.Start];
var d = sketch.Points[line.End] - p;
if ( d.LengthSquared < Eps * Eps )
return false;
t = Vec2.Dot( point - p, d ) / d.LengthSquared;
return t is >= -Eps and <= 1f + Eps;
}
case SketchCircle circle:
{
var c = sketch.Points[circle.Center];
var angle = MathF.Atan2( point.y - c.y, point.x - c.x );
if ( angle < 0f )
angle += MathF.Tau;
t = angle / MathF.Tau;
return true;
}
case SketchArc arc:
{
var c = sketch.Points[arc.Center];
var start = MathF.Atan2( sketch.Points[arc.Start].y - c.y, sketch.Points[arc.Start].x - c.x );
var here = MathF.Atan2( point.y - c.y, point.x - c.x );
var sweep = ArcSweep( sketch, arc );
if ( MathF.Abs( sweep ) < Eps )
return false;
var offset = here - start;
// Bring the offset into the same turn direction as the sweep before comparing, or a
// hit just past the start reads as a hit just short of a full turn.
if ( sweep > 0f )
{
while ( offset < 0f ) offset += MathF.Tau;
while ( offset > MathF.Tau ) offset -= MathF.Tau;
}
else
{
while ( offset > 0f ) offset -= MathF.Tau;
while ( offset < -MathF.Tau ) offset += MathF.Tau;
}
t = offset / sweep;
return t is >= -Eps and <= 1f + Eps;
}
default:
{
// No closed form: find the nearest tessellated segment and report where on it the
// point landed.
var pts = curve.Tessellate( sketch, sketch.Tolerance );
var best = float.MaxValue;
for ( var i = 0; i + 1 < pts.Count; i++ )
{
var p = pts[i];
var d = pts[i + 1] - p;
if ( d.LengthSquared < Eps * Eps )
continue;
var u = Math.Clamp( Vec2.Dot( point - p, d ) / d.LengthSquared, 0f, 1f );
var distance = (p + d * u - point).Length;
if ( distance >= best )
continue;
best = distance;
t = (i + u) / (pts.Count - 1);
}
return best < MathF.Max( sketch.Tolerance * 4f, 1e-3f );
}
}
}
/// <summary>An arc's signed sweep in radians, positive counter-clockwise.</summary>
public static float ArcSweep( Sketch sketch, SketchArc arc )
{
var c = sketch.Points[arc.Center];
var a0 = MathF.Atan2( sketch.Points[arc.Start].y - c.y, sketch.Points[arc.Start].x - c.x );
var a1 = MathF.Atan2( sketch.Points[arc.End].y - c.y, sketch.Points[arc.End].x - c.x );
var sweep = a1 - a0;
if ( arc.Clockwise )
{
while ( sweep > 0f ) sweep -= MathF.Tau;
while ( sweep <= -MathF.Tau ) sweep += MathF.Tau;
if ( MathF.Abs( sweep ) < 1e-6f ) sweep = -MathF.Tau;
}
else
{
while ( sweep < 0f ) sweep += MathF.Tau;
while ( sweep >= MathF.Tau ) sweep -= MathF.Tau;
if ( MathF.Abs( sweep ) < 1e-6f ) sweep = MathF.Tau;
}
return sweep;
}
}
/// <summary>
/// The edits a sketcher needs that are not "draw another curve": trim, extend, fillet and offset.
///
/// WHY THESE ARE EDITS AND NOT FEATURES. Everything in Features/ is parametric — it re-runs on
/// rebuild and its inputs stay editable. These are not: they change the curve list in place, the
/// way dragging a point does, and the undo stack is what takes them back. Onshape draws the same
/// line, and the reason is that a parametric trim has to name the thing it trimmed against, which
/// means every one of these would need a persistent reference to a curve that a later edit can
/// delete. That is a large amount of machinery to make "cut this bit off" survive a rebuild, and
/// it buys very little, because the sketch itself is the thing being edited.
///
/// EVERY OPERATION RETURNS FALSE AND A REASON RATHER THAN THROWING. A trim that hits nothing and a
/// fillet too big for its corner are things a user does constantly by accident, not exceptional
/// states, and the editor turns the reason into a status line.
/// </summary>
public static class SketchEdit
{
const float Eps = 1e-5f;
/// <summary>
/// Round a corner where two lines meet, replacing the sharp join with a tangent arc.
///
/// The corner is named by the POINT the two lines share, which is the only unambiguous way to
/// say which corner — two lines can meet at either end, and shared points are how this sketch
/// stores a join in the first place.
///
/// Both lines keep their far ends and are shortened to the tangent points; the arc is a new
/// curve between them and the corner point itself is left in the sketch, orphaned. Leaving it
/// is deliberate: removing a point renumbers every index above it, and every curve and every
/// constraint in the sketch is stored as an index. An orphan point costs two floats and is
/// invisible; a renumber is a silent corruption of every rule in the sketch.
/// </summary>
public static bool Fillet( Sketch sketch, int corner, float radius, out string error )
{
error = null;
if ( radius <= 0f )
{
error = "A fillet needs a radius greater than zero.";
return false;
}
if ( corner < 0 || corner >= sketch.Points.Count )
{
error = "That corner is not a point in this sketch.";
return false;
}
var lines = sketch.Curves
.OfType<SketchLine>()
.Where( l => !l.Construction && (l.Start == corner || l.End == corner) )
.ToList();
if ( lines.Count != 2 )
{
error = lines.Count < 2
? "A fillet needs two lines meeting at the corner."
: $"{lines.Count} lines meet at that corner, so which two to round is ambiguous.";
return false;
}
var c = sketch.Points[corner];
// Direction AWAY from the corner along each line, and the far end each keeps.
var farA = lines[0].Start == corner ? lines[0].End : lines[0].Start;
var farB = lines[1].Start == corner ? lines[1].End : lines[1].Start;
var dirA = (sketch.Points[farA] - c);
var dirB = (sketch.Points[farB] - c);
var lenA = dirA.Length;
var lenB = dirB.Length;
if ( lenA < Eps || lenB < Eps )
{
error = "One of the lines at that corner has no length.";
return false;
}
var ua = dirA / lenA;
var ub = dirB / lenB;
var cos = Math.Clamp( Vec2.Dot( ua, ub ), -1f, 1f );
var angle = MathF.Acos( cos );
if ( angle < 1e-3f || MathF.Abs( angle - MathF.PI ) < 1e-3f )
{
error = angle < 1e-3f
? "Those two lines fold back on each other, so there is no corner to round."
: "Those two lines are straight through the corner, so there is nothing to round.";
return false;
}
// Distance from the corner to each tangent point, and from the corner to the arc centre.
// Standard corner-rounding: the tangent length is r/tan(half), the centre sits r/sin(half)
// along the bisector.
var half = angle * 0.5f;
var tangent = radius / MathF.Tan( half );
if ( tangent > lenA - Eps || tangent > lenB - Eps )
{
error = $"A radius of {radius} needs {tangent:0.###} of line on each side, and only " +
$"{MathF.Min( lenA, lenB ):0.###} is available.";
return false;
}
var pointA = c + ua * tangent;
var pointB = c + ub * tangent;
var bisector = (ua + ub);
if ( bisector.Length < Eps )
{
error = "Those two lines are straight through the corner, so there is nothing to round.";
return false;
}
var centre = c + bisector.Normal * (radius / MathF.Sin( half ));
var indexA = sketch.AddPoint( pointA );
var indexB = sketch.AddPoint( pointB );
var indexC = sketch.AddPoint( centre );
// Pull each line off the corner and onto its tangent point.
if ( lines[0].Start == corner ) lines[0].Start = indexA; else lines[0].End = indexA;
if ( lines[1].Start == corner ) lines[1].Start = indexB; else lines[1].End = indexB;
// The arc runs from A to B the short way. Which way that is depends on which side of the
// bisector the corner sits, and the cross product is what says so.
var clockwise = Vec2.Cross( pointA - centre, pointB - centre ) < 0f;
sketch.Add( new SketchArc( indexC, indexA, indexB, clockwise ) );
return true;
}
/// <summary>
/// Cut the piece of a curve that the pick point sits on, back to wherever it crosses something
/// else. A curve crossing nothing is removed outright, which is what a trim of an untouched
/// line means.
///
/// Lines, arcs and circles only. A trimmed circle becomes an arc, which is the one case here
/// that changes a curve's type rather than its extent.
/// </summary>
public static bool Trim( Sketch sketch, SketchCurve curve, Vec2 pick, out string error )
{
error = null;
if ( curve is not (SketchLine or SketchArc or SketchCircle) )
{
error = $"Trimming a {curve.GetType().Name} is not supported.";
return false;
}
if ( !SketchIntersect.OnCurve( sketch, curve, pick, out var pickT ) && curve is not SketchCircle )
{
error = "That point is not on the curve.";
return false;
}
var cuts = new List<float>();
foreach ( var other in sketch.Curves )
{
if ( ReferenceEquals( other, curve ) )
continue;
foreach ( var hit in SketchIntersect.Between( sketch, curve, other ) )
{
if ( hit.TA is > Eps and < 1f - Eps || curve is SketchCircle )
cuts.Add( hit.TA );
}
}
cuts.Sort();
if ( cuts.Count == 0 )
{
sketch.Curves.Remove( curve );
return true;
}
if ( curve is SketchCircle circle )
return TrimCircle( sketch, circle, cuts, pickT, out error );
// The piece under the pick runs between the two cuts either side of it, with the curve's
// own ends standing in where there is no cut on that side.
var lower = 0f;
var upper = 1f;
foreach ( var cut in cuts )
{
if ( cut <= pickT && cut > lower ) lower = cut;
if ( cut >= pickT && cut < upper ) upper = cut;
}
var removesStart = lower <= Eps;
var removesEnd = upper >= 1f - Eps;
if ( removesStart && removesEnd )
{
sketch.Curves.Remove( curve );
return true;
}
if ( removesStart )
return MoveEnd( sketch, curve, atStart: true, PointAt( sketch, curve, upper ), out error );
if ( removesEnd )
return MoveEnd( sketch, curve, atStart: false, PointAt( sketch, curve, lower ), out error );
// A cut out of the middle leaves two pieces, so the curve is shortened to the first and a
// copy carries the second.
var tailStart = PointAt( sketch, curve, upper );
var tailEnd = EndPoint( sketch, curve, atStart: false );
if ( !MoveEnd( sketch, curve, atStart: false, PointAt( sketch, curve, lower ), out error ) )
return false;
switch ( curve )
{
case SketchLine:
sketch.Add( new SketchLine( sketch.AddPoint( tailStart ), sketch.AddPoint( tailEnd ) ) );
break;
case SketchArc arc:
sketch.Add( new SketchArc( arc.Center,
sketch.AddPoint( tailStart ), sketch.AddPoint( tailEnd ), arc.Clockwise ) );
break;
}
return true;
}
/// <summary>
/// A circle has no ends, so trimming it is different in kind: the piece under the pick runs
/// between the cut before it and the cut after it, WRAPPING past 1 back to 0, and what is left
/// is a single arc going the other way round.
/// </summary>
static bool TrimCircle( Sketch sketch, SketchCircle circle, List<float> cuts, float pickT, out string error )
{
error = null;
if ( cuts.Count < 2 )
{
error = "A circle needs to be crossed in two places before a piece of it can be trimmed.";
return false;
}
// The cut at or before the pick, and the one after it, both wrapping.
var before = cuts.Where( c => c <= pickT ).DefaultIfEmpty( cuts[^1] ).Max();
var after = cuts.Where( c => c >= pickT ).DefaultIfEmpty( cuts[0] ).Min();
if ( MathF.Abs( before - after ) < Eps )
{
error = "That piece of the circle is too small to trim.";
return false;
}
var centre = sketch.Points[circle.Center];
Vec2 On( float t )
{
var angle = t * MathF.Tau;
return new Vec2( centre.x + MathF.Cos( angle ) * circle.Radius,
centre.y + MathF.Sin( angle ) * circle.Radius );
}
// What survives runs from the cut AFTER the pick round to the cut BEFORE it.
var keepStart = sketch.AddPoint( On( after ) );
var keepEnd = sketch.AddPoint( On( before ) );
sketch.Curves.Remove( circle );
sketch.Add( new SketchArc( circle.Center, keepStart, keepEnd ) );
return true;
}
/// <summary>
/// Stretch a line or an arc past one of its ends until it runs into something. Extends to the
/// NEAREST crossing, which is what makes repeated extends walk outward one curve at a time
/// rather than leaping to the far side of the sketch.
/// </summary>
public static bool Extend( Sketch sketch, SketchCurve curve, bool atStart, out string error )
{
error = null;
switch ( curve )
{
case SketchLine line:
{
var from = sketch.Points[atStart ? line.Start : line.End];
var toward = sketch.Points[atStart ? line.End : line.Start];
var direction = from - toward;
if ( direction.Length < Eps )
{
error = "That line has no length, so there is no direction to extend it in.";
return false;
}
var unit = direction.Normal;
var best = float.MaxValue;
var found = Vec2.Zero;
// A long probe standing in for the infinite ray. Bounded rather than infinite
// because every intersection routine here works on bounded curves, and the bound
// only has to beat the size of the sketch. Built once, outside the loop — its two
// points then stay in the list for the same reason a fillet's corner does, since
// removing them would renumber every index above them.
var probe = new SketchLine(
sketch.AddPoint( from ),
sketch.AddPoint( from + unit * ProbeLength( sketch ) ) );
foreach ( var other in sketch.Curves )
{
if ( ReferenceEquals( other, curve ) )
continue;
foreach ( var hit in SketchIntersect.Between( sketch, probe, other ) )
{
var distance = (hit.Point - from).Length;
if ( distance <= Eps || distance >= best )
continue;
best = distance;
found = hit.Point;
}
}
if ( best == float.MaxValue )
{
error = "Nothing lies beyond that end to extend to.";
return false;
}
return MoveEnd( sketch, curve, atStart, found, out error );
}
case SketchArc arc:
{
// An arc extends along its own circle, so what it can reach is wherever that circle
// crosses something — and the nearest such crossing in the sweep direction.
var (centre, radius) = SketchIntersect.CircleOf( sketch, arc );
var probe = new SketchCircle( arc.Center, radius );
var sweep = SketchIntersect.ArcSweep( sketch, arc );
var fromAngle = Angle( sketch.Points[atStart ? arc.Start : arc.End] - centre );
var outward = atStart ? -MathF.Sign( sweep ) : MathF.Sign( sweep );
var best = float.MaxValue;
var found = Vec2.Zero;
foreach ( var other in sketch.Curves )
{
if ( ReferenceEquals( other, curve ) )
continue;
foreach ( var hit in SketchIntersect.Between( sketch, probe, other ) )
{
var step = (Angle( hit.Point - centre ) - fromAngle) * outward;
while ( step < 0f ) step += MathF.Tau;
while ( step > MathF.Tau ) step -= MathF.Tau;
if ( step <= Eps || step >= best )
continue;
best = step;
found = hit.Point;
}
}
if ( best == float.MaxValue )
{
error = "Nothing lies beyond that end to extend to.";
return false;
}
return MoveEnd( sketch, curve, atStart, found, out error );
}
default:
error = $"Extending a {curve.GetType().Name} is not supported.";
return false;
}
}
/// <summary>
/// Copy a chain of lines and arcs a fixed distance to one side.
///
/// Positive is to the LEFT of the direction each curve is travelling, which makes the sign mean
/// something consistent along a chain rather than depending on each curve's own winding.
///
/// CORNERS ARE CLOSED BY EXTENDING THE NEIGHBOURS TO THEIR CROSSING, not by inserting an arc.
/// On an outside corner the two offset curves fall short of each other and on an inside corner
/// they overshoot; moving the shared end onto the crossing fixes both, and it is what a CAD
/// offset does by default. Where the two do not cross at all — which happens when the offset is
/// larger than the feature it is going round — the joint is left open and reported.
/// </summary>
public static bool Offset( Sketch sketch, IReadOnlyList<SketchCurve> chain, float distance,
out List<SketchCurve> created, out string error )
{
created = new List<SketchCurve>();
error = null;
if ( chain.Count == 0 )
{
error = "Nothing was selected to offset.";
return false;
}
if ( MathF.Abs( distance ) < Eps )
{
error = "An offset of zero would just copy the curves on top of themselves.";
return false;
}
foreach ( var curve in chain )
{
if ( curve is not (SketchLine or SketchArc) )
{
error = $"Offsetting a {curve.GetType().Name} is not supported.";
return false;
}
}
// Every offset curve gets its own fresh points; the joints are stitched afterwards by
// moving those points, which is why they cannot be shared with the originals.
var ends = new List<(int Start, int End)>();
foreach ( var curve in chain )
{
switch ( curve )
{
case SketchLine line:
{
var a = sketch.Points[line.Start];
var b = sketch.Points[line.End];
var direction = b - a;
if ( direction.Length < Eps )
{
error = "One of the lines has no length.";
return false;
}
var left = new Vec2( -direction.Normal.y, direction.Normal.x ) * distance;
var start = sketch.AddPoint( a + left );
var end = sketch.AddPoint( b + left );
created.Add( sketch.Add( new SketchLine( start, end ) ) );
ends.Add( (start, end) );
break;
}
case SketchArc arc:
{
var (centre, radius) = SketchIntersect.CircleOf( sketch, arc );
// Travelling counter-clockwise puts the centre on the left, so a positive
// offset moves toward it and the radius shrinks. Clockwise is the mirror.
var sweep = SketchIntersect.ArcSweep( sketch, arc );
var moved = sweep > 0f ? radius - distance : radius + distance;
if ( moved < Eps )
{
error = $"An offset of {distance} collapses an arc of radius {radius:0.###}.";
return false;
}
var start = sketch.AddPoint( centre + (sketch.Points[arc.Start] - centre).Normal * moved );
var end = sketch.AddPoint( centre + (sketch.Points[arc.End] - centre).Normal * moved );
created.Add( sketch.Add( new SketchArc( arc.Center, start, end, arc.Clockwise ) ) );
ends.Add( (start, end) );
break;
}
}
}
var openJoints = 0;
for ( var i = 0; i + 1 < created.Count; i++ )
{
// Only stitch a joint the originals actually shared. Two curves selected together but
// not touching are two separate offsets, and dragging their ends together would be an
// invention rather than an offset.
if ( !Touching( chain[i], chain[i + 1] ) )
continue;
if ( !Meet( sketch, created[i], created[i + 1], out var join ) )
{
openJoints++;
continue;
}
sketch.Points[ends[i].End] = join;
sketch.Points[ends[i + 1].Start] = join;
}
if ( openJoints > 0 )
error = $"{openJoints} corner(s) left open: the offset is wider than the feature it turns.";
return true;
}
static bool Touching( SketchCurve a, SketchCurve b )
{
var (a0, a1) = a.Endpoints;
var (b0, b1) = b.Endpoints;
return a1 == b0 || a1 == b1 || a0 == b0 || a0 == b1;
}
/// <summary>Where two offset curves cross, taking the crossing nearest their shared corner when
/// there is more than one — a circle and a line cross twice and only one of them is the joint
/// being repaired.</summary>
static bool Meet( Sketch sketch, SketchCurve a, SketchCurve b, out Vec2 point )
{
point = Vec2.Zero;
// Bounded intersection first, which is the inside-corner case where the two overshoot and
// genuinely cross.
var hits = SketchIntersect.Between( sketch, a, b );
if ( hits.Count > 0 )
{
point = hits[0].Point;
return true;
}
// Outside corner: the two fall short, so they only meet once extended. Only lines can be
// extended without ambiguity here, so an arc joint that falls short is reported open.
if ( a is not SketchLine la || b is not SketchLine lb )
return false;
var p = sketch.Points[la.Start];
var d = sketch.Points[la.End] - p;
var q = sketch.Points[lb.Start];
var e = sketch.Points[lb.End] - q;
var denom = Vec2.Cross( d, e );
if ( MathF.Abs( denom ) < Eps )
return false;
point = p + d * (Vec2.Cross( q - p, e ) / denom);
return true;
}
static float ProbeLength( Sketch sketch )
{
var extent = 1f;
foreach ( var p in sketch.Points )
extent = MathF.Max( extent, MathF.Max( MathF.Abs( p.x ), MathF.Abs( p.y ) ) );
return extent * 4f + 10f;
}
static float Angle( Vec2 v )
{
var a = MathF.Atan2( v.y, v.x );
return a < 0f ? a + MathF.Tau : a;
}
static Vec2 PointAt( Sketch sketch, SketchCurve curve, float t ) => curve switch
{
SketchLine line => sketch.Points[line.Start] + (sketch.Points[line.End] - sketch.Points[line.Start]) * t,
SketchArc arc => ArcPoint( sketch, arc, t ),
_ => throw new InvalidOperationException( $"{curve.GetType().Name} has no point-at" )
};
static Vec2 ArcPoint( Sketch sketch, SketchArc arc, float t )
{
var centre = sketch.Points[arc.Center];
var radius = arc.Radius( sketch );
var start = MathF.Atan2( sketch.Points[arc.Start].y - centre.y, sketch.Points[arc.Start].x - centre.x );
var angle = start + SketchIntersect.ArcSweep( sketch, arc ) * t;
return new Vec2( centre.x + MathF.Cos( angle ) * radius, centre.y + MathF.Sin( angle ) * radius );
}
static Vec2 EndPoint( Sketch sketch, SketchCurve curve, bool atStart )
{
var (a, b) = curve.Endpoints;
return sketch.Points[atStart ? a : b];
}
/// <summary>
/// Move one end of a curve to a new position, by moving the POINT it references rather than by
/// repointing the curve at a new index. That keeps every constraint attached to that end
/// attached, which is the difference between trimming a line and quietly deleting the rules
/// that were holding it.
/// </summary>
static bool MoveEnd( Sketch sketch, SketchCurve curve, bool atStart, Vec2 to, out string error )
{
error = null;
var (a, b) = curve.Endpoints;
var index = atStart ? a : b;
if ( index < 0 || index >= sketch.Points.Count )
{
error = "That curve has no end to move.";
return false;
}
sketch.Points[index] = to;
return true;
}
}