Editor/Effigy/LoopOffset.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// Move a closed 2D loop in or out by a fixed distance, measured from its EDGES.
///
/// This is what a draft angle needs: the far cap of a tapered extrude is the near cap offset by
/// distance × tan(angle), and every wall then leans by exactly that angle because both ends of it
/// are that far apart in plane.
///
/// FROM THE EDGES, NOT FROM THE VERTICES, and it is the same distinction shell is built around. Push
/// each vertex along its own bisector by d and the walls come out at the wrong angle everywhere two
/// edges do not meet at 90 degrees — a 60-degree corner ends up a different distance from its own
/// edges than a square one, so the taper varies around the profile and measures wrong while looking
/// plausible. Offsetting the EDGE LINES and intersecting the results is exact for any corner angle,
/// and it is the same reasoning PlaneOffset carries for three dimensions.
///
/// SELF-INTERSECTION IS NOT HANDLED, stated rather than discovered. Offset a loop inward by more
/// than its narrowest half-width and the result folds through itself — the classic case is a thin
/// tab that vanishes and comes back inside out. Detecting a fold in general is the same problem as a
/// 2D boolean; what this does instead is check three cheap things that between them catch every
/// practical case: the signed area keeps its sign, it has not collapsed to nothing, and no edge has
/// reversed direction. That third one is not redundant — see TryOffset for the symmetric profile
/// that passes the first two while being inside out. All three mean the same thing to a user, that
/// the taper is too steep for this profile at this distance, and saying so beats building a solid
/// that is wrong in a way nothing downstream will notice.
/// </summary>
public static class LoopOffset
{
/// <summary>
/// Offset a loop by <paramref name="distance"/>, positive being INWARD — toward the material,
/// whichever way the loop is wound.
///
/// The loop's winding is what makes that work, and it needs no branch: see Inward. An outer loop
/// shrinks and a hole in it widens, from one rule, which is what a draft angle does in reality —
/// the whole section gets smaller, so the metal between an outer wall and a hole thins from both
/// sides at once.
/// </summary>
public static bool TryOffset( IReadOnlyList<Vec2> loop, float distance, out List<Vec2> result, out string error )
{
result = null;
error = null;
if ( loop is null || loop.Count < 3 )
{
error = "a loop needs at least three points";
return false;
}
var area = SignedArea( loop );
if ( MathF.Abs( area ) < 1e-9f )
{
error = "the profile has no area";
return false;
}
// Zero is not a special case worth guarding — it falls out as a copy — but it is the common
// one, so it skips the work.
if ( MathF.Abs( distance ) < 1e-9f )
{
result = new List<Vec2>( loop );
return true;
}
var counterClockwise = area > 0f;
var offset = new List<Vec2>( loop.Count );
for ( var i = 0; i < loop.Count; i++ )
{
var previous = loop[(i - 1 + loop.Count) % loop.Count];
var current = loop[i];
var next = loop[(i + 1) % loop.Count];
// The two edges meeting at this corner, each slid inward by `distance` along its own
// normal. Intersecting the slid LINES is what makes the result exactly `distance` from
// both edges rather than approximately from either.
var intoA = Inward( current - previous ) * distance;
var intoB = Inward( next - current ) * distance;
if ( TryIntersect( previous + intoA, current + intoA, current + intoB, next + intoB, out var corner ) )
{
offset.Add( corner );
continue;
}
// Parallel edges - a straight-through vertex, or a doubled point. There is no
// intersection to take and none is needed: sliding the point along the shared normal is
// already exactly right.
offset.Add( current + intoA );
}
var newArea = SignedArea( offset );
// Turned inside out, or collapsed to nothing. Both are "too far", and both produce a solid
// that looks like geometry and measures like nonsense.
if ( newArea > 0f != counterClockwise || MathF.Abs( newArea ) < MathF.Abs( area ) * 1e-4f )
{
error = $"offsetting by {distance:0.###} collapses this profile — it is narrower than that somewhere";
return false;
}
// AND THE CHECK THE AREA CANNOT MAKE. Push a symmetric profile past its own centre and every
// vertex crosses to the far side — which in two dimensions is a rotation by half a turn, and
// rotations PRESERVE orientation. The signed area comes back the same sign and a healthy
// size, describing a solid that is inside out and measures fine. A square drafted at 60
// degrees over its own width sailed through the test above.
//
// An edge that has reversed direction is the local signature of that fold, and it cannot be
// hidden by symmetry.
for ( var i = 0; i < loop.Count; i++ )
{
var j = (i + 1) % loop.Count;
var before = loop[j] - loop[i];
var after = offset[j] - offset[i];
if ( Vec2.Dot( before, after ) > 0f )
continue;
error = $"offsetting by {distance:0.###} folds this profile through itself";
return false;
}
result = offset;
return true;
}
/// <summary>
/// The unit normal of an edge pointing into the material: always the left of travel.
///
/// NO WINDING BRANCH, and the first version had one, which made holes shrink instead of widen.
/// The winding already carries the answer. An outer loop runs counter-clockwise and its material
/// is inside it, so left of travel points inward. A hole runs clockwise and its material is
/// OUTSIDE it, so left of travel points outward — away from the void, into the metal. Same rule,
/// opposite result, exactly because the two are wound opposite ways. Branching on the winding
/// applies the correction twice and sends holes the wrong way.
/// </summary>
static Vec2 Inward( Vec2 edge )
{
var direction = edge.Normal;
return new Vec2( -direction.y, direction.x );
}
/// <summary>Where two infinite lines cross, each given by two points on it. False when they are
/// parallel to within a tolerance that scales with the segments, so a long nearly-straight
/// corner is treated as straight rather than throwing its intersection out to infinity.</summary>
static bool TryIntersect( Vec2 a0, Vec2 a1, Vec2 b0, Vec2 b1, out Vec2 point )
{
point = Vec2.Zero;
var da = a1 - a0;
var db = b1 - b0;
var denominator = Vec2.Cross( da, db );
// Relative rather than absolute: the cross product carries the product of both lengths, so a
// fixed epsilon calls large geometry parallel and small geometry crossing.
if ( MathF.Abs( denominator ) < 1e-7f * MathF.Sqrt( da.LengthSquared * db.LengthSquared ) )
return false;
point = a0 + da * (Vec2.Cross( b0 - a0, db ) / denominator);
return true;
}
static float SignedArea( IReadOnlyList<Vec2> loop )
{
var sum = 0f;
for ( var i = 0; i < loop.Count; i++ )
{
var a = loop[i];
var b = loop[(i + 1) % loop.Count];
sum += a.x * b.y - b.x * a.y;
}
return sum * 0.5f;
}
}