Effigy/Sketch/SketchReference.cs
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;
}
}