Editor/Effigy/Sketch/SketchReference.cs

Editor-side helper that builds a set of 2D reference geometry for a sketch from a mesh face. It projects mesh boundary vertices into the sketch plane, deduplicates nearby points, stores edges as index pairs, and provides utilities to copy those edges into the sketch as real lines.

File Access
using System;
using System.Collections.Generic;

namespace Effigy;

/// <summary>
/// Geometry the sketch does not own, projected into its plane so it can be seen and snapped to.
///
/// WHAT THIS IS FOR. A sketch on the face of an existing part is nearly always ABOUT that face —
/// a boss centred on it, a pocket set in from one of its corners, a rib running along one of its
/// edges. Until this existed the face went blank the moment the sketcher opened: the plane was
/// derived from it and then nothing about it was drawn or snappable, so lining a new rectangle up
/// with the edge directly underneath it was done by eye against the shaded solid. That is exactly
/// the kind of "close enough" that turns into a 0.03-unit sliver after an extrude.
///
/// EVERY OTHER CAD PACKAGE CALLS THIS PROJECTED REFERENCE GEOMETRY and makes you ask for it a
/// curve at a time — Onshape's Use tool, SolidWorks' Convert Entities. Effigy shows the whole
/// face's boundary automatically instead, because the face was CHOSEN as the plane a moment ago,
/// which is a much stronger statement of intent than clicking one edge of it.
///
/// IT IS NOT PART OF THE SKETCH. Nothing here is in Sketch.Points or Sketch.Curves, so it never
/// reaches ProfileFinder, never extrudes, and is never saved. It is rebuilt from the model every
/// time the sketch is opened, which is what keeps it honest when the face underneath changes —
/// the same reason FaceRef stores geometry rather than "Face6".
/// </summary>
public sealed class SketchReference
{
	/// <summary>Corners of the referenced geometry, in sketch-plane coordinates.</summary>
	public readonly List<Vec2> Points = new();

	/// <summary>Edges, as index pairs into <see cref="Points"/>.</summary>
	public readonly List<(int A, int B)> Edges = new();

	public bool IsEmpty => Points.Count == 0;

	/// <summary>An edge as the two positions it runs between. Bounds-checked to a zero-length
	/// segment rather than throwing: this is drawn and hit-tested every frame from an index that a
	/// rebuild underneath could have invalidated, and a viewport that throws once per frame is
	/// worse than one that briefly draws nothing.</summary>
	public (Vec2 A, Vec2 B) Segment( int index )
	{
		if ( index < 0 || index >= Edges.Count )
			return (Vec2.Zero, Vec2.Zero);

		var (a, b) = Edges[index];

		if ( a < 0 || a >= Points.Count || b < 0 || b >= Points.Count )
			return (Vec2.Zero, Vec2.Zero);

		return (Points[a], Points[b]);
	}

	/// <summary>
	/// Copy one reference edge into the sketch as a real line — Onshape's Use, one edge at a time.
	///
	/// WHY A COPY AND NOT A LIVE LINK. Onshape's projected curves stay attached to what they were
	/// taken from and move when it moves. That is the better behaviour and it is also a whole
	/// feature: the sketch would need a second class of curve that the user cannot drag or delete,
	/// that ProfileFinder unions in, and that is rebuilt rather than saved. A copy is what the tool
	/// does here, and it is honest about it — the line becomes ordinary sketch geometry, yours to
	/// trim and drag, and it does NOT follow the face afterwards.
	///
	/// Points are reused through SketchSnapper.PointIndex, so an edge copied in welds onto whatever
	/// is already at its ends rather than laying a second point on top of the first. That is what
	/// makes "use all four edges, then draw a line across" close into two regions instead of into
	/// nothing at all.
	/// </summary>
	/// <returns>The line added, or null when the edge is degenerate or the sketch already has it.</returns>
	public SketchLine UseEdge( Sketch sketch, int edgeIndex )
	{
		if ( sketch is null || edgeIndex < 0 || edgeIndex >= Edges.Count )
			return null;

		var (a, b) = Segment( edgeIndex );

		var start = SketchSnapper.PointIndex( sketch, a );
		var end = SketchSnapper.PointIndex( sketch, b );

		// A zero-length line is not geometry - ProfileFinder links it into the adjacency map twice
		// at one point and calls the sketch branching. The line tool has the same guard.
		if ( start == end )
			return null;

		// USING THE SAME EDGE TWICE MUST NOT LAY A SECOND LINE ON THE FIRST. Two curves between the
		// same pair of points is exactly the branching case ProfileFinder refuses, so clicking an
		// edge you already used would quietly destroy the profile you were building.
		foreach ( var curve in sketch.Curves )
		{
			var (from, to) = curve.Endpoints;

			if ( (from == start && to == end) || (from == end && to == start) )
				return null;
		}

		return sketch.Add( new SketchLine( start, end ) );
	}

	/// <summary>Copy every reference edge in, which is the common case: the whole face outline, so a
	/// single line drawn across it closes two regions. Returns how many were added — edges already
	/// in the sketch are skipped, so running it twice is harmless.</summary>
	public int UseAll( Sketch sketch )
	{
		var added = 0;

		for ( var i = 0; i < Edges.Count; i++ )
		{
			if ( UseEdge( sketch, i ) is not null )
				added++;
		}

		return added;
	}

	/// <summary>
	/// The boundary of the face a sketch is attached to, in that sketch's plane.
	///
	/// THE BOUNDARY OF THE SURFACE, NOT OF ONE n-GON. A face that has been through a boolean is
	/// usually several faces sharing a plane, and outlining each of them separately draws the seams
	/// where they were split — lines that are not edges of anything, sitting in the middle of what
	/// looks like one flat surface, and snapping to them is snapping to an artefact of how the mesh
	/// happens to be cut up. FaceSurface is what decides where the surface stops, and it is the
	/// same answer the viewport highlights and the edge picker offers: three things that used to
	/// each work it out for themselves and disagree on screen at the same time.
	///
	/// PROJECTED, NOT INTERSECTED, so a sketch with an offset still gets the face's outline —
	/// directly below where it will be drawn, which is what makes the offset useful for a boss
	/// standing clear of the surface it grows from.
	/// </summary>
	public static SketchReference FromFace( IEnumerable<Body> bodies, FaceRef reference, SketchPlane plane )
	{
		var result = new SketchReference();

		if ( plane is null || !FacePlane.TryResolveFace( bodies, reference, out var body, out var faceIndex ) )
			return result;

		var mesh = body.Mesh;

		// Scaled to the part, for the same reason every other tolerance in the sketcher is: a
		// constant that is generous on a 100-unit block silently merges every vertex of a 0.1-unit
		// one. See SketchSnapper's header for what fixed tolerances did to this sketcher.
		var tolerance = MathF.Max( mesh.BoundsDiagonal * 1e-4f, 1e-5f );

		var surface = FaceSurface.FromFace( mesh, faceIndex );
		var mapped = new Dictionary<int, int>();

		foreach ( var (from, to) in surface.Boundary )
		{
			var a = Map( result, mapped, mesh, plane, tolerance, from );
			var b = Map( result, mapped, mesh, plane, tolerance, to );

			if ( a != b )
				result.Edges.Add( (a, b) );
		}

		return result;
	}

	/// <summary>Mesh vertex to reference point, projected and de-duplicated. Two mesh vertices at
	/// the same position — which a boolean leaves behind routinely — must become ONE snap target,
	/// or the corner of the face has two dots on it and the cursor picks between them at
	/// random.</summary>
	static int Map( SketchReference result, Dictionary<int, int> mapped, PolyMesh mesh,
		SketchPlane plane, float tolerance, int vertex )
	{
		if ( mapped.TryGetValue( vertex, out var existing ) )
			return existing;

		var p = plane.ToPlane( mesh.Positions[vertex] );

		for ( var i = 0; i < result.Points.Count; i++ )
		{
			if ( (result.Points[i] - p).LengthSquared > tolerance * tolerance )
				continue;

			mapped[vertex] = i;
			return i;
		}

		result.Points.Add( p );
		mapped[vertex] = result.Points.Count - 1;

		return result.Points.Count - 1;
	}
}