mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-11 21:24:15 -04:00
feat(cutting): add native fallback contour entries
AutomaticEntryCandidatesWithFallbacks completes the uncapped catalogue with tier-3 fallbacks after the preferred points: native arc midpoints, the eight compass points of a whole circle (a pure-circle contour is now usable instead of refusing), near-convex-corner points on each incident straight edge back from the corner by 2 x the applicable lead-in length using the emitter's own SelectLeadIn semantics (non-length lead-in styles contribute 0 and the fallback is omitted, never approximated), and the exact target-facing closest point toward the caller's look-ahead. Every fallback runs through the same reflex/cusp exclusion — a raw closest point or an inset landing on a forbidden inside corner is dropped — and the same geometric duplicate merge; short edges omit an out-of-segment inset by projection parameter instead of extrapolating. No ranking and no lead-safety verdict yet.
This commit is contained in:
1 parent
63f2a4b555
commit
4e60cebf68
3 files changed
+422
-43
No files matched your search
@@ -15,6 +15,24 @@ internal enum AutomaticEntryKind
|
||||
|
||||
/// <summary>A tangent line/arc joint. A collinear line/line split is not a joint and never appears.</summary>
|
||||
TangentJoint = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Tier 3 (fallback): a point on a straight edge meeting a convex corner, back from the
|
||||
/// corner by about twice the applicable lead-in length, strictly inside the edge.
|
||||
/// </summary>
|
||||
NearCorner = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Tier 3 (fallback): the exact native closest point facing the caller's look-ahead
|
||||
/// position (pass the arrival there when there is no next cut). Never a reflex/cusp vertex.
|
||||
/// </summary>
|
||||
TargetFacing = 4,
|
||||
|
||||
/// <summary>Tier 3 (fallback): the native midpoint of an arc entity.</summary>
|
||||
ArcMidpoint = 5,
|
||||
|
||||
/// <summary>Tier 3 (fallback): one of the eight compass points of a whole circle.</summary>
|
||||
CircleCompass = 6,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -162,63 +162,153 @@ public sealed class PreparedContours
|
||||
/// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear
|
||||
/// line/line splits, circles and interior points never appear; each geometric point is
|
||||
/// reported once, keeping the most preferred kind. A pure-arc contour can have no
|
||||
/// preferred point at all — the S04 fallback catalogue supplies those. Manual entry
|
||||
/// through <see cref="Entry"/> / <see cref="ClosestEntry"/> is unaffected.
|
||||
/// preferred point at all — <see cref="AutomaticEntryCandidatesWithFallbacks"/> supplies
|
||||
/// those. Manual entry through <see cref="Entry"/> / <see cref="ClosestEntry"/> is
|
||||
/// unaffected.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidates(int contourOrdinal, CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle)
|
||||
if (IsSingleCircle(shape))
|
||||
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
|
||||
if (shape.Entities.Count < 2)
|
||||
throw new ArgumentException("Contour has no vertex to classify.");
|
||||
return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
|
||||
}
|
||||
|
||||
// Collect EVERY eligible candidate first, then deduplicate geometrically; at equal
|
||||
// points the most preferred kind wins regardless of which entity supplied it.
|
||||
var found = new List<ContourEntryCandidate>();
|
||||
/// <summary>
|
||||
/// The complete uncapped automatic start catalogue: the preferred points of
|
||||
/// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc
|
||||
/// midpoints, near-convex-corner points on straight edges, the eight compass points of a
|
||||
/// whole circle, and the exact target-facing closest point toward
|
||||
/// <paramref name="lookAhead"/> (pass the arrival point there when there is no next cut).
|
||||
/// A pure-circle contour therefore yields compass points instead of refusing. Every
|
||||
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
|
||||
/// preferred tier; fallbacks never replace a preferred point at the same geometry.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
||||
int contourOrdinal,
|
||||
Vector? lookAhead = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
var all = IsSingleCircle(shape)
|
||||
? new List<ContourEntryCandidate>()
|
||||
: PreferredCandidates(shape, contourOrdinal, token);
|
||||
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
|
||||
return MergeByGeometry(all);
|
||||
}
|
||||
|
||||
private static bool IsSingleCircle(Shape shape) =>
|
||||
shape.Entities.Count == 1 && shape.Entities[0] is Circle;
|
||||
|
||||
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
|
||||
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
|
||||
=> CataloguePoints(shape, token)
|
||||
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Tier-3 fallbacks for one contour, each already run through the reflex/cusp exclusion:
|
||||
/// native arc midpoints, the eight compass points of whole circles, near-convex-corner
|
||||
/// insets on straight edges, and the exact target-facing closest point toward
|
||||
/// <paramref name="lookAhead"/>. No ranking and no lead-safety verdict here.
|
||||
/// </summary>
|
||||
private List<ContourEntryCandidate> FallbackCandidates(
|
||||
Shape shape,
|
||||
int contourOrdinal,
|
||||
Vector? lookAhead,
|
||||
CancellationToken token)
|
||||
{
|
||||
var fallbacks = new List<ContourEntryCandidate>();
|
||||
var lead = ApplicableLeadInLength(contourOrdinal);
|
||||
var count = shape.Entities.Count;
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var entity = shape.Entities[i];
|
||||
|
||||
// Vertex reached by travelling along entity i (its end point), reported under
|
||||
// entity i. The closed contour guarantees every vertex appears exactly once this
|
||||
// way; each is classified from the contour's own winding.
|
||||
var vertex = End(entity);
|
||||
if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner))
|
||||
switch (shape.Entities[i])
|
||||
{
|
||||
switch (corner.Kind)
|
||||
{
|
||||
case ContourCuttingStrategy.CornerKind.Convex:
|
||||
Add(i, vertex, AutomaticEntryKind.ConvexCorner);
|
||||
break;
|
||||
case ContourCuttingStrategy.CornerKind.Smooth
|
||||
when entity is Line && Next(i) is Arc:
|
||||
// A line leaving into an arc: the tangent joint. The reverse travel
|
||||
// order classifies the same joint from the arc, matched below.
|
||||
Add(i, vertex, AutomaticEntryKind.TangentJoint);
|
||||
break;
|
||||
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
||||
// of a plain straight edge are not preferred automatic starts here.
|
||||
case ContourCuttingStrategy.CornerKind.Smooth
|
||||
when entity is Arc && Next(i) is Line:
|
||||
Add(i, vertex, AutomaticEntryKind.TangentJoint);
|
||||
break;
|
||||
}
|
||||
case Arc arc:
|
||||
// The native midpoint of an arc (exact native API, never tessellation).
|
||||
fallbacks.Add(Fallback(i, arc.MidPoint(), AutomaticEntryKind.ArcMidpoint));
|
||||
break;
|
||||
case Circle circle:
|
||||
// The legacy eight compass points, same native construction.
|
||||
for (var angle = 0; angle < 8; angle++)
|
||||
fallbacks.Add(Fallback(i, circle.Center + new Vector(
|
||||
System.Math.Cos(angle * System.Math.PI / 4),
|
||||
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius,
|
||||
AutomaticEntryKind.CircleCompass));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (entity is Line line)
|
||||
Add(i, line.MidPoint, AutomaticEntryKind.StraightMidpoint);
|
||||
// Near-convex-corner fallbacks: about twice the applicable lead-in length back from
|
||||
// each convex corner along each incident STRAIGHT edge, only when strictly inside
|
||||
// that edge. Short edges simply omit the point; it never extrapolates past an edge
|
||||
// endpoint and so never lands on the reflex/cusp vertex at the far end.
|
||||
if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle))
|
||||
{
|
||||
foreach (var corner in CataloguePoints(shape, token)
|
||||
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var cornerPoint = End(shape.Entities[corner.EntityOrdinal]);
|
||||
// Back INTO each incident edge from the corner: the edge ending at the
|
||||
// corner retreats against its own travel, the edge starting at the corner
|
||||
// advances along its own travel.
|
||||
AddInset(corner.EntityOrdinal, cornerPoint, inward: false);
|
||||
AddInset((corner.EntityOrdinal + 1) % count, cornerPoint, inward: true);
|
||||
}
|
||||
}
|
||||
|
||||
if (lookAhead != null)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
PostVerificationGeometry.Validate(lookAhead.Value);
|
||||
var facing = ClosestEntry(contourOrdinal, lookAhead.Value);
|
||||
// A raw closest point may land exactly on a reflex/cusp vertex; automatic
|
||||
// selection must never sneak a forbidden inside corner back in, so drop it.
|
||||
if (!IsForbiddenVertex(shape, facing.Point))
|
||||
fallbacks.Add(Fallback(facing.EntityOrdinal, facing.Point, AutomaticEntryKind.TargetFacing));
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return fallbacks;
|
||||
|
||||
// Deduplicate: at equal points the most preferred metadata wins, independent of
|
||||
// which entity supplied it; the winner's own ordinal and point are kept.
|
||||
void AddInset(int entityOrdinal, Vector corner, bool inward)
|
||||
{
|
||||
// `inward` selects along the edge's own travel from its start; without it the
|
||||
// point retreats against travel. Both ways move BACK INTO the edge from the
|
||||
// corner, which sits at the edge's end (inward=false) or start (inward=true).
|
||||
if (shape.Entities[entityOrdinal] is not Line line)
|
||||
return;
|
||||
var direction = (line.EndPoint - line.StartPoint).Normalize();
|
||||
var offset = direction * (2 * lead);
|
||||
var point = inward ? corner + offset : corner - offset;
|
||||
// Strictly inside the edge by projection parameter (distance alone loses the
|
||||
// sign when a short edge is overshoot): never the corner, never the far
|
||||
// endpoint (where a reflex vertex might sit). Short edges omit the point.
|
||||
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
|
||||
if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
|
||||
return;
|
||||
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
|
||||
}
|
||||
|
||||
ContourEntryCandidate Fallback(int entityOrdinal, Vector point, AutomaticEntryKind kind)
|
||||
=> new(Entry(contourOrdinal, entityOrdinal, point), kind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// At equal geometric points the most preferred kind wins, independent of which entity
|
||||
/// supplied it; the winner keeps its own ordinal and point. Result order: preference,
|
||||
/// then entity ordinal.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ContourEntryCandidate> MergeByGeometry(List<ContourEntryCandidate> candidates)
|
||||
{
|
||||
var byPoint = new Dictionary<(long, long), ContourEntryCandidate>();
|
||||
var order = new List<(long, long)>();
|
||||
foreach (var candidate in found)
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var key = candidate.GeometryKey;
|
||||
if (!byPoint.TryGetValue(key, out var existing))
|
||||
@@ -234,13 +324,98 @@ public sealed class PreparedContours
|
||||
.ThenBy(key => byPoint[key].Choice.EntityOrdinal)
|
||||
.Select(key => byPoint[key])
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="point"/> sits on a vertex of the contour that is reflex or
|
||||
/// cusp from either travel direction — a point automatic selection must never emit.
|
||||
/// </summary>
|
||||
private bool IsForbiddenVertex(Shape shape, Vector point)
|
||||
{
|
||||
for (var i = 0; i < shape.Entities.Count; i++)
|
||||
{
|
||||
if (shape.Entities[i] is Circle)
|
||||
continue; // A whole circle has no vertex.
|
||||
var vertex = End(shape.Entities[i]);
|
||||
if (vertex.DistanceTo(point) > PostVerificationGeometry.Epsilon)
|
||||
continue;
|
||||
if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, shape.Entities[i], out var corner)
|
||||
&& corner.Kind is ContourCuttingStrategy.CornerKind.Reflex or ContourCuttingStrategy.CornerKind.Cusp)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The applicable lead-in length for a contour following the emitter's own selection:
|
||||
/// the perimeter (last shape) is external, anything else internal; non-length lead-in
|
||||
/// styles contribute 0, which omits the near-corner fallback instead of approximating.
|
||||
/// </summary>
|
||||
private double ApplicableLeadInLength(int contourOrdinal)
|
||||
=> (contourOrdinal == PerimeterOrdinal
|
||||
? parameters.ExternalLeadIn
|
||||
: parameters.InternalLeadIn) switch
|
||||
{
|
||||
LineLeadIn line => line.Length,
|
||||
LineLineLeadIn lineLine => lineLine.Length1,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The preferred catalogue points for one contour: for each entity, its convex corner
|
||||
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
|
||||
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
|
||||
/// nothing; a single whole circle yields no points at all.
|
||||
/// </summary>
|
||||
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
|
||||
{
|
||||
var found = new List<(int, Vector, AutomaticEntryKind)>();
|
||||
var count = shape.Entities.Count;
|
||||
if (count == 1 && shape.Entities[0] is Circle)
|
||||
{
|
||||
// A whole circle has no corners or joints; only the fallback tier applies.
|
||||
return found;
|
||||
}
|
||||
|
||||
if (count < 2)
|
||||
throw new ArgumentException("Contour has no vertex to classify.");
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var entity = shape.Entities[i];
|
||||
|
||||
// Vertex reached by travelling along entity i (its end point), reported under
|
||||
// entity i. The closed contour guarantees every vertex appears exactly once
|
||||
// this way; each is classified from the contour's own winding.
|
||||
var vertex = End(entity);
|
||||
if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner))
|
||||
{
|
||||
switch (corner.Kind)
|
||||
{
|
||||
case ContourCuttingStrategy.CornerKind.Convex:
|
||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
|
||||
break;
|
||||
case ContourCuttingStrategy.CornerKind.Smooth
|
||||
when entity is Line && Next(i) is Arc:
|
||||
// A line leaving into an arc: the tangent joint. The reverse travel
|
||||
// order classifies the same joint from the arc, matched below.
|
||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||
break;
|
||||
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
||||
// of a plain straight edge are not preferred automatic starts here.
|
||||
case ContourCuttingStrategy.CornerKind.Smooth
|
||||
when entity is Arc && Next(i) is Line:
|
||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (entity is Line line)
|
||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
|
||||
}
|
||||
return found;
|
||||
|
||||
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
||||
|
||||
void Add(int entityOrdinal, Vector point, AutomaticEntryKind kind)
|
||||
{
|
||||
found.Add(new ContourEntryCandidate(Entry(contourOrdinal, entityOrdinal, point), kind));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
||||
|
||||
@@ -231,5 +231,191 @@ public class ContourEntryCandidateTests
|
||||
Assert.Equal(candidates.Count, candidates.Select(c => c.GeometryKey).Distinct().Count());
|
||||
}
|
||||
|
||||
// --- S04 fallback tier ------------------------------------------------------
|
||||
|
||||
private static IReadOnlyList<ContourEntryCandidate> WithFallbacks(
|
||||
Program program, Vector? lookAhead = null, string style = "line") =>
|
||||
PreparedContours.Capture(program, ExplicitContourTests.Parameters(style))
|
||||
.AutomaticEntryCandidatesWithFallbacks(0, lookAhead);
|
||||
|
||||
[Fact]
|
||||
public void Circle_FallbacksAreCompassPointsAndTargetFacing()
|
||||
{
|
||||
var p = new Program();
|
||||
p.MoveTo(5, 3);
|
||||
p.ArcTo(5, 3, 3, 3, RotationType.CCW); // end (5,3), center (3,3), radius 2
|
||||
|
||||
// The preferred catalogue still refuses a whole circle...
|
||||
Assert.Throws<ArgumentException>(() => Capture(p).AutomaticEntryCandidates(0));
|
||||
|
||||
// ...but the fallback catalogue makes it usable: the eight native compass points.
|
||||
var plain = WithFallbacks(p);
|
||||
Assert.Equal(8, plain.Count);
|
||||
Assert.All(plain, c => Assert.Equal(AutomaticEntryKind.CircleCompass, c.Kind));
|
||||
for (var angle = 0; angle < 8; angle++)
|
||||
{
|
||||
var expected = new Vector(3, 3) +
|
||||
new Vector(System.Math.Cos(angle * System.Math.PI / 4), System.Math.Sin(angle * System.Math.PI / 4)) * 2;
|
||||
Assert.Contains(plain, c => Distance(c.Choice.Point, expected) < 1e-9);
|
||||
}
|
||||
|
||||
// With a look-ahead the exact target-facing closest point joins the set. The
|
||||
// look-ahead is off-compass so the merge cannot deduplicate it away.
|
||||
var ahead = new Vector(4, -1);
|
||||
var facing = WithFallbacks(p, ahead);
|
||||
Assert.Equal(9, facing.Count);
|
||||
var target = Assert.Single(facing, c => c.Kind == AutomaticEntryKind.TargetFacing);
|
||||
var facingPoint = new Vector(3, 3) + (ahead - new Vector(3, 3)).Normalize() * 2;
|
||||
Assert.Equal(facingPoint.X, target.Choice.Point.X, 6);
|
||||
Assert.Equal(facingPoint.Y, target.Choice.Point.Y, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilletArcMidpoint_IsTier3_KeepsJointPreferred()
|
||||
{
|
||||
// Half-circle bump on the top edge: joints (0,10),(10,10); arc midpoint (5,15).
|
||||
var p = new Program();
|
||||
p.MoveTo(0, 0);
|
||||
p.LineTo(10, 0);
|
||||
p.LineTo(10, 10);
|
||||
p.ArcTo(new Vector(0, 10), new Vector(5, 10), RotationType.CCW);
|
||||
p.LineTo(0, 0);
|
||||
|
||||
// The preferred tier has no arc midpoint at all.
|
||||
Assert.DoesNotContain(Capture(p).AutomaticEntryCandidates(0),
|
||||
c => c.Kind == AutomaticEntryKind.ArcMidpoint);
|
||||
|
||||
var withFallbacks = WithFallbacks(p);
|
||||
var mid = Assert.Single(withFallbacks, c => c.Kind == AutomaticEntryKind.ArcMidpoint);
|
||||
Assert.Equal(5, mid.Choice.Point.X, 9);
|
||||
Assert.Equal(15, mid.Choice.Point.Y, 9);
|
||||
// Tier 3: strictly after every preferred kind in catalogue order.
|
||||
var lastPreferred = withFallbacks.Select(c => c.Kind).ToList()
|
||||
.FindLastIndex(k => k <= AutomaticEntryKind.TangentJoint);
|
||||
var midpointIndex = withFallbacks.ToList().IndexOf(mid);
|
||||
Assert.True(midpointIndex > lastPreferred);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongEdge_GetsNearCornerPointsAtTwoTimesLeadIn()
|
||||
{
|
||||
// 10x10 square, default LineLeadIn Length 0.3 -> inset 0.6 on each incident edge.
|
||||
var candidates = WithFallbacks(ExplicitContourTests.Square(false));
|
||||
var near = candidates.Where(c => c.Kind == AutomaticEntryKind.NearCorner).ToList();
|
||||
|
||||
Assert.Equal(8, near.Count); // 4 convex corners x 2 incident straight edges
|
||||
Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(0.6, 0)) < 1e-9);
|
||||
Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(9.4, 0)) < 1e-9);
|
||||
Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(10, 9.4)) < 1e-9);
|
||||
Assert.All(near, c => Assert.True(c.Kind > AutomaticEntryKind.TangentJoint));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortEdge_OmitsNearCornerInsteadOfExtrapolating()
|
||||
{
|
||||
// 10 x 0.4 rectangle: every convex corner's vertical edge (0.4) is shorter than
|
||||
// 2 x lead-in (0.6), so only the horizontal edges carry near-corner points.
|
||||
var p = ClosedContour(new[]
|
||||
{
|
||||
new Vector(0, 0), new Vector(10, 0), new Vector(10, 0.4), new Vector(0, 0.4),
|
||||
});
|
||||
var near = WithFallbacks(p).Where(c => c.Kind == AutomaticEntryKind.NearCorner).ToList();
|
||||
|
||||
Assert.Equal(4, near.Count);
|
||||
// Every point stays strictly inside a horizontal edge of the rectangle — never
|
||||
// extrapolated onto a vertical edge or past an endpoint.
|
||||
Assert.All(near, c =>
|
||||
{
|
||||
var onBottom = Distance(c.Choice.Point, new Vector(c.Choice.Point.X, 0)) < 1e-9
|
||||
&& c.Choice.Point.X > 0 && c.Choice.Point.X < 10;
|
||||
var onTop = Distance(c.Choice.Point, new Vector(c.Choice.Point.X, 0.4)) < 1e-9
|
||||
&& c.Choice.Point.X > 0 && c.Choice.Point.X < 10;
|
||||
Assert.True(onBottom || onTop);
|
||||
});
|
||||
Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(0.6, 0)) < 1e-9);
|
||||
Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(9.4, 0.4)) < 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsetExactlyTwoLeadInsFromAReflexCorner_IsOmitted()
|
||||
{
|
||||
// Boundary notch 0.6 deep — exactly 2 x lead-in. The inset from each convex
|
||||
// opening corner lands exactly on the reflex inner corner, so neither inner
|
||||
// corner may appear in the automatic catalogue at all.
|
||||
var p = ClosedContour(new[]
|
||||
{
|
||||
new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10),
|
||||
new Vector(0, 4.6), new Vector(0.6, 4.6), new Vector(0.6, 4), new Vector(0, 4),
|
||||
});
|
||||
var candidates = WithFallbacks(p);
|
||||
|
||||
Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, new Vector(0.6, 4.6)) < 1e-9);
|
||||
Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, new Vector(0.6, 4)) < 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetExactlyAtReflexVertex_IsNotAnAutomaticStart()
|
||||
{
|
||||
// L-outline; (5,5) is reflex for its own travel. The raw closest point from that
|
||||
// position lands exactly on the reflex vertex and must be dropped.
|
||||
var p = ClosedContour(new[]
|
||||
{
|
||||
new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(5, 10),
|
||||
new Vector(5, 5), new Vector(0, 5),
|
||||
});
|
||||
|
||||
var reflex = new Vector(5, 5);
|
||||
var candidates = WithFallbacks(p, reflex);
|
||||
|
||||
// The forbidden closest point is dropped entirely...
|
||||
Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, reflex) < 1e-9);
|
||||
Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.TargetFacing);
|
||||
// ...and the catalogue stays usable through the other fallbacks.
|
||||
Assert.Contains(candidates, c => c.Kind == AutomaticEntryKind.NearCorner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetFacingAtSharedPoint_KeepsThePreferredCorner()
|
||||
{
|
||||
// Look-ahead straight at corner (10,10): closest point IS the convex corner, so
|
||||
// the geometric merge keeps the more preferred kind at that single point.
|
||||
var candidates = WithFallbacks(ExplicitContourTests.Square(false), new Vector(10, 10));
|
||||
|
||||
var atCorner = candidates.Where(c => Distance(c.Choice.Point, new Vector(10, 10)) < 1e-9).ToList();
|
||||
var corner = Assert.Single(atCorner);
|
||||
Assert.Equal(AutomaticEntryKind.ConvexCorner, corner.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallbackCatalogue_IsNonEmptyForEveryShape_AndHonoursCancellation()
|
||||
{
|
||||
// All-rounded contour (bump square) stays usable; cancellation is honoured.
|
||||
var bump = new Program();
|
||||
bump.MoveTo(0, 0);
|
||||
bump.LineTo(10, 0);
|
||||
bump.LineTo(10, 10);
|
||||
bump.ArcTo(new Vector(0, 10), new Vector(5, 10), RotationType.CCW);
|
||||
bump.LineTo(0, 0);
|
||||
Assert.NotEmpty(WithFallbacks(bump));
|
||||
|
||||
var prepared = PreparedContours.Capture(ExplicitContourTests.Square(false), ExplicitContourTests.Parameters());
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
cancelled.Cancel();
|
||||
Assert.Throws<OperationCanceledException>(() =>
|
||||
prepared.AutomaticEntryCandidatesWithFallbacks(0, new Vector(1, 1), cancelled.Token));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("arc")]
|
||||
[InlineData("linearc")]
|
||||
[InlineData("clean")]
|
||||
public void NonLengthLeadIns_OmitNearCornerInsteadOfApproximating(string style)
|
||||
{
|
||||
// Only lead-in styles with a straight length feed the inset; others contribute 0
|
||||
// and the near-corner fallback is omitted entirely — no invented setting.
|
||||
var candidates = WithFallbacks(ExplicitContourTests.Square(false), style: style);
|
||||
Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.NearCorner);
|
||||
}
|
||||
|
||||
private static double Distance(Vector a, Vector b) => a.DistanceTo(b);
|
||||
}
|
||||
Reference in new issue
Block a user