Editor/Effigy/Sketch/Profile.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy;

/// <summary>
/// A closed region found in a sketch: an outer boundary, counter-clockwise, plus any loops nested
/// inside it.
///
/// Points are in plane coordinates and the outer loop does NOT repeat its first point at the end.
/// </summary>
public sealed class Profile
{
	public List<Vec2> Outer = new();
	public List<List<Vec2>> Holes = new();

	public bool HasHoles => Holes.Count > 0;

	/// <summary>
	/// True when this region is the intersection of two or more other profiles, not a loop of the
	/// curve graph. Two overlapping rectangles are three pickable faces: each whole, and the lens
	/// in the middle. The lens is this, and it is how a click in the overlap names the overlap
	/// rather than whichever whole happened to be smaller.
	///
	/// Features that build every region (no <c>RegionSeeds</c>) skip these, or the lens would be
	/// extruded on top of the two wholes that already contain it.
	/// </summary>
	public bool FromOverlap;

	/// <summary>
	/// Whether a point in plane coordinates falls inside this region — within the outer loop and
	/// not down any of its holes.
	///
	/// This is what turns a click in the viewport into a face selection, and what lets a feature
	/// re-find the face it was pointed at after the sketch was edited.
	/// </summary>
	public bool Contains( Vec2 p ) =>
		ProfileFinder.Contains( Outer, p ) && !Holes.Any( h => ProfileFinder.Contains( h, p ) );

	/// <summary>Shoelace area of the outer loop, minus the holes.</summary>
	public float Area => MathF.Abs( ProfileFinder.SignedArea( Outer ) )
		- Holes.Sum( h => MathF.Abs( ProfileFinder.SignedArea( h ) ) );
}

public sealed class ProfileResult
{
	public List<Profile> Profiles = new();

	/// <summary>Chains that never closed. Usually a sketch mid-draw rather than a mistake, so this
	/// is reported rather than thrown.</summary>
	public int OpenChains;

	public List<string> Warnings = new();
}

/// <summary>
/// Turns a sketch's curves into closed regions that Extrude and Revolve can consume.
///
/// HOW IT WORKS: curves reference shared point indices, so the sketch is already a graph — points
/// are nodes, curves are edges. A closed region is a cycle. Because coincident corners share an
/// index rather than merely sitting at the same coordinates, the walk is exact integer bookkeeping
/// with no floating-point matching anywhere in it.
///
/// BRANCHING IS HANDLED BY PLANAR FACE TRAVERSAL. Only points where exactly two curves met used to
/// be followed, so a line drawn across a rectangle — which is how anyone divides a shape — was
/// reported as "not supported yet" rather than split into the two regions it plainly is.
///
/// The upgrade is the one this comment used to describe as the upgrade path, and it works out
/// exactly as advertised. Every curve becomes two directed HALF-EDGES. At each point the outgoing
/// half-edges are sorted by the direction they actually leave in, and the rule for walking a face
/// is: arrive along h, take h's reverse, and leave along whichever half-edge sits immediately
/// CLOCKWISE of it. Follow that and you trace one face and come back where you started, every time,
/// because each half-edge belongs to exactly one face.
///
/// The faces that come out counter-clockwise are regions. Each connected piece of the sketch also
/// produces one clockwise face — the infinite one outside it — and those are dropped, which is the
/// whole of the special-case handling.
///
/// THE ANGLE HAS TO BE THE TANGENT, not the direction of the straight line to the far end. Where an
/// arc and a line leave the same point, the straight-line direction can order them the wrong way
/// round, and the wrong order picks the wrong face. Tessellating and taking the first segment gets
/// the tangent for free and reuses the sampling everything else already agrees on.
///
/// OVERLAPPING LOOPS ARE THREE FACES, NOT TWO WHOLES. Two rectangles that cross without sharing a
/// vertex are two cycles of the integer graph, and the lens between them is not a cycle of either.
/// Nesting used to read that as a hole whenever one loop's first vertex sat inside the other, which
/// ate the overlap and the part that stuck out. Crossing is not nesting: a hole is strictly inside
/// and does not cross. The lens is recovered by imprinting those crossings on a copy
/// (<see cref="SketchArrangement"/>) and keeping any arrangement face that sits inside two or more
/// originals. The originals stay pickable as wholes; the lens is pickable as itself. The source
/// sketch is not mutated — coincidence-as-identity is still the editing model.
///
/// A LINE DRAWN ACROSS A RECTANGLE IS THE SAME PROBLEM FROM THE OTHER SIDE. The integer graph
/// does not see a T-junction: the divider's ends sit on the edges and share no point index with
/// them, so they are pruned as dangling and the rectangle stays one face. Two such lines that
/// cross in the middle are four faces anyone can see and one face the walk reports. The imprint
/// already splits those — T-junctions are proper crossings, not endpoint-on-endpoint kisses —
/// and <see cref="RecoverCutRegions"/> uses that copy when the unsplit walk threw away geometry
/// that the arrangement would have turned into faces. Overlapping closed loops are left alone:
/// those have no dangling curves, and the wholes-plus-lens rule above still applies.
///
/// TWO SKETCH FEATURES ON THE SAME PLANE ARE THE SAME PROBLEM. The lens between them is not a
/// cycle of either graph. Pass the other sketches in; they are overlaid in this plane's
/// coordinates, imprinted, and any arrangement face that sits in this sketch AND in at least one
/// other is kept as an overlap. Exclusive faces of the neighbours are not — this sketch does not
/// own them.
/// </summary>
public static class ProfileFinder
{
	public static ProfileResult Find( Sketch sketch ) => Find( sketch, null );

	public static ProfileResult Find( Sketch sketch, IEnumerable<Sketch> neighbors )
	{
		var result = new ProfileResult();
		var loops = CollectLoops( sketch, result );

		NestInto( result, loops );
		RecoverCutRegions( sketch, result );
		AddOverlapRegions( sketch, neighbors, result );

		return result;
	}

	/// <summary>
	/// Every closed loop the unsplit sketch already knows about: closed curves (circles, ellipses,
	/// closed splines) plus the faces of the integer graph. Overlap lenses are not in this list;
	/// those come from the arrangement pass afterwards.
	/// </summary>
	static List<List<Vec2>> CollectLoops( Sketch sketch, ProfileResult result )
	{
		var loops = new List<List<Vec2>>();

		// A closed curve — circle, ellipse, closed spline — is a region on its own and never
		// participates in the graph walk.
		foreach ( var closed in sketch.Curves.Where( c => c.IsClosed && !c.Construction ) )
		{
			var pts = closed.Tessellate( sketch, sketch.Tolerance );
			pts.RemoveAt( pts.Count - 1 ); // drop the repeated closing point

			// A circle at or below the sketch tolerance tessellates to fewer than three points and
			// is not a region at all. Walked loops are guarded by their own Count >= 3 check; this
			// path skipped it, and a two-point "loop" extruded into faces with two corners.
			if ( pts.Count < 3 )
			{
				result.Warnings.Add(
					$"A closed {closed.GetType().Name} is too small to form a region at the sketch tolerance of {sketch.Tolerance}" );
				continue;
			}

			loops.Add( pts );
		}

		var edges = sketch.Curves
			.Where( c => !c.Construction && !c.IsClosed )
			.ToList();

		loops.AddRange( FindFaces( sketch, edges, result ) );

		return loops;
	}

	/// <summary>
	/// Nesting: a loop inside an odd number of other loops is a hole. Crossing is not inside —
	/// two overlapping rectangles are two outers, not one with the other cut out of it.
	/// </summary>
	static void NestInto( ProfileResult result, List<List<Vec2>> loops )
	{
		var depths = new int[loops.Count];

		for ( var i = 0; i < loops.Count; i++ )
		{
			for ( var j = 0; j < loops.Count; j++ )
			{
				if ( i != j && StrictlyInside( loops[j], loops[i] ) )
					depths[i]++;
			}
		}

		for ( var i = 0; i < loops.Count; i++ )
		{
			if ( depths[i] % 2 != 0 )
				continue;

			var profile = new Profile { Outer = Orient( loops[i], counterClockwise: true ) };

			// Immediate children only: nested one level deeper AND strictly inside this one.
			for ( var j = 0; j < loops.Count; j++ )
			{
				if ( j != i && depths[j] == depths[i] + 1 && StrictlyInside( loops[i], loops[j] ) )
					profile.Holes.Add( Orient( loops[j], counterClockwise: false ) );
			}

			result.Profiles.Add( profile );
		}
	}

	/// <summary>
	/// The lens (and any n-way overlap) as its own profile, so a click in the middle names the
	/// middle. Originals are left in place: clicking the part that belongs to only one loop still
	/// picks that whole, which is the thing people already could pick.
	///
	/// Neighbours are other sketches on the same plane. Their outers count toward "sits in two
	/// or more", but a face that misses this sketch entirely is not added — exclusive faces of
	/// a neighbour belong to that neighbour.
	///
	/// Skipped when fewer than two outers exist across host and neighbours, or when no pair of
	/// them even overlap in bounds — a hole is not an overlap, and two disjoint squares must
	/// not pay for an imprint.
	/// </summary>
	static void AddOverlapRegions( Sketch sketch, IEnumerable<Sketch> neighbors, ProfileResult result )
	{
		var hostOuters = result.Profiles.Where( p => !p.FromOverlap ).ToList();
		var allOuters = new List<Profile>( hostOuters );

		if ( neighbors is not null )
		{
			foreach ( var guest in neighbors )
			{
				if ( guest is null || ReferenceEquals( guest, sketch )
					|| !SketchArrangement.Coplanar( sketch.Plane, guest.Plane ) )
					continue;

				foreach ( var profile in Originals( guest ) )
					allOuters.Add( ProjectProfile( profile, guest.Plane, sketch.Plane ) );
			}
		}

		if ( allOuters.Count < 2 || !AnyBoundsOverlap( allOuters ) )
			return;

		var working = SketchArrangement.ImprintCrossings( SketchArrangement.Overlay( sketch, neighbors ) );

		if ( ReferenceEquals( working, sketch ) )
			return;

		var discarded = new ProfileResult();
		var faces = CollectLoops( working, discarded );

		foreach ( var face in faces )
		{
			if ( face.Count < 3 )
				continue;

			var loop = Orient( face, counterClockwise: true );
			var seed = InteriorPoint( loop );
			var hostHits = 0;
			var allHits = 0;

			foreach ( var outer in hostOuters )
			{
				if ( outer.Contains( seed ) )
					hostHits++;
			}

			if ( hostHits == 0 )
				continue;

			allHits = hostHits;

			for ( var i = hostOuters.Count; i < allOuters.Count; i++ )
			{
				if ( allOuters[i].Contains( seed ) )
					allHits++;
			}

			if ( allHits < 2 )
				continue;

			result.Profiles.Add( new Profile { Outer = loop, FromOverlap = true } );
		}
	}

	/// <summary>
	/// Interior cuts that never joined the integer graph — a line across a rectangle, two lines
	/// crossing in the middle of one — become faces on the imprinted copy. The unsplit walk
	/// prunes them as dangling because coincidence is identity, then reports the outer loop as
	/// one region. That is the right editing model and the wrong picture: the user drew four
	/// panes and got one.
	///
	/// Only runs when the unsplit walk actually discarded open chains. Two overlapping rectangles
	/// have no dangling curves; they stay two wholes plus a lens. A tail hanging off a corner
	/// shares a vertex and does not cross anything, so the imprint is a no-op and the tail stays
	/// reported. And a recovered partition has to cover the same area as the unsplit outers —
	/// otherwise an overlapping pair that happened to have a stray line would lose its wholes.
	/// </summary>
	static void RecoverCutRegions( Sketch sketch, ProfileResult result )
	{
		if ( result.OpenChains == 0 )
			return;

		var working = SketchArrangement.ImprintCrossings( sketch );

		if ( ReferenceEquals( working, sketch ) )
			return;

		var recovered = new ProfileResult();
		NestInto( recovered, CollectLoops( working, recovered ) );

		if ( recovered.Profiles.Count <= result.Profiles.Count )
			return;

		if ( result.Profiles.Count > 0 )
		{
			var before = result.Profiles.Sum( p => p.Area );
			var after = recovered.Profiles.Sum( p => p.Area );
			var tol = 1e-3f * MathF.Max( 1f, MathF.Max( before, after ) );

			if ( MathF.Abs( after - before ) > tol )
				return;
		}

		result.Profiles.Clear();
		result.Profiles.AddRange( recovered.Profiles );
		result.Warnings.Clear();
		result.Warnings.AddRange( recovered.Warnings );
		result.OpenChains = recovered.OpenChains;
	}

	/// <summary>This sketch's own closed regions, without overlap extras and without looking at
	/// neighbours — the outers that a neighbour contributes to a combined arrangement.</summary>
	static List<Profile> Originals( Sketch sketch )
	{
		var result = new ProfileResult();
		NestInto( result, CollectLoops( sketch, result ) );
		return result.Profiles;
	}

	/// <summary>The smallest region that contains <paramref name="seed"/>, or null. Same rule
	/// the viewport pick uses: two overlapping wholes both contain a click in the lens, and
	/// the lens wins because it is smaller.</summary>
	public static Profile SmallestContaining( IEnumerable<Profile> profiles, Vec2 seed )
	{
		Profile picked = null;

		if ( profiles is null )
			return null;

		foreach ( var profile in profiles )
		{
			if ( !profile.Contains( seed ) )
				continue;

			if ( picked is null || profile.Area < picked.Area )
				picked = profile;
		}

		return picked;
	}

	/// <summary>A profile expressed in another plane's coordinates. Identity when the two
	/// planes share origin and axes, so a coplanar neighbour with the same UV is free.</summary>
	public static Profile Project( Profile profile, SketchPlane from, SketchPlane to ) => new()
	{
		Outer = ProjectLoop( profile.Outer, from, to ),
		Holes = profile.Holes.Select( h => ProjectLoop( h, from, to ) ).ToList(),
		FromOverlap = profile.FromOverlap
	};

	static Profile ProjectProfile( Profile profile, SketchPlane from, SketchPlane to ) =>
		Project( profile, from, to );

	static List<Vec2> ProjectLoop( List<Vec2> loop, SketchPlane from, SketchPlane to )
	{
		if ( from.Origin.AlmostEquals( to.Origin )
			&& from.XAxis.AlmostEquals( to.XAxis )
			&& from.YAxis.AlmostEquals( to.YAxis ) )
			return loop;

		var projected = new List<Vec2>( loop.Count );

		foreach ( var p in loop )
			projected.Add( to.ToPlane( from.ToWorld( p ) ) );

		return projected;
	}

	static bool AnyBoundsOverlap( List<Profile> outers )
	{
		for ( var i = 0; i < outers.Count; i++ )
		{
			var a = Bounds( outers[i].Outer );

			for ( var j = i + 1; j < outers.Count; j++ )
			{
				var b = Bounds( outers[j].Outer );

				if ( a.min.x <= b.max.x && a.max.x >= b.min.x
					&& a.min.y <= b.max.y && a.max.y >= b.min.y )
					return true;
			}
		}

		return false;
	}

	static (Vec2 min, Vec2 max) Bounds( List<Vec2> loop )
	{
		var min = new Vec2( float.MaxValue, float.MaxValue );
		var max = new Vec2( float.MinValue, float.MinValue );

		foreach ( var p in loop )
		{
			min = new Vec2( MathF.Min( min.x, p.x ), MathF.Min( min.y, p.y ) );
			max = new Vec2( MathF.Max( max.x, p.x ), MathF.Max( max.y, p.y ) );
		}

		return (min, max);
	}

	/// <summary>
	/// Inner is a hole in outer only when it sits entirely inside and the two do not cross.
	///
	/// EVERY VERTEX, not one interior point. The centroid of a rectangle with a circle in the
	/// middle sits inside that circle, so a single-point test flipped nesting and produced no
	/// outer at all. An overlapping neighbour can have one vertex inside without being a hole;
	/// requiring every vertex rejects that, and <see cref="Crosses"/> rejects the rest.
	/// </summary>
	static bool StrictlyInside( List<Vec2> outer, List<Vec2> inner )
	{
		if ( inner.Count == 0 || Crosses( outer, inner ) )
			return false;

		foreach ( var p in inner )
		{
			if ( !Contains( outer, p ) )
				return false;
		}

		return true;
	}

	/// <summary>
	/// A point strictly inside the polygon, not a vertex. Nesting used to probe <c>loop[0]</c>,
	/// which for an overlapping neighbour is often on another loop's boundary, where ray-casting
	/// is undefined.
	///
	/// The area centroid is the first choice: it sits well inside a convex face (the lens) and
	/// inside a shallow crescent (one circle not the other). Stepping off an edge is the fallback
	/// for a C-shape whose centroid has fallen out of the polygon — and it is a fallback because
	/// an inset off the dent of a crescent can land in the lens, which would then be counted as
	/// inside both originals.
	/// </summary>
	static Vec2 InteriorPoint( List<Vec2> loop )
	{
		if ( loop.Count == 0 )
			return Vec2.Zero;

		var area2 = 0f;
		var cx = 0f;
		var cy = 0f;

		for ( var i = 0; i < loop.Count; i++ )
		{
			var a = loop[i];
			var b = loop[(i + 1) % loop.Count];
			var cross = a.x * b.y - b.x * a.y;
			area2 += cross;
			cx += (a.x + b.x) * cross;
			cy += (a.y + b.y) * cross;
		}

		if ( MathF.Abs( area2 ) > 1e-12f )
		{
			var centroid = new Vec2( cx / ( 3f * area2 ), cy / ( 3f * area2 ) );

			if ( Contains( loop, centroid ) )
				return centroid;
		}

		var sign = area2 >= 0f ? 1f : -1f;

		for ( var i = 0; i < loop.Count; i++ )
		{
			var a = loop[i];
			var b = loop[(i + 1) % loop.Count];
			var edge = b - a;
			var length = edge.Length;

			if ( length < 1e-8f )
				continue;

			var inset = MathF.Min( 1e-3f, length * 0.1f );
			var left = new Vec2( -edge.y, edge.x ) / length;
			var probe = new Vec2(
				(a.x + b.x) * 0.5f + left.x * sign * inset,
				(a.y + b.y) * 0.5f + left.y * sign * inset );

			if ( Contains( loop, probe ) )
				return probe;
		}

		return loop[0];
	}

	/// <summary>Proper edge crossings, ignoring vertices the two loops already share.</summary>
	static bool Crosses( List<Vec2> a, List<Vec2> b )
	{
		const float eps = 1e-5f;

		for ( var i = 0; i < a.Count; i++ )
		{
			var a0 = a[i];
			var a1 = a[(i + 1) % a.Count];
			var da = a1 - a0;

			for ( var j = 0; j < b.Count; j++ )
			{
				var b0 = b[j];
				var b1 = b[(j + 1) % b.Count];
				var db = b1 - b0;
				var denom = Vec2.Cross( da, db );

				if ( MathF.Abs( denom ) < eps )
					continue;

				var t = Vec2.Cross( b0 - a0, db ) / denom;
				var u = Vec2.Cross( b0 - a0, da ) / denom;

				if ( t is > eps and < 1f - eps && u is > eps and < 1f - eps )
					return true;
			}
		}

		return false;
	}

	/// <summary>One direction along one curve. Two of these per curve, and each belongs to exactly
	/// one face, which is what makes the traversal terminate and cover everything.</summary>
	sealed class HalfEdge
	{
		public SketchCurve Curve;
		public int From, To;

		/// <summary>The direction it actually LEAVES From in, as an angle. The tangent, not the
		/// bearing of the far endpoint — see the class comment for why that distinction decides
		/// which face an arc belongs to.</summary>
		public float Angle;

		public HalfEdge Twin;
		public bool Used;

		/// <summary>Tessellated points from From to To inclusive.</summary>
		public List<Vec2> Points;
	}

	/// <summary>
	/// Every bounded face of the sketch's curve graph, as loops of points.
	///
	/// Dangling curves are pruned first. A curve with a free end encloses nothing, and in a face
	/// traversal it is worse than useless: the walk runs out along it and back, leaving a zero-width
	/// spur in the middle of an otherwise good region. Pruning repeats, because removing one dangling
	/// curve can leave the next one dangling — a whole tail retracts one curve at a time.
	/// </summary>
	static List<List<Vec2>> FindFaces( Sketch sketch, List<SketchCurve> edges, ProfileResult result )
	{
		var faces = new List<List<Vec2>>();
		var live = new List<SketchCurve>();
		var pruned = new List<SketchCurve>();

		foreach ( var curve in edges )
		{
			var (a, b) = Ends( curve );

			// A curve whose two ends are the same point is a closed loop of one curve, which the
			// traversal has no way to walk — its twin leaves the same vertex it arrives at. A closed
			// arc should have been drawn as a circle.
			if ( a == b )
			{
				result.Warnings.Add( $"a curve starting and ending at point {a} was skipped; draw a full circle instead" );
				continue;
			}

			live.Add( curve );
		}

		while ( true )
		{
			var degree = new Dictionary<int, int>();

			foreach ( var curve in live )
			{
				var (a, b) = Ends( curve );
				degree[a] = degree.GetValueOrDefault( a ) + 1;
				degree[b] = degree.GetValueOrDefault( b ) + 1;
			}

			var dangling = live.Where( c =>
			{
				var (a, b) = Ends( c );
				return degree[a] < 2 || degree[b] < 2;
			} ).ToList();

			if ( dangling.Count == 0 )
				break;

			foreach ( var curve in dangling )
			{
				live.Remove( curve );
				pruned.Add( curve );
			}
		}

		var chains = CountChains( pruned );
		result.OpenChains += chains;

		// SAID OUT LOUD, not just dropped. A dangling curve encloses nothing and cannot be part of a
		// region, but it is still geometry somebody drew — and building the good regions while
		// silently discarding it is the failure mode that matters here: it looks like it worked. The
		// caller turns this into "built from N regions; ignored: ...".
		if ( chains > 0 )
		{
			result.Warnings.Add( chains == 1
				? $"{pruned.Count} curve(s) form an open chain that does not enclose anything"
				: $"{pruned.Count} curve(s) form {chains} open chains that do not enclose anything" );
		}

		if ( live.Count == 0 )
			return faces;

		// --- build the half-edges and sort them around each point --------------------------------

		var outgoing = new Dictionary<int, List<HalfEdge>>();
		var all = new List<HalfEdge>( live.Count * 2 );

		foreach ( var curve in live )
		{
			var (a, b) = Ends( curve );
			var forwardPoints = curve.Tessellate( sketch, sketch.Tolerance );
			var backwardPoints = new List<Vec2>( forwardPoints );
			backwardPoints.Reverse();

			var forward = new HalfEdge { Curve = curve, From = a, To = b, Points = forwardPoints };
			var backward = new HalfEdge { Curve = curve, From = b, To = a, Points = backwardPoints };

			forward.Twin = backward;
			backward.Twin = forward;

			forward.Angle = LeavingAngle( forwardPoints );
			backward.Angle = LeavingAngle( backwardPoints );

			all.Add( forward );
			all.Add( backward );

			Add( outgoing, a, forward );
			Add( outgoing, b, backward );
		}

		foreach ( var list in outgoing.Values )
			list.Sort( ( x, y ) => x.Angle.CompareTo( y.Angle ) );

		// --- walk every face ---------------------------------------------------------------------

		foreach ( var seed in all )
		{
			if ( seed.Used )
				continue;

			var loop = new List<Vec2>();
			var current = seed;

			// Each half-edge is used once, so a walk can never be longer than the total. The guard is
			// for a graph the sort could not order consistently rather than for the normal case.
			for ( var guard = all.Count + 1; guard > 0; guard-- )
			{
				current.Used = true;

				// Consecutive half-edges share their joining point, so every curve after the first
				// drops its opening point.
				for ( var i = loop.Count == 0 ? 0 : 1; i < current.Points.Count; i++ )
					loop.Add( current.Points[i] );

				var next = NextAroundFace( outgoing, current );

				if ( next is null || ReferenceEquals( next, seed ) )
					break;

				current = next;
			}

			// The walk finishes back on its first point, which is already the loop's first entry.
			if ( loop.Count > 1 )
				loop.RemoveAt( loop.Count - 1 );

			// Counter-clockwise means a bounded face. Every connected piece of the sketch also
			// produces exactly one clockwise face, the infinite one around it, and that is the one
			// thing here with nothing to contribute.
			if ( loop.Count >= 3 && SignedArea( loop ) > 0f )
				faces.Add( loop );
		}

		return faces;
	}

	/// <summary>
	/// The next half-edge around the same face.
	///
	/// Arrive along h at its far point, turn round onto h's twin, and leave along whichever outgoing
	/// half-edge sits immediately CLOCKWISE of the twin. Taking the clockwise neighbour is what makes
	/// bounded faces come out counter-clockwise; taking the other one traces them the other way and
	/// every region arrives inside out.
	/// </summary>
	static HalfEdge NextAroundFace( Dictionary<int, List<HalfEdge>> outgoing, HalfEdge h )
	{
		if ( !outgoing.TryGetValue( h.To, out var around ) || around.Count == 0 )
			return null;

		var index = around.IndexOf( h.Twin );

		if ( index < 0 )
			return null;

		return around[(index - 1 + around.Count) % around.Count];
	}

	/// <summary>The direction a tessellated curve sets off in, as an angle. Uses the first segment
	/// long enough to have a direction, so a curve that starts with a hair-thin step still reports
	/// where it is actually going.</summary>
	static float LeavingAngle( List<Vec2> points )
	{
		var from = points[0];

		for ( var i = 1; i < points.Count; i++ )
		{
			var delta = points[i] - from;

			if ( delta.LengthSquared > 1e-16f )
				return MathF.Atan2( delta.y, delta.x );
		}

		return 0f;
	}

	static void Add( Dictionary<int, List<HalfEdge>> map, int point, HalfEdge edge )
	{
		if ( !map.TryGetValue( point, out var list ) )
			map[point] = list = new List<HalfEdge>();

		list.Add( edge );
	}

	/// <summary>How many separate open chains a set of pruned curves forms, so "its curves do not
	/// join up" can be said about the right number of them.</summary>
	static int CountChains( List<SketchCurve> pruned )
	{
		if ( pruned.Count == 0 )
			return 0;

		var remaining = new HashSet<SketchCurve>( pruned );
		var chains = 0;

		while ( remaining.Count > 0 )
		{
			var seed = remaining.First();
			var queue = new Queue<SketchCurve>();
			queue.Enqueue( seed );
			remaining.Remove( seed );

			var touched = new HashSet<int>();

			while ( queue.Count > 0 )
			{
				var (a, b) = Ends( queue.Dequeue() );
				touched.Add( a );
				touched.Add( b );

				foreach ( var candidate in remaining.ToList() )
				{
					var (ca, cb) = Ends( candidate );

					if ( !touched.Contains( ca ) && !touched.Contains( cb ) )
						continue;

					remaining.Remove( candidate );
					queue.Enqueue( candidate );
				}
			}

			chains++;
		}

		return chains;
	}

	static (int A, int B) Ends( SketchCurve curve ) => curve.Endpoints;

	/// <summary>
	/// Follow curves end to end until we return to where we started, or run out.
	///
	/// `reverse` walks out of the seed curve's other end. It exists because a walk only ever goes
	/// one way: seeded from the MIDDLE of an open polyline, the forward walk consumes one half and
	/// the untouched other half is then picked up as a second seed, so one chain gets counted as
	/// two. Walking both ways from the seed consumes the whole chain at once.
	/// </summary>
	static List<Vec2> WalkLoop(
		Sketch sketch,
		SketchCurve start,
		Dictionary<int, List<SketchCurve>> adjacency,
		HashSet<SketchCurve> visited,
		out bool closed,
		bool reverse = false )
	{
		var points = new List<Vec2>();
		var (startA, startB) = Ends( start );
		var firstPoint = reverse ? startB : startA;

		var current = start;
		var entryPoint = firstPoint;
		closed = false;

		while ( true )
		{
			visited.Add( current );

			var (a, b) = Ends( current );
			var exitPoint = entryPoint == a ? b : a;

			var tess = current.Tessellate( sketch, sketch.Tolerance );

			// Tessellation always runs start->end; reverse it when the walk crosses the other way.
			if ( entryPoint != a )
				tess.Reverse();

			// Drop the first point of every curve after the first: consecutive curves share their
			// joining point, and keeping both would leave a zero-length segment in the loop.
			for ( var i = points.Count == 0 ? 0 : 1; i < tess.Count; i++ )
				points.Add( tess[i] );

			if ( exitPoint == firstPoint )
			{
				closed = true;

				// The walk ended back at the start point, which is already the loop's first entry.
				if ( points.Count > 1 )
					points.RemoveAt( points.Count - 1 );

				return points;
			}

			if ( !adjacency.TryGetValue( exitPoint, out var candidates ) )
				return points;

			var next = candidates.FirstOrDefault( c => c != current && !visited.Contains( c ) );

			if ( next is null )
				return points;

			current = next;
			entryPoint = exitPoint;
		}
	}

	/// <summary>Shoelace formula. Positive is counter-clockwise.</summary>
	public static float SignedArea( List<Vec2> loop )
	{
		var area = 0f;

		for ( var i = 0; i < loop.Count; i++ )
		{
			var a = loop[i];
			var b = loop[(i + 1) % loop.Count];
			area += a.x * b.y - b.x * a.y;
		}

		return area * 0.5f;
	}

	static List<Vec2> Orient( List<Vec2> loop, bool counterClockwise )
	{
		var ccw = SignedArea( loop ) > 0f;

		if ( ccw != counterClockwise )
		{
			var copy = new List<Vec2>( loop );
			copy.Reverse();
			return copy;
		}

		return new List<Vec2>( loop );
	}

	/// <summary>Ray casting. Points exactly on the boundary are undefined, which is fine — this is
	/// only ever asked about a vertex of a different loop.</summary>
	public static bool Contains( List<Vec2> loop, Vec2 p )
	{
		var inside = false;

		for ( int i = 0, j = loop.Count - 1; i < loop.Count; j = i++ )
		{
			var a = loop[i];
			var b = loop[j];

			if ( a.y > p.y != b.y > p.y &&
				 p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x )
			{
				inside = !inside;
			}
		}

		return inside;
	}
}