Editor/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 COPLANAR REGION, 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. So the region is collected first (every face of the
/// body lying in this plane and pointing the same way) and only the edges used ONCE within it
/// survive, which is precisely its silhouette. A hole through the face keeps its rim, because a
/// rim edge is used once too.
///
/// 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;
var normal = plane.Normal;
// How far the face sits from the sketch plane along the normal: zero when the sketch sits
// straight on it, the offset distance when it is raised off it. Measured from the resolved
// face's centroid, so the whole coplanar region shares one number and "is this vertex in
// the face's plane" is one comparison against it.
var depth = Vec3.Dot( mesh.FaceCentroid( mesh.Faces[faceIndex] ) - plane.Origin, normal );
// 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 );
bool InPlane( Vec3 p ) => MathF.Abs( Vec3.Dot( p - plane.Origin, normal ) - depth ) <= tolerance;
var coplanar = new List<Face>();
for ( var i = 0; i < mesh.Faces.Count; i++ )
{
var face = mesh.Faces[i];
if ( face.Count < 3 )
continue;
// Facing the same way, which matters on a thin part: the far side of a near-zero
// thickness sliver would otherwise join the region and cancel out every edge it shares,
// leaving an outline with nothing in it.
if ( Vec3.Dot( mesh.FaceNormal( face ), normal ) <= 0f )
continue;
var flat = true;
for ( var c = 0; c < face.Count && flat; c++ )
flat = InPlane( mesh.Positions[face.Indices[c]] );
if ( flat )
coplanar.Add( face );
}
// A face too warped to pass its own flatness test still deserves an outline — it is the one
// the user picked. Better a slightly-off polygon than a sketcher that shows nothing on the
// face it is sitting on and never says why.
if ( coplanar.Count == 0 )
coplanar.Add( mesh.Faces[faceIndex] );
var uses = new Dictionary<EdgeKey, int>();
foreach ( var face in coplanar )
{
for ( var c = 0; c < face.Count; c++ )
Count( uses, new EdgeKey( face.Indices[c], face.Indices[(c + 1) % face.Count] ) );
}
var mapped = new Dictionary<int, int>();
var taken = new HashSet<EdgeKey>();
// Walked in face order rather than over the dictionary, so the output is the same list every
// time it is built. Dictionary order is not promised, and an outline whose points renumber
// between two identical calls makes every index the viewport is holding meaningless.
foreach ( var face in coplanar )
{
for ( var c = 0; c < face.Count; c++ )
{
var from = face.Indices[c];
var to = face.Indices[(c + 1) % face.Count];
var key = new EdgeKey( from, to );
// Used twice within the region means an interior seam between two coplanar faces.
if ( uses[key] != 1 || !taken.Add( key ) )
continue;
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;
}
static void Count( Dictionary<EdgeKey, int> uses, EdgeKey key ) =>
uses[key] = uses.TryGetValue( key, out var n ) ? n + 1 : 1;
/// <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;
}
}