Editor/Effigy/EdgeBlend.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy;

/// <summary>
/// Chamfer and fillet — the two ways to take a sharp edge off a solid, and one implementation,
/// because they differ in exactly one place.
///
/// TWO NAMES BECAUSE THEY ARE TWO OPERATIONS TO THE PERSON USING THEM. Onshape has a Chamfer tool
/// and a Fillet tool, sized in a distance and a radius respectively, and nobody reaching for a
/// rounded corner thinks of it as a chamfer with a segment count turned up. This file used to be
/// called Bevel and offered the flat cut only; the rounding arrived as a parameter on it, which
/// put the two operations behind one name and one control that meant a different thing at each
/// end of its range.
///
/// THE COERCION AT THE HEART OF THIS: a face's corner is never split into two points. Instead each
/// (face, vertex) pair gets exactly ONE new point — the intersection, in that face's own plane, of
/// its two boundary edges after sliding the selected ones inward by that edge's setback. That
/// single rule is what makes an edge cut, a vertex cap and a plain untouched corner all fall out of
/// the same code: an untouched corner is the case where neither incident line moved, so the
/// "intersection" is just the original vertex.
///
/// THE PART THAT ISN'T LOCAL: a corner can still move because of an edge that ISN'T selected,
/// simply because the corner's OTHER edge is. That moved point lands exactly on the unselected
/// edge's own line (see CutCorner) — so the face across THAT edge, even though nothing about it was
/// selected, now disagrees with its neighbour about where their shared edge ends. Reconciling that
/// is what the bridging pass does for every edge, not only the ones the angle threshold picked.
///
/// WHERE A FILLET DIFFERS FROM A CHAMFER, AND IT IS ONLY THESE TWO THINGS:
///
/// 1. The setback is derived rather than given. A chamfer's distance IS the setback. A fillet's
///    radius is not: the tangent points of a circle of radius r inscribed against two faces meeting
///    at an opening angle φ sit r/tan(φ/2) back from the edge, so a fillet computes a setback PER
///    EDGE from that edge's own angle. On a cube every edge opens at 90° and the two coincide,
///    which is why one global width was enough as long as only chamfers existed.
///
/// 2. The single bridging quad along an edge becomes n quads across an arc — and every point on
///    that arc has to be threaded into the vertex cap at each end, in the cap's own cyclic order.
///    That is the part that made rounding not a local change. Arc points added to the bridge and
///    not to the cap leave T-junctions, which pass closed, manifold and Euler while rendering
///    wrong. See ArcRails and the cap pass.
/// </summary>
public static class EdgeBlend
{
	/// <summary>
	/// Flat cut. `distance` is how far back from the edge the cut starts on each adjacent face,
	/// which is what Onshape's chamfer means by distance.
	/// </summary>
	public static PolyMesh Chamfer( PolyMesh mesh, float distance, float angleThresholdDegrees )
		=> ChamferReport( mesh, distance, angleThresholdDegrees ).Mesh;

	public static BlendReport ChamferReport( PolyMesh mesh, float distance, float angleThresholdDegrees )
		=> Apply( mesh, distance, angleThresholdDegrees, 1, rounded: false );

	/// <summary>
	/// Rounded cut. `radius` is the radius of the arc, not the setback — see the class comment for
	/// why those are the same number on a cube and different everywhere else.
	///
	/// `segments` is how many faces the arc is made of. Four is a reasonable default for a
	/// polygonal kernel: enough to read as round at model scale, few enough not to bury the quad
	/// cage Catmull-Clark wants under a strip of slivers. One is legal and produces exactly the
	/// chamfer, which is the right answer to asking for a one-segment arc.
	/// </summary>
	public static PolyMesh Fillet( PolyMesh mesh, float radius, float angleThresholdDegrees, int segments = 4 )
		=> FilletReport( mesh, radius, angleThresholdDegrees, segments ).Mesh;

	public static BlendReport FilletReport( PolyMesh mesh, float radius, float angleThresholdDegrees, int segments = 4 )
		=> Apply( mesh, radius, angleThresholdDegrees, Math.Max( 1, segments ), rounded: true );

	/// <summary>
	/// Cuts every edge whose two face normals diverge by at least `angleThresholdDegrees`.
	/// Boundary edges (one adjacent face) are never selected — cutting an open edge needs different
	/// handling and is not built.
	///
	/// Skin weights, if the mesh is rigged, come along: every new point is a genuine cut of exactly
	/// one original vertex (see the class comment), so it inherits that vertex's weights unchanged
	/// rather than an average of its neighbours — the same "one point, one source" rule
	/// CatmullClark's own corner rule follows, just without the blending, because a cut corner
	/// doesn't move far enough from its source to warrant one. An arc point between two corners cut
	/// from the same vertex is that vertex's cut too, so it takes the same weights.
	/// </summary>
	static BlendReport Apply( PolyMesh mesh, float size, float angleThresholdDegrees, int segments, bool rounded )
	{
		var report = new BlendReport { OriginalVolume = mesh.SignedVolume() };
		var noun = rounded ? "radius" : "distance";
		var sizeLabel = rounded ? "Radius" : "Distance";

		if ( size <= 0f )
		{
			report.Mesh = mesh.Clone();
			report.Failure = new FeatureDiagnostic(
				DiagnosticSeverity.Error,
				$"This {noun} is zero, so the feature has nothing to cut",
				$"{sizeLabel} is {size:0.###}. A blend that does not move any edge is a no-op, not a success.",
				sizeLabel,
				remedies: new[] { $"Enter a {noun} greater than zero" } );
			return report;
		}

		var work = Cut( mesh, size, angleThresholdDegrees, rounded );
		report.SelectedEdges = work.Selected.Count;
		report.SharpestDegrees = work.SharpestDegrees;

		if ( work.Selected.Count == 0 )
		{
			report.Mesh = mesh.Clone();
			report.Failure = new FeatureDiagnostic(
				DiagnosticSeverity.Error,
				$"No edge on this body is sharper than {angleThresholdDegrees:0.#}°",
				$"The sharpest edge opens at {work.SharpestDegrees:0.#}°, so nothing was selected and the solid is unchanged.",
				"Angle threshold",
				work.SharpestDegrees > 0f ? MathF.Max( 0f, work.SharpestDegrees - 1f ) : (float?)null,
				$"Lower the angle threshold below {work.SharpestDegrees:0.#}°",
				"Pick a body with sharper edges" );
			return report;
		}

		var shrunk = BuildShrunkFaces( work );
		var collapsed = CountCollapsed( mesh, shrunk, work.FaceNormals );

		if ( collapsed > 0 )
		{
			var fit = Bisect( size, s => FitsAll( mesh, s, angleThresholdDegrees, segments, rounded ) );
			report.SuggestedSize = fit;
			report.Mesh = mesh.Clone();
			report.Failure = new FeatureDiagnostic(
				DiagnosticSeverity.Error,
				$"This {noun} collapses {collapsed} face(s) of the solid",
				$"{sizeLabel} {size:0.###} eats at least one face entirely — the largest {noun} that still fits is {fit:0.###}.",
				sizeLabel,
				fit,
				$"Reduce {noun} to {fit:0.###}" );
			return report;
		}

		var result = Finish( work, shrunk, segments, rounded, out var flattened, out var uncapped );
		report.ResultVolume = result.SignedVolume();

		if ( report.ResultVolume <= 0f )
		{
			var fit = Bisect( size, s => FitsAll( mesh, s, angleThresholdDegrees, segments, rounded ) );

			report.SuggestedSize = fit;
			report.Mesh = mesh.Clone();
			report.Failure = new FeatureDiagnostic(
				DiagnosticSeverity.Error,
				$"This {noun} turns the solid inside out",
				$"The body enclosed {report.OriginalVolume:0.###} and came back {report.ResultVolume:0.###} — opposite fillets have met through the middle. The largest {noun} that stays a solid is {fit:0.###}.",
				sizeLabel,
				fit,
				$"Reduce {noun} to {fit:0.###}" );
			return report;
		}

		report.Mesh = result;

		if ( report.OriginalVolume > 1e-6f && report.ResultVolume < report.OriginalVolume * 0.5f )
		{
			report.Warnings.Add( new FeatureDiagnostic(
				DiagnosticSeverity.Warning,
				$"This {noun} removes more than half the solid",
				$"The body went from {report.OriginalVolume:0.###} down to {report.ResultVolume:0.###} ({100f * report.ResultVolume / report.OriginalVolume:0.#}% left). That is still a valid solid, and almost never what was meant.",
				sizeLabel,
				remedies: new[] { $"Reduce the {noun}", "Check that every sharp edge was meant to be blended" } ) );
		}

		if ( work.ClampedSetbacks > 0 )
		{
			report.Warnings.Add( new FeatureDiagnostic(
				DiagnosticSeverity.Warning,
				$"{work.ClampedSetbacks} edge(s) are too shallow for this {noun}",
				$"Their blend is narrower than the {noun} asked for — a setback of {noun}/tan(φ/2) would have eaten the faces either side, so it was clamped.",
				sizeLabel,
				remedies: new[] { $"Reduce the {noun}", "Raise the angle threshold so shallow edges are left sharp" } ) );
		}

		if ( work.SquaredCorners > 0 )
		{
			report.Warnings.Add( new FeatureDiagnostic(
				DiagnosticSeverity.Warning,
				$"{work.SquaredCorners} corner(s) were too shallow to cut cleanly and were squared off",
				"A near-straight corner throws the cut point arbitrarily far; those corners were snapped back so the solid stays local.",
				remedies: new[] { "Leave those edges unselected", $"Reduce the {noun}" } ) );
		}

		if ( flattened > 0 )
		{
			report.Warnings.Add( new FeatureDiagnostic(
				DiagnosticSeverity.Warning,
				$"{flattened} edge(s) could not be rounded and were chamfered instead",
				"The two ends of the arc disagreed about where the centre sits, so those edges fell back to a flat quad rather than a broken strip.",
				remedies: new[] { $"Reduce the {noun}", "Use Chamfer on those edges" } ) );
		}

		if ( uncapped > 0 )
		{
			report.Warnings.Add( new FeatureDiagnostic(
				DiagnosticSeverity.Warning,
				$"{uncapped} corner(s) sit on a boundary or non-manifold vertex and were left sharp",
				"A cap needs a closed loop of faces around the vertex; those vertices do not have one.",
				remedies: new[] { "Close the mesh first", "Avoid blending edges that run onto a boundary" } ) );
		}

		return report;
	}

	static PolyMesh ApplyUnchecked( PolyMesh mesh, float size, float angleThresholdDegrees, int segments, bool rounded )
	{
		if ( size <= 0f )
			return mesh.Clone();

		var work = Cut( mesh, size, angleThresholdDegrees, rounded );

		if ( work.Selected.Count == 0 )
			return mesh.Clone();

		var shrunk = BuildShrunkFaces( work );
		return Finish( work, shrunk, segments, rounded, out _, out _ );
	}

	static bool ShrinkFits( PolyMesh mesh, float size, float angleThresholdDegrees, bool rounded )
	{
		var work = Cut( mesh, size, angleThresholdDegrees, rounded );

		if ( work.Selected.Count == 0 )
			return true;

		var shrunk = BuildShrunkFaces( work );
		return CountCollapsed( mesh, shrunk, work.FaceNormals ) == 0;
	}

	static bool FitsAll( PolyMesh mesh, float size, float angleThresholdDegrees, int segments, bool rounded )
	{
		if ( !ShrinkFits( mesh, size, angleThresholdDegrees, rounded ) )
			return false;

		return ApplyUnchecked( mesh, size, angleThresholdDegrees, segments, rounded ).SignedVolume() > 0f;
	}

	static float Bisect( float size, Func<float, bool> fits )
	{
		if ( fits( size ) )
			return size;

		var lo = 0f;
		var hi = size;

		for ( var i = 0; i < 12; i++ )
		{
			var mid = ( lo + hi ) * 0.5f;

			if ( fits( mid ) )
				lo = mid;
			else
				hi = mid;
		}

		return lo;
	}

	sealed class CutWork
	{
		public PolyMesh Source;
		public Vec3[] FaceNormals;
		public Dictionary<EdgeKey, List<int>> EdgeFaces;
		public List<int>[] VertexFaces;
		public HashSet<EdgeKey> Selected;
		public Dictionary<EdgeKey, float> Setbacks;
		public Dictionary<(int Face, int Vertex), int> CornerPoint;
		public Dictionary<(int Face, EdgeKey), int> EdgeFrom;
		public Dictionary<(int Face, int Vertex), EdgeKey> NextEdgeAtVertex;
		public List<Vec3> Positions;
		public List<BoneWeight[]> Weights;
		public int ClampedSetbacks;
		public int SquaredCorners;
		public float SharpestDegrees;
		public float BlendSize;
	}

	static CutWork Cut( PolyMesh mesh, float size, float angleThresholdDegrees, bool rounded )
	{
		var edgeFaces = mesh.BuildEdgeFaces();
		var vertexFaces = mesh.BuildVertexFaces();
		var faceNormals = mesh.Faces.Select( mesh.FaceNormal ).ToArray();
		var selected = SelectEdges( mesh, edgeFaces, faceNormals, angleThresholdDegrees );
		var setback = Setbacks( selected, edgeFaces, faceNormals, size, rounded, out var clamped );

		var work = new CutWork
		{
			Source = mesh,
			FaceNormals = faceNormals,
			EdgeFaces = edgeFaces,
			VertexFaces = vertexFaces,
			Selected = selected,
			Setbacks = setback,
			CornerPoint = new Dictionary<(int Face, int Vertex), int>(),
			EdgeFrom = new Dictionary<(int Face, EdgeKey), int>(),
			NextEdgeAtVertex = new Dictionary<(int Face, int Vertex), EdgeKey>(),
			Positions = new List<Vec3>( mesh.Positions ),
			Weights = mesh.IsRigged ? new List<BoneWeight[]>( mesh.Skin.Vertices ) : null,
			ClampedSetbacks = clamped,
			SharpestDegrees = SharpestEdgeDegrees( edgeFaces, faceNormals ),
			BlendSize = size
		};

		var squared = 0;

		for ( var fi = 0; fi < mesh.Faces.Count; fi++ )
		{
			var f = mesh.Faces[fi];
			var n = faceNormals[fi];
			var count = f.Count;

			for ( var i = 0; i < count; i++ )
			{
				var prev = f.Indices[(i - 1 + count) % count];
				var v = f.Indices[i];
				var next = f.Indices[(i + 1) % count];

				var prevKey = new EdgeKey( prev, v );
				var nextKey = new EdgeKey( v, next );

				work.EdgeFrom[(fi, nextKey)] = v;
				work.NextEdgeAtVertex[(fi, v)] = nextKey;

				var point = CutCorner(
					mesh.Positions[prev], mesh.Positions[v], mesh.Positions[next], n,
					Setback( setback, prevKey ), Setback( setback, nextKey ), ref squared );

				int index;

				if ( point is { } p )
				{
					index = work.Positions.Count;
					work.Positions.Add( p );
					work.Weights?.Add( mesh.Skin[v] );
				}
				else
				{
					index = v;
				}

				work.CornerPoint[(fi, v)] = index;
			}
		}

		work.SquaredCorners = squared;
		return work;
	}

	static PolyMesh BuildShrunkFaces( CutWork work )
	{
		var result = new PolyMesh
		{
			Positions = work.Positions,
			Skin = work.Weights is null ? null : new SkinWeights { Vertices = work.Weights }
		};

		for ( var fi = 0; fi < work.Source.Faces.Count; fi++ )
		{
			var f = work.Source.Faces[fi];
			var newIndices = new int[f.Count];

			for ( var i = 0; i < f.Count; i++ )
				newIndices[i] = work.CornerPoint[(fi, f.Indices[i])];

			result.AddFace( newIndices, (Vec2[])f.UVs.Clone(), f.Material );
		}

		return result;
	}

	static int CountCollapsed( PolyMesh original, PolyMesh shrunk, Vec3[] originalNormals )
	{
		var n = 0;

		for ( var fi = 0; fi < original.Faces.Count; fi++ )
		{
			var origArea = original.FaceArea( original.Faces[fi] );

			if ( origArea < 1e-12f )
				continue;

			var signed = SignedArea( shrunk, shrunk.Faces[fi], originalNormals[fi] );

			if ( signed <= origArea * 1e-4f )
				n++;
		}

		return n;
	}

	static float SignedArea( PolyMesh mesh, Face f, Vec3 referenceNormal )
	{
		var c = mesh.FaceCentroid( f );
		var area = 0f;

		for ( var i = 0; i < f.Count; i++ )
		{
			var a = mesh.Positions[f.Indices[i]] - c;
			var b = mesh.Positions[f.Indices[(i + 1) % f.Count]] - c;
			area += Vec3.Dot( Vec3.Cross( a, b ), referenceNormal ) * 0.5f;
		}

		return area;
	}

	static float SharpestEdgeDegrees( Dictionary<EdgeKey, List<int>> edgeFaces, Vec3[] faceNormals )
	{
		var sharpest = 0f;

		foreach ( var ( _, faces ) in edgeFaces )
		{
			if ( faces.Count != 2 )
				continue;

			var dot = Math.Clamp( Vec3.Dot( faceNormals[faces[0]], faceNormals[faces[1]] ), -1f, 1f );
			var deg = MathF.Acos( dot ) * 180f / MathF.PI;

			if ( deg > sharpest )
				sharpest = deg;
		}

		return sharpest;
	}

	static PolyMesh Finish( CutWork work, PolyMesh result, int segments, bool rounded,
		out int flattened, out int uncapped )
	{
		var mesh = work.Source;
		var selected = work.Selected;
		var edgeFaces = work.EdgeFaces;
		var edgeFrom = work.EdgeFrom;
		var cornerPoint = work.CornerPoint;
		var faceNormals = work.FaceNormals;
		var nextEdgeAtVertex = work.NextEdgeAtVertex;
		var vertexFaces = work.VertexFaces;
		flattened = 0;
		uncapped = 0;

		// Pass 2 already ran: `result` holds the shrunk faces. Continue from the original Apply
		// at pass 2.5.
		var rails = rounded
			? ArcRails( mesh, result, selected, edgeFaces, edgeFrom, cornerPoint, faceNormals, work.BlendSize, segments )
			: new Dictionary<EdgeKey, Rails>();

		if ( rounded && segments >= 2 )
		{
			foreach ( var key in selected )
			{
				if ( edgeFaces[key].Count == 2 && !rails.ContainsKey( key ) )
					flattened++;
			}
		}

		// Pass 3: a bridging face for every edge whose two sides disagree at either end — not just
		// the selected ones. An edge can end up needing this even though it was never selected
		// itself: cut a face's corner because of one of its OTHER edges, and the corner slides along
		// THIS edge's own line (see CutCorner) — while the face on the far side of this same edge,
		// having no selected edge of its own here, never moves. Left alone that is a T-junction: two
		// faces disagreeing about where their shared edge ends, which shows up as an open boundary
		// rather than a crash. Skipping unselected edges here was the original design and the bug
		// both — an edge cut is not local to the edge you selected, it is local to every corner
		// that edge touches.
		foreach ( var key in edgeFaces.Keys )
		{
			var faces = edgeFaces[key];

			if ( faces.Count != 2 )
				continue; // boundary edge — nothing on the other side to reconcile with.

			var (fAtoB, fBtoA) = edgeFrom[(faces[0], key)] == key.A
				? (faces[0], faces[1])
				: (faces[1], faces[0]);

			var pAonAB = cornerPoint[(fAtoB, key.A)];
			var pBonAB = cornerPoint[(fAtoB, key.B)];
			var pBonBA = cornerPoint[(fBtoA, key.B)];
			var pAonBA = cornerPoint[(fBtoA, key.A)];

			var hasArc = rails.TryGetValue( key, out var arc );

			var railA = hasArc ? arc.AtA : new[] { pAonAB, pAonBA };
			var railB = hasArc ? arc.AtB : new[] { pBonAB, pBonBA };

			for ( var s = 0; s < railA.Length - 1; s++ )
			{
				var corners = new List<int> { railA[s], railA[s + 1], railB[s + 1], railB[s] };

				for ( var i = corners.Count - 1; i > 0; i-- )
				{
					if ( corners[i] == corners[i - 1] )
						corners.RemoveAt( i );
				}

				if ( corners.Count > 2 && corners[0] == corners[^1] )
					corners.RemoveAt( corners.Count - 1 );

				if ( corners.Count < 3 )
					continue;

				result.AddFace( corners.ToArray(), material: mesh.Faces[fAtoB].Material );
			}
		}

		foreach ( var (v, faces) in vertexFaces.Select( ( fs, v ) => (v, fs) ) )
		{
			var loop = WalkFacesAroundVertex( v, faces, edgeFaces, nextEdgeAtVertex );

			if ( loop is null )
			{
				var cut = false;

				foreach ( var f in faces )
				{
					if ( cornerPoint.TryGetValue( (f, v), out var idx ) && idx != v )
					{
						cut = true;
						break;
					}
				}

				if ( cut )
					uncapped++;

				continue;
			}

			var points = new List<int>();

			foreach ( var f in loop )
			{
				points.Add( cornerPoint[(f, v)] );

				var edge = nextEdgeAtVertex[(f, v)];

				if ( !rails.TryGetValue( edge, out var arc ) )
					continue;

				var rail = v == edge.A ? arc.AtA : arc.AtB;

				if ( f == arc.FaceAtoB )
				{
					for ( var i = 1; i < rail.Length - 1; i++ )
						points.Add( rail[i] );
				}
				else
				{
					for ( var i = rail.Length - 2; i > 0; i-- )
						points.Add( rail[i] );
				}
			}

			var distinct = new List<int>();

			foreach ( var p in points )
			{
				if ( distinct.Count == 0 || distinct[^1] != p )
					distinct.Add( p );
			}

			if ( distinct.Count > 1 && distinct[0] == distinct[^1] )
				distinct.RemoveAt( distinct.Count - 1 );

			if ( distinct.Count < 3 )
				continue;

			result.AddFace( distinct.ToArray(), material: mesh.Faces[loop[0]].Material );
		}

		return RemoveUnusedVertices( result );
	}

	// --- the arc --------------------------------------------------------------------------------

	/// <summary>One rounded edge: the strip's two rails, and which of its faces the rails run FROM
	/// so a cap walking the other way knows to read them backwards.</summary>
	readonly struct Rails
	{
		public readonly int FaceAtoB;
		public readonly int[] AtA, AtB;

		public Rails( int faceAtoB, int[] atA, int[] atB )
		{
			FaceAtoB = faceAtoB;
			AtA = atA;
			AtB = atB;
		}
	}

	/// <summary>How far the two ends may disagree about where the arc's centre is, as a fraction of
	/// the radius. Exactly zero on any edge whose corners were cut by a true fillet; non-zero means
	/// the corner has been pulled somewhere the arc cannot follow, and that edge stays flat.</summary>
	const float CentreAgreement = 0.05f;

	/// <summary>
	/// The arc points along every selected edge, at both ends.
	///
	/// ALL OR NOTHING PER EDGE. An arc that could be built at one end and not the other would leave
	/// the strip's two rails different lengths, and there is no honest way to pair them up. So an
	/// end that cannot be established fails the whole edge back to a flat quad — which is a
	/// chamfer on that one edge, visibly coarser and never wrong.
	/// </summary>
	static Dictionary<EdgeKey, Rails> ArcRails(
		PolyMesh mesh, PolyMesh result, HashSet<EdgeKey> selected,
		Dictionary<EdgeKey, List<int>> edgeFaces,
		Dictionary<(int Face, EdgeKey Edge), int> edgeFrom,
		Dictionary<(int Face, int Vertex), int> cornerPoint,
		Vec3[] faceNormals, float radius, int segments )
	{
		var rails = new Dictionary<EdgeKey, Rails>();

		if ( segments < 2 )
			return rails; // a one-segment arc IS the chamfer, and it is already what pass 3 emits.

		foreach ( var key in selected )
		{
			var faces = edgeFaces[key];

			if ( faces.Count != 2 )
				continue;

			var (fAtoB, fBtoA) = edgeFrom[(faces[0], key)] == key.A
				? (faces[0], faces[1])
				: (faces[1], faces[0]);

			var nA = faceNormals[fAtoB];
			var nB = faceNormals[fBtoA];

			// WHICH SIDE THE CENTRE SITS ON. A convex edge is rounded from inside the solid and a
			// concave one from outside, and the normals alone cannot tell them apart — the angle
			// between them is the same for an edge that opens at 90° and one that opens at 270°.
			// The direction the AB-side face traverses the edge is what breaks the tie.
			var along = (mesh.Positions[key.B] - mesh.Positions[key.A]).Normal;
			var convex = Vec3.Dot( Vec3.Cross( nA, nB ), along ) > 0f;
			var signed = convex ? radius : -radius;

			var atA = Arc( result, cornerPoint[(fAtoB, key.A)], cornerPoint[(fBtoA, key.A)],
				nA, nB, signed, segments, key.A, mesh );

			if ( atA is null )
				continue;

			var atB = Arc( result, cornerPoint[(fAtoB, key.B)], cornerPoint[(fBtoA, key.B)],
				nA, nB, signed, segments, key.B, mesh );

			if ( atB is null )
				continue;

			rails[key] = new Rails( fAtoB, atA, atB );
		}

		return rails;
	}

	/// <summary>
	/// One end's rail: the two tangent points with `segments - 1` arc points between them, or null
	/// if this end cannot carry an arc.
	///
	/// The centre is found twice — once from each tangent point, stepping off its own face by the
	/// radius — and the two answers agreeing is the check that this really is a fillet's corner
	/// rather than a corner some other edge has dragged elsewhere.
	/// </summary>
	static int[] Arc( PolyMesh result, int i0, int i1, Vec3 nA, Vec3 nB, float signedRadius,
		int segments, int vertex, PolyMesh source )
	{
		var rail = new int[segments + 1];

		// An end where both faces kept the same point is an uncut corner: the rail is that one
		// point repeated, so the strip's quads collapse to triangles converging on it. That is the
		// shape a fillet running out onto an unbevelled corner actually has.
		if ( i0 == i1 )
		{
			Array.Fill( rail, i0 );
			return rail;
		}

		var a0 = result.Positions[i0];
		var a1 = result.Positions[i1];

		var c0 = a0 - nA * signedRadius;
		var c1 = a1 - nB * signedRadius;

		if ( (c0 - c1).Length > CentreAgreement * MathF.Max( MathF.Abs( signedRadius ), 1f ) )
			return null;

		var centre = (c0 + c1) * 0.5f;

		var u = a0 - centre;
		var w = a1 - centre;

		if ( u.LengthSquared < 1e-12f || w.LengthSquared < 1e-12f )
			return null;

		rail[0] = i0;
		rail[segments] = i1;

		for ( var k = 1; k < segments; k++ )
		{
			var point = centre + Slerp( u, w, (float)k / segments );

			rail[k] = result.AddVertex( point );

			// Same "one point, one source" rule as a cut corner: this point is a cut of `vertex`
			// and nothing else, so it takes that vertex's weights rather than a blend.
			result.Skin?.Vertices.Add( source.IsRigged ? source.Skin[vertex] : Array.Empty<BoneWeight>() );
		}

		return rail;
	}

	/// <summary>
	/// Interpolate between two vectors along the arc between them rather than the chord — which is
	/// the whole difference between a fillet and a chamfer subdivided.
	///
	/// Falls back to a straight lerp when the two are nearly parallel, where the arc and the chord
	/// are the same thing to well inside float precision and the division is not.
	/// </summary>
	static Vec3 Slerp( Vec3 u, Vec3 w, float t )
	{
		var lengths = u.Length * w.Length;

		if ( lengths < 1e-12f )
			return Vec3.Lerp( u, w, t );

		var cos = Math.Clamp( Vec3.Dot( u, w ) / lengths, -1f, 1f );
		var omega = MathF.Acos( cos );
		var sin = MathF.Sin( omega );

		if ( sin < 1e-5f )
			return Vec3.Lerp( u, w, t );

		return u * (MathF.Sin( (1f - t) * omega ) / sin) + w * (MathF.Sin( t * omega ) / sin);
	}

	// --- setbacks -------------------------------------------------------------------------------

	/// <summary>A fillet setback may not exceed this multiple of its radius. r/tan(φ/2) runs away
	/// as an edge flattens out, and past here the "corner" being rounded is a gentle bend whose
	/// fillet would eat the faces either side of it.</summary>
	const float MaxSetback = 12f;

	/// <summary>
	/// How far back from each selected edge to cut.
	///
	/// A chamfer's distance is the setback outright. A fillet's radius is not: the tangent points
	/// of a circle of radius r against two faces opening at φ sit r/tan(φ/2) back from the edge.
	/// φ comes from the two normals — acos(-dot) — and needs no convexity test, because a 90° edge
	/// and a 270° edge set back by the same amount and only the arc's centre differs.
	/// </summary>
	static Dictionary<EdgeKey, float> Setbacks(
		HashSet<EdgeKey> selected, Dictionary<EdgeKey, List<int>> edgeFaces, Vec3[] faceNormals,
		float size, bool rounded, out int clamped )
	{
		var setbacks = new Dictionary<EdgeKey, float>( selected.Count );
		clamped = 0;

		foreach ( var key in selected )
		{
			if ( !rounded )
			{
				setbacks[key] = size;
				continue;
			}

			var faces = edgeFaces[key];

			if ( faces.Count != 2 )
				continue;

			var cos = Math.Clamp( -Vec3.Dot( faceNormals[faces[0]], faceNormals[faces[1]] ), -1f, 1f );
			var half = MathF.Acos( cos ) * 0.5f;
			var tan = MathF.Tan( half );

			if ( tan < 1f / MaxSetback )
			{
				clamped++;
				setbacks[key] = size * MaxSetback;
			}
			else
			{
				setbacks[key] = size / tan;
			}
		}

		return setbacks;
	}

	/// <summary>Zero for an edge that was never selected, which CutCorner reads as "this line did
	/// not move".</summary>
	static float Setback( Dictionary<EdgeKey, float> setbacks, EdgeKey key ) =>
		setbacks.TryGetValue( key, out var width ) ? width : 0f;

	// --- unchanged from the flat-chamfer original -----------------------------------------------

	static PolyMesh RemoveUnusedVertices( PolyMesh mesh )
	{
		var remap = new int[mesh.Positions.Count];
		Array.Fill( remap, -1 );

		var positions = new List<Vec3>();
		var weights = mesh.Skin is not null ? new List<BoneWeight[]>() : null;

		foreach ( var f in mesh.Faces )
		{
			foreach ( var i in f.Indices )
			{
				if ( remap[i] < 0 )
				{
					remap[i] = positions.Count;
					positions.Add( mesh.Positions[i] );
					weights?.Add( mesh.Skin[i] );
				}
			}
		}

		var result = new PolyMesh { Positions = positions, Skin = weights is null ? null : new SkinWeights { Vertices = weights } };

		foreach ( var f in mesh.Faces )
			result.AddFace( f.Indices.Select( i => remap[i] ).ToArray(), (Vec2[])f.UVs.Clone(), f.Material );

		return result;
	}

	static HashSet<EdgeKey> SelectEdges(
		PolyMesh mesh, Dictionary<EdgeKey, List<int>> edgeFaces, Vec3[] faceNormals, float angleThresholdDegrees )
	{
		var cosThreshold = MathF.Cos( angleThresholdDegrees * MathF.PI / 180f );
		var selected = new HashSet<EdgeKey>();

		foreach ( var (key, faces) in edgeFaces )
		{
			if ( faces.Count != 2 )
				continue; // boundary or non-manifold — never selected.

			var dot = Vec3.Dot( faceNormals[faces[0]], faceNormals[faces[1]] );

			if ( dot < cosThreshold )
				selected.Add( key );
		}

		return selected;
	}

	/// <summary>
	/// The new position for one face's corner at `v`, or null if neither incident edge was cut —
	/// meaning the corner is untouched and the caller should keep using `v` itself.
	///
	/// Each cut edge slides its supporting line inward, within the face's own plane, by that edge's
	/// own setback; the corner becomes the intersection of its two (possibly-slid) boundary lines.
	/// </summary>
	static Vec3? CutCorner( Vec3 prev, Vec3 v, Vec3 next, Vec3 faceNormal, float prevWidth, float nextWidth, ref int squared )
	{
		if ( prevWidth <= 0f && nextWidth <= 0f )
			return null;

		// Direction as the FACE traverses each edge — (prev,v) ending at v, (v,next) starting at
		// v — not "away from v". Cross(normal, direction) then points consistently inward for
		// every edge of a CCW polygon; get the sign backwards and every cut face turns inside
		// out while still looking fine in wireframe, the exact trap CatmullClark's own notes warn
		// about elsewhere in this kernel.
		var dirPrev = (v - prev).Normal;
		var dirNext = (next - v).Normal;

		var p1 = v;
		var d1 = dirPrev;

		if ( prevWidth > 0f )
			p1 += Vec3.Cross( faceNormal, dirPrev ).Normal * prevWidth;

		var p2 = v;
		var d2 = dirNext;

		if ( nextWidth > 0f )
			p2 += Vec3.Cross( faceNormal, dirNext ).Normal * nextWidth;

		// WHERE THE CORNER GOES WHEN THE TWO LINES WILL NOT INTERSECT USEFULLY.
		//
		// At a straight corner the two boundary lines are the same line, slid inward by the same
		// width in the same direction, so their "intersection" is undefined while the corner
		// itself is not: it is just the original vertex moved inward by width. That is this
		// fallback, and it is the right answer for the clamp below too — both are the case where
		// the intersection has stopped meaning anything.
		//
		// Taken from whichever edge was actually cut; when both are, they agree to within the
		// angle that made the intersection useless in the first place.
		var width = prevWidth > 0f ? prevWidth : nextWidth;

		var inward = prevWidth > 0f
			? Vec3.Cross( faceNormal, dirPrev ).Normal
			: Vec3.Cross( faceNormal, dirNext ).Normal;

		var fallback = v + inward * width;

		if ( IntersectCoplanarLines( p1, d1, p2, d2, faceNormal ) is not { } hit )
			return fallback;

		// A LOCAL CUT, SO A CORNER THAT TRAVELS MILES IS THE DEGENERATE CASE.
		//
		// The offset point sits at roughly width/sin(turn) from the vertex, so a corner that is
		// nearly straight throws it arbitrarily far. Ear clipping a thin annulus produces exactly
		// that: collinear corners (turn 180°, sin 1.5e-5) that put the point 15000 units away on a
		// model 20 units across. Those vertices are finite and the mesh still validates as closed
		// and manifold, which is why only a render ever showed it — the model collapses to a
		// speck because the view has to fit a stray vertex a thousand diameters out.
		//
		// Every honest cut lands within a few multiples of width: a square corner is about 1.4x,
		// and even a 5° needle only reaches ~11x. Past this it is degeneracy, not sharpness.
		if ( (hit - v).Length > width * MaxCornerOffset )
		{
			squared++;
			return fallback;
		}

		return hit;
	}

	/// <summary>How far a cut corner may travel from its original vertex, as a multiple of the
	/// setback. See CutCorner for why a cap is needed at all and why this value is generous.
	/// </summary>
	const float MaxCornerOffset = 20f;

	/// <summary>
	/// Where two lines meet, given they both lie in the plane with this normal. Null if they are
	/// (near) parallel — a straight 180° corner, which ear clipping a thin ring produces routinely.
	///
	/// `denom` IS sin(angle between the two directions): d1, d2 and planeNormal are all unit
	/// vectors, so the triple product reduces to it exactly. That is worth stating because the
	/// epsilon is otherwise impossible to reason about — the previous 1e-9 read as a floating
	/// point guard but actually meant "only reject corners straighter than 6e-8 degrees", which no
	/// mesh ever is, so it never once fired. CutCorner's clamp is what makes the result robust;
	/// this threshold only avoids handing it a division that has already lost all its precision.
	/// </summary>
	static Vec3? IntersectCoplanarLines( Vec3 p1, Vec3 d1, Vec3 p2, Vec3 d2, Vec3 planeNormal )
	{
		var denom = Vec3.Dot( Vec3.Cross( d1, d2 ), planeNormal );

		if ( MathF.Abs( denom ) < 1e-6f )
			return null;

		var w = p2 - p1;
		var t = Vec3.Dot( Vec3.Cross( w, d2 ), planeNormal ) / denom;

		return p1 + d1 * t;
	}

	/// <summary>
	/// The faces touching `v`, in the cyclic order they actually wind around it — found by hopping
	/// from each face across the edge it shares with its neighbour, using the SAME edge every face
	/// calls "next" at this vertex. Returns null for a boundary or non-manifold vertex, where the
	/// faces around v do not form one closed loop.
	/// </summary>
	static List<int> WalkFacesAroundVertex(
		int v, List<int> facesAtV, Dictionary<EdgeKey, List<int>> edgeFaces,
		Dictionary<(int Face, int Vertex), EdgeKey> nextEdgeAtVertex )
	{
		if ( facesAtV.Count < 2 )
			return null;

		var order = new List<int>( facesAtV.Count );
		var current = facesAtV[0];

		for ( var step = 0; step < facesAtV.Count; step++ )
		{
			order.Add( current );

			var edge = nextEdgeAtVertex[(current, v)];
			var onEdge = edgeFaces[edge];

			if ( onEdge.Count != 2 )
				return null; // this vertex touches a boundary edge.

			current = onEdge[0] == current ? onEdge[1] : onEdge[0];
		}

		return current == order[0] && order.Count == facesAtV.Count ? order : null;
	}
}