Editor/Effigy/Sketch/SketchArrangement.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy;
/// <summary>
/// Geometric crossings turned into shared vertices, on a COPY of the sketch.
///
/// Profile finding walks an integer graph: coincidence is identity, and two loops that overlap
/// in the plane without sharing a point index are two separate regions even when they plainly
/// share an area. That is the right editing model — dragging one rectangle must not invent
/// vertices on the other — but it is the wrong region model. The lens between two overlapping
/// circles is a face someone can point a tool at, and it is not a cycle of the unsplit graph.
///
/// This builds the overlay the walk needs without touching the sketch the user is editing. Line,
/// arc and circle crossings use <see cref="SketchIntersect"/> so the split points are the same
/// ones trim already believes in. Endpoint-on-endpoint touches are left alone: two rectangles
/// that merely kiss at a corner must not become a bowtie.
/// </summary>
public static class SketchArrangement
{
const float Eps = 1e-5f;
/// <summary>
/// A sketch whose crossing curves share a vertex at each crossing, or <paramref name="sketch"/>
/// itself when nothing crosses. Never mutates the input.
/// </summary>
public static Sketch ImprintCrossings( Sketch sketch )
{
if ( sketch is null || !HasProperCrossing( sketch ) )
return sketch;
var working = sketch.Clone();
SplitAtCrossings( working );
return working;
}
/// <summary>
/// Split crossing curves on the sketch itself. Profile finding does this on a copy so dragging
/// one rectangle cannot invent vertices on another. The line tool does not call this: splitting
/// replaces the curve, and Horizontal/Vertical constraints stored against its id would then
/// point at nothing. Returns whether anything was split.
/// </summary>
public static bool SplitLive( Sketch sketch )
{
if ( sketch is null || !HasProperCrossing( sketch ) )
return false;
SplitAtCrossings( sketch );
return true;
}
/// <summary>
/// Whether two sketch planes are the same plane in space — parallel, and the same offset along
/// the normal. In-plane origin and axes may differ; profile finding projects through world.
/// Opposite normals still count: the underside of a plate is the same plane as the top, flipped.
/// </summary>
public static bool Coplanar( SketchPlane a, SketchPlane b )
{
if ( a is null || b is null )
return false;
if ( MathF.Abs( MathF.Abs( Vec3.Dot( a.Normal, b.Normal ) ) - 1f ) > 1e-4f )
return false;
return MathF.Abs( Vec3.Dot( b.Origin - a.Origin, a.Normal ) ) < 1e-4f;
}
/// <summary>
/// Guest sketches laid into the host's plane coordinates, construction dropped. The host is
/// not mutated: a clone carries its curves, then each coplanar guest is appended with points
/// projected through world. Returns the host itself when nothing was added, so the caller can
/// skip work that is just the host again.
/// </summary>
public static Sketch Overlay( Sketch host, IEnumerable<Sketch> guests )
{
if ( host is null || guests is null )
return host;
Sketch combined = null;
foreach ( var guest in guests )
{
if ( guest is null || ReferenceEquals( guest, host ) || !Coplanar( host.Plane, guest.Plane ) )
continue;
combined ??= host.Clone();
Append( combined, guest );
}
return combined ?? host;
}
static void Append( Sketch host, Sketch guest )
{
var map = new int[guest.Points.Count];
for ( var i = 0; i < guest.Points.Count; i++ )
map[i] = host.AddPoint( host.Plane.ToPlane( guest.Plane.ToWorld( guest.Points[i] ) ) );
foreach ( var curve in guest.Curves )
{
if ( curve.Construction )
continue;
var copy = Remap( curve, map );
if ( copy is not null )
host.Add( copy );
}
}
static SketchCurve Remap( SketchCurve curve, int[] map ) => curve switch
{
SketchLine line => new SketchLine( map[line.Start], map[line.End] ),
SketchArc arc => new SketchArc( map[arc.Center], map[arc.Start], map[arc.End], arc.Clockwise ),
SketchCircle circle => new SketchCircle( map[circle.Center], circle.Radius ),
SketchEllipse ellipse => new SketchEllipse( map[ellipse.Center], map[ellipse.MajorPoint], ellipse.MinorRadius ),
SketchSpline spline => new SketchSpline( spline.Points.Select( i => map[i] ).ToList(), spline.Closed ),
_ => null
};
static bool HasProperCrossing( Sketch sketch )
{
var live = Splittable( sketch );
for ( var i = 0; i < live.Count; i++ )
{
for ( var j = i + 1; j < live.Count; j++ )
{
foreach ( var hit in SketchIntersect.Between( sketch, live[i], live[j] ) )
{
if ( IsProperCrossing( live[i], live[j], hit ) )
return true;
}
}
}
return false;
}
static void SplitAtCrossings( Sketch sketch )
{
var live = Splittable( sketch );
var cuts = new Dictionary<SketchCurve, List<Cut>>();
void Add( SketchCurve curve, float t, Vec2 point )
{
if ( !cuts.TryGetValue( curve, out var list ) )
cuts[curve] = list = new List<Cut>();
list.Add( new Cut( t, point ) );
}
for ( var i = 0; i < live.Count; i++ )
{
for ( var j = i + 1; j < live.Count; j++ )
{
foreach ( var hit in SketchIntersect.Between( sketch, live[i], live[j] ) )
{
if ( !IsProperCrossing( live[i], live[j], hit ) )
continue;
Add( live[i], hit.TA, hit.Point );
Add( live[j], hit.TB, hit.Point );
}
}
}
foreach ( var (curve, list) in cuts.ToList() )
SplitCurve( sketch, curve, list );
}
static List<SketchCurve> Splittable( Sketch sketch ) =>
sketch.Curves.Where( c => !c.Construction && CanSplit( c ) ).ToList();
static bool CanSplit( SketchCurve curve ) => curve is SketchLine or SketchArc or SketchCircle;
/// <summary>
/// A real crossing rather than two corners sitting on the same point. Circles have no
/// endpoints — t = 0 is just the +X of the parameterisation — so a hit on a circle is always
/// a split. Two open curves that only meet at their ends already share a vertex if they were
/// drawn as a join, and must not gain one if they were not: that is how two rectangles
/// touching at a corner stay two rectangles.
/// </summary>
static bool IsProperCrossing( SketchCurve a, SketchCurve b, CurveHit hit ) =>
!(IsOpenEnd( a, hit.TA ) && IsOpenEnd( b, hit.TB ));
static bool IsOpenEnd( SketchCurve curve, float t ) =>
!curve.IsClosed && (t < Eps || t > 1f - Eps);
static void SplitCurve( Sketch sketch, SketchCurve curve, List<Cut> cuts )
{
switch ( curve )
{
case SketchLine line:
SplitLine( sketch, line, InteriorCuts( cuts, closed: false ) );
break;
case SketchArc arc:
SplitArc( sketch, arc, InteriorCuts( cuts, closed: false ) );
break;
case SketchCircle circle:
SplitCircle( sketch, circle, InteriorCuts( cuts, closed: true ) );
break;
}
}
static List<Cut> InteriorCuts( List<Cut> cuts, bool closed )
{
var unique = new List<Cut>();
foreach ( var cut in cuts.OrderBy( c => c.T ) )
{
var t = cut.T;
var point = cut.Point;
if ( closed )
{
t -= MathF.Floor( t );
if ( t > 1f - Eps )
t = 0f;
}
else if ( t <= Eps || t >= 1f - Eps )
{
continue;
}
if ( unique.Count > 0 && SameCut( unique[^1], t, point ) )
continue;
unique.Add( new Cut( t, point ) );
}
if ( closed && unique.Count > 1 && SameCut( unique[0], unique[^1].T, unique[^1].Point ) )
unique.RemoveAt( unique.Count - 1 );
return unique;
}
static bool SameCut( Cut existing, float t, Vec2 point ) =>
MathF.Abs( existing.T - t ) < Eps
|| MathF.Abs( existing.T - t + 1f ) < Eps
|| MathF.Abs( existing.T - t - 1f ) < Eps
|| (existing.Point - point).LengthSquared < Eps * Eps;
static void SplitLine( Sketch sketch, SketchLine line, List<Cut> cuts )
{
if ( cuts.Count == 0 )
return;
var start = line.Start;
var end = line.End;
var construction = line.Construction;
sketch.Curves.Remove( line );
var prev = start;
foreach ( var cut in cuts )
{
var vertex = FindOrAdd( sketch, cut.Point );
sketch.Add( new SketchLine( prev, vertex ) { Construction = construction } );
prev = vertex;
}
sketch.Add( new SketchLine( prev, end ) { Construction = construction } );
}
static void SplitArc( Sketch sketch, SketchArc arc, List<Cut> cuts )
{
if ( cuts.Count == 0 )
return;
var start = arc.Start;
var end = arc.End;
var construction = arc.Construction;
sketch.Curves.Remove( arc );
var prev = start;
foreach ( var cut in cuts )
{
var vertex = FindOrAdd( sketch, cut.Point );
sketch.Add( new SketchArc( arc.Center, prev, vertex, arc.Clockwise )
{
Construction = construction
} );
prev = vertex;
}
sketch.Add( new SketchArc( arc.Center, prev, end, arc.Clockwise )
{
Construction = construction
} );
}
static void SplitCircle( Sketch sketch, SketchCircle circle, List<Cut> cuts )
{
// A single tangent point does not split a circle into faces. Leave it as a closed curve
// so the ordinary loop path still sees it.
if ( cuts.Count < 2 )
return;
var construction = circle.Construction;
sketch.Curves.Remove( circle );
for ( var i = 0; i < cuts.Count; i++ )
{
var a = FindOrAdd( sketch, cuts[i].Point );
var b = FindOrAdd( sketch, cuts[(i + 1) % cuts.Count].Point );
sketch.Add( new SketchArc( circle.Center, a, b )
{
Construction = construction
} );
}
}
static int FindOrAdd( Sketch sketch, Vec2 point )
{
var merge = Eps * Eps;
for ( var i = 0; i < sketch.Points.Count; i++ )
{
if ( (sketch.Points[i] - point).LengthSquared < merge )
return i;
}
return sketch.AddPoint( point );
}
readonly struct Cut
{
public readonly float T;
public readonly Vec2 Point;
public Cut( float t, Vec2 point )
{
T = t;
Point = point;
}
}
}