fix(geometry): harden the arc-preserving per-entity offset
GetOffsetPerimeterEntities/GetOffsetPartEntities feed directional-distance loops (FillLinear, Compactor, RotationSlideStrategy) that handle arcs natively. Switching them to Clipper line output (plan option B) made OpenNest.Tests run 48s -> 8m19s, Fill tests ~3x slower, and broke 20 exact-fit tests through tessellation and conservative padding, so they keep the per-entity offset (option A), hardened: - Arc, Circle and Line offsets are now side-symmetric. Right on a CCW arc shrank instead of growing, Right on a CW circle grew, and Right on a line offset to the left and reversed it. Only Left was used on hot paths, so this was latent (SimplifierViewer drew both tolerance bands on one side). - Shape.OffsetEntity closes every gap between consecutive offset pieces: convex non-tangent line/arc corners get a round join about the original corner, lines across a collapsed fillet are mitered, and any other gap (concave arc corner, collapsed entity) is bridged with a line. Before, only line-line corners were joined, so a vertex could slip through. - Zero-area spikes are left in place and documented: they lie inside the offset envelope, which is harmless for directional distance. - OffsetOutward/OffsetInward become internal; PartGeometry is their only caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -443,19 +443,23 @@ namespace OpenNest.Geometry
|
|||||||
boundingBox.Width = maxY - minY;
|
boundingBox.Width = maxY - minY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offsets the arc to the given side of its travel direction. The center lies to
|
||||||
|
/// the left of a CCW arc and to the right of a CW (reversed) one, so the arc grows
|
||||||
|
/// on the other side and shrinks toward its center. Returns null when it shrinks
|
||||||
|
/// to nothing.
|
||||||
|
/// </summary>
|
||||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||||
{
|
{
|
||||||
if (side == OffsetSide.Left && reversed)
|
var grows = (side == OffsetSide.Left) == reversed;
|
||||||
{
|
|
||||||
return new Arc(center, radius + distance, startAngle, endAngle, reversed);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (distance >= radius)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return new Arc(center, radius - distance, startAngle, endAngle, reversed);
|
if (grows)
|
||||||
}
|
return new Arc(center, radius + distance, startAngle, endAngle, reversed);
|
||||||
|
|
||||||
|
if (distance >= radius)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new Arc(center, radius - distance, startAngle, endAngle, reversed);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Entity OffsetEntity(double distance, Vector pt)
|
public override Entity OffsetEntity(double distance, Vector pt)
|
||||||
|
|||||||
@@ -273,7 +273,10 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||||
{
|
{
|
||||||
if (side == OffsetSide.Left && Rotation == RotationType.CCW)
|
// The center lies to the left of a CCW circle and to the right of a CW one.
|
||||||
|
var shrinks = (side == OffsetSide.Left) == (Rotation == RotationType.CCW);
|
||||||
|
|
||||||
|
if (shrinks)
|
||||||
{
|
{
|
||||||
return Radius <= distance
|
return Radius <= distance
|
||||||
? null
|
? null
|
||||||
@@ -281,7 +284,7 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return new Circle(center, Radius + distance) { Layer = Layer };
|
return new Circle(center, Radius + distance) { Layer = Layer, Rotation = Rotation };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -398,11 +398,9 @@ namespace OpenNest.Geometry
|
|||||||
var x = System.Math.Cos(angle) * distance;
|
var x = System.Math.Cos(angle) * distance;
|
||||||
var y = System.Math.Sin(angle) * distance;
|
var y = System.Math.Sin(angle) * distance;
|
||||||
|
|
||||||
var pt = new Vector(x, y);
|
var pt = side == OffsetSide.Left ? new Vector(x, y) : new Vector(-x, -y);
|
||||||
|
|
||||||
return side == OffsetSide.Left
|
return new Line(StartPoint + pt, EndPoint + pt);
|
||||||
? new Line(StartPoint + pt, EndPoint + pt)
|
|
||||||
: new Line(EndPoint + pt, StartPoint + pt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Entity OffsetEntity(double distance, Vector pt)
|
public override Entity OffsetEntity(double distance, Vector pt)
|
||||||
|
|||||||
+184
-59
@@ -463,80 +463,60 @@ namespace OpenNest.Geometry
|
|||||||
boundingBox = Entities.Select(geo => geo.BoundingBox).ToList().GetBoundingBox();
|
boundingBox = Entities.Select(geo => geo.BoundingBox).ToList().GetBoundingBox();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offsets each perimeter entity to the given side and joins the pieces into a
|
||||||
|
/// closed chain: line-line corners get a round join (convex) or a miter (concave),
|
||||||
|
/// other convex corners get a round join, and any remaining gap (a concave corner
|
||||||
|
/// involving an arc, or an entity that collapsed under the offset) is bridged
|
||||||
|
/// with a line. Cutouts are offset the same way.
|
||||||
|
/// <para>
|
||||||
|
/// Where a feature is narrower than twice the distance, the result keeps zero-area
|
||||||
|
/// spikes and inverted loops. They lie inside the true offset envelope, so they are
|
||||||
|
/// harmless to directional-distance queries, which only need a closed boundary
|
||||||
|
/// that never falls inside the envelope. Use <see cref="ClipperBridge"/> when a
|
||||||
|
/// clean region is needed.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||||
{
|
{
|
||||||
var offsetShape = new Shape();
|
var offsetShape = new Shape();
|
||||||
var definedShape = new ShapeProfile(this);
|
var definedShape = new ShapeProfile(this);
|
||||||
|
|
||||||
Entity firstEntity = null;
|
var pieces = new List<OffsetPiece>();
|
||||||
Entity firstOffsetEntity = null;
|
var collapsed = false;
|
||||||
Entity lastEntity = null;
|
|
||||||
Entity lastOffsetEntity = null;
|
|
||||||
|
|
||||||
foreach (var entity in definedShape.Perimeter.Entities)
|
foreach (var entity in definedShape.Perimeter.Entities)
|
||||||
{
|
{
|
||||||
var offsetEntity = entity.OffsetEntity(distance, side);
|
var offsetEntity = entity.OffsetEntity(distance, side);
|
||||||
|
|
||||||
if (offsetEntity == null)
|
if (offsetEntity == null)
|
||||||
|
{
|
||||||
|
collapsed = true;
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (firstEntity == null)
|
|
||||||
{
|
|
||||||
firstEntity = entity;
|
|
||||||
firstOffsetEntity = offsetEntity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (entity.Type)
|
pieces.Add(new OffsetPiece(entity, offsetEntity, collapsed));
|
||||||
{
|
collapsed = false;
|
||||||
case EntityType.Line:
|
|
||||||
{
|
|
||||||
var line = (Line)entity;
|
|
||||||
var offsetLine = (Line)offsetEntity;
|
|
||||||
|
|
||||||
if (lastOffsetEntity != null && lastOffsetEntity.Type == EntityType.Line)
|
|
||||||
{
|
|
||||||
JoinOffsetLines(
|
|
||||||
(Line)lastEntity,
|
|
||||||
(Line)lastOffsetEntity,
|
|
||||||
line,
|
|
||||||
offsetLine,
|
|
||||||
distance,
|
|
||||||
side,
|
|
||||||
offsetShape
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
offsetShape.Entities.Add(offsetLine);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
offsetShape.Entities.Add(offsetEntity);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastOffsetEntity = offsetEntity;
|
|
||||||
lastEntity = entity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the shape: join last offset entity back to first
|
// Entities that collapsed at the end of the loop sit before the first piece.
|
||||||
if (
|
if (collapsed && pieces.Count > 0)
|
||||||
lastOffsetEntity != null
|
pieces[0] = pieces[0] with { CollapsedBefore = true };
|
||||||
&& firstOffsetEntity != null
|
|
||||||
&& lastOffsetEntity != firstOffsetEntity
|
for (var i = 0; i < pieces.Count; i++)
|
||||||
&& lastOffsetEntity.Type == EntityType.Line
|
|
||||||
&& firstOffsetEntity.Type == EntityType.Line
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
JoinOffsetLines(
|
offsetShape.Entities.Add(pieces[i].Offset);
|
||||||
(Line)lastEntity,
|
|
||||||
(Line)lastOffsetEntity,
|
if (pieces.Count > 1)
|
||||||
(Line)firstEntity,
|
{
|
||||||
(Line)firstOffsetEntity,
|
JoinOffsetPieces(
|
||||||
distance,
|
pieces[i],
|
||||||
side,
|
pieces[(i + 1) % pieces.Count],
|
||||||
offsetShape
|
distance,
|
||||||
);
|
side,
|
||||||
|
offsetShape
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var cutout in definedShape.Cutouts)
|
foreach (var cutout in definedShape.Cutouts)
|
||||||
@@ -547,6 +527,151 @@ namespace OpenNest.Geometry
|
|||||||
return offsetShape;
|
return offsetShape;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly record struct OffsetPiece(
|
||||||
|
Entity Source,
|
||||||
|
Entity Offset,
|
||||||
|
bool CollapsedBefore
|
||||||
|
);
|
||||||
|
|
||||||
|
private static void JoinOffsetPieces(
|
||||||
|
OffsetPiece last,
|
||||||
|
OffsetPiece next,
|
||||||
|
double distance,
|
||||||
|
OffsetSide side,
|
||||||
|
Shape offsetShape
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Lines meeting across a collapsed fillet are concave, so a miter trims both at
|
||||||
|
// their intersection. Parallel ones (a round-bottomed slot) fall through to
|
||||||
|
// the bridge below.
|
||||||
|
if (
|
||||||
|
next.CollapsedBefore
|
||||||
|
&& last.Offset is Line lastOffsetLine
|
||||||
|
&& next.Offset is Line nextOffsetLine
|
||||||
|
&& Intersect.IntersectsUnbounded(nextOffsetLine, lastOffsetLine, out var miter)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
lastOffsetLine.EndPoint = miter;
|
||||||
|
nextOffsetLine.StartPoint = miter;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next.CollapsedBefore && last.Source is Line lastLine && next.Source is Line nextLine)
|
||||||
|
{
|
||||||
|
JoinOffsetLines(
|
||||||
|
lastLine,
|
||||||
|
(Line)last.Offset,
|
||||||
|
nextLine,
|
||||||
|
(Line)next.Offset,
|
||||||
|
distance,
|
||||||
|
side,
|
||||||
|
offsetShape
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!TryGetEnds(last.Offset, out _, out var gapStart)
|
||||||
|
|| !TryGetEnds(next.Offset, out var gapEnd, out _)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (gapStart.DistanceTo(gapEnd) <= OpenNest.Math.Tolerance.Epsilon)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!next.CollapsedBefore
|
||||||
|
&& IsConvexCorner(last.Source, next.Source, side, out var corner)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
offsetShape.Entities.Add(
|
||||||
|
new Arc(
|
||||||
|
corner,
|
||||||
|
distance,
|
||||||
|
corner.AngleTo(gapStart),
|
||||||
|
corner.AngleTo(gapEnd),
|
||||||
|
side == OffsetSide.Left
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concave corner or collapsed entity: the neighbors' offsets overlap, so a
|
||||||
|
// straight bridge stays inside the offset envelope and closes the chain.
|
||||||
|
offsetShape.Entities.Add(new Line(gapStart, gapEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsConvexCorner(
|
||||||
|
Entity last,
|
||||||
|
Entity next,
|
||||||
|
OffsetSide side,
|
||||||
|
out Vector corner
|
||||||
|
)
|
||||||
|
{
|
||||||
|
corner = default;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!TryGetEnds(last, out _, out corner)
|
||||||
|
|| !TryGetTangents(last, out _, out var d1)
|
||||||
|
|| !TryGetTangents(next, out var d2, out _)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var cross = d1.X * d2.Y - d1.Y * d2.X;
|
||||||
|
|
||||||
|
return (side == OffsetSide.Left && cross < -OpenNest.Math.Tolerance.Epsilon)
|
||||||
|
|| (side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetEnds(Entity entity, out Vector start, out Vector end)
|
||||||
|
{
|
||||||
|
switch (entity)
|
||||||
|
{
|
||||||
|
case Line line:
|
||||||
|
start = line.StartPoint;
|
||||||
|
end = line.EndPoint;
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case Arc arc:
|
||||||
|
start = arc.StartPoint();
|
||||||
|
end = arc.EndPoint();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
start = end = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Direction of travel at the start and end of a line or arc.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryGetTangents(Entity entity, out Vector start, out Vector end)
|
||||||
|
{
|
||||||
|
switch (entity)
|
||||||
|
{
|
||||||
|
case Line line:
|
||||||
|
start = end = line.EndPoint - line.StartPoint;
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case Arc arc:
|
||||||
|
start = ArcTangent(arc, arc.StartAngle);
|
||||||
|
end = ArcTangent(arc, arc.EndAngle);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
start = end = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector ArcTangent(Arc arc, double angle)
|
||||||
|
{
|
||||||
|
var sin = System.Math.Sin(angle);
|
||||||
|
var cos = System.Math.Cos(angle);
|
||||||
|
return arc.IsReversed ? new Vector(sin, -cos) : new Vector(-sin, cos);
|
||||||
|
}
|
||||||
|
|
||||||
private static void JoinOffsetLines(
|
private static void JoinOffsetLines(
|
||||||
Line lastLine,
|
Line lastLine,
|
||||||
Line lastOffsetLine,
|
Line lastOffsetLine,
|
||||||
@@ -611,7 +736,7 @@ namespace OpenNest.Geometry
|
|||||||
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
|
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
|
||||||
/// making the method independent of the original contour winding direction.
|
/// making the method independent of the original contour winding direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Shape OffsetOutward(double distance)
|
internal Shape OffsetOutward(double distance)
|
||||||
{
|
{
|
||||||
var poly = ToPolygon();
|
var poly = ToPolygon();
|
||||||
|
|
||||||
@@ -660,7 +785,7 @@ namespace OpenNest.Geometry
|
|||||||
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
|
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
|
||||||
/// making the method independent of the original contour winding direction.
|
/// making the method independent of the original contour winding direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Shape OffsetInward(double distance)
|
internal Shape OffsetInward(double distance)
|
||||||
{
|
{
|
||||||
var poly = ToPolygon();
|
var poly = ToPolygon();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Geometry;
|
||||||
|
|
||||||
|
public class ShapeOffsetTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, OffsetSide.Left, 4)] // CCW: center on the left, shrinks.
|
||||||
|
[InlineData(false, OffsetSide.Right, 6)]
|
||||||
|
[InlineData(true, OffsetSide.Left, 6)] // CW: center on the right, grows.
|
||||||
|
[InlineData(true, OffsetSide.Right, 4)]
|
||||||
|
public void ArcOffset_GrowsAwayFromCenter(bool reversed, OffsetSide side, double radius)
|
||||||
|
{
|
||||||
|
var arc = new Arc(0, 0, 5, 0, Angle.HalfPI, reversed);
|
||||||
|
|
||||||
|
var offset = (Arc)arc.OffsetEntity(1, side);
|
||||||
|
|
||||||
|
Assert.Equal(radius, offset.Radius, 9);
|
||||||
|
Assert.Equal(reversed, offset.IsReversed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(RotationType.CCW, OffsetSide.Left, 4)]
|
||||||
|
[InlineData(RotationType.CCW, OffsetSide.Right, 6)]
|
||||||
|
[InlineData(RotationType.CW, OffsetSide.Left, 6)]
|
||||||
|
[InlineData(RotationType.CW, OffsetSide.Right, 4)]
|
||||||
|
public void CircleOffset_GrowsAwayFromCenter(RotationType rotation, OffsetSide side, double radius)
|
||||||
|
{
|
||||||
|
var circle = new Circle(0, 0, 5) { Rotation = rotation };
|
||||||
|
|
||||||
|
var offset = (Circle)circle.OffsetEntity(1, side);
|
||||||
|
|
||||||
|
Assert.Equal(radius, offset.Radius, 9);
|
||||||
|
Assert.Equal(rotation, offset.Rotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(OffsetSide.Left, 1)]
|
||||||
|
[InlineData(OffsetSide.Right, -1)]
|
||||||
|
public void LineOffset_MovesToSideAndKeepsDirection(OffsetSide side, double y)
|
||||||
|
{
|
||||||
|
var line = new Line(0, 0, 10, 0);
|
||||||
|
|
||||||
|
var offset = (Line)line.OffsetEntity(1, side);
|
||||||
|
|
||||||
|
Assert.True(offset.StartPoint.DistanceTo(new Vector(0, y)) < 1e-9);
|
||||||
|
Assert.True(offset.EndPoint.DistanceTo(new Vector(10, y)) < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OffsetOutward_NonTangentLineArcCorners_GetRoundJoins()
|
||||||
|
{
|
||||||
|
// D shape: right half of an r=5 circle closed by the Y axis. Both corners are
|
||||||
|
// convex and not tangent, so the offset needs a round join at each.
|
||||||
|
var shape = new Shape();
|
||||||
|
shape.Entities.Add(new Arc(0, 0, 5, -Angle.HalfPI, Angle.HalfPI));
|
||||||
|
shape.Entities.Add(new Line(0, 5, 0, -5));
|
||||||
|
|
||||||
|
var offset = shape.OffsetOutward(1);
|
||||||
|
|
||||||
|
AssertClosedChain(offset.Entities);
|
||||||
|
Assert.Equal(2, offset.Entities.OfType<Arc>().Count(a => a.Radius.IsEqualTo(1)));
|
||||||
|
Assert.All(Samples(offset.Entities), p => Assert.True(DistanceTo(shape, p) > 1 - 1e-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OffsetOutward_CollapsedFillet_ClosesTheChain()
|
||||||
|
{
|
||||||
|
// 10x4 part with a 0.2-wide slot down from the top, with a round (r=0.1) bottom.
|
||||||
|
// Offsetting outward by 0.25 collapses the slot's end arc.
|
||||||
|
var shape = new Shape();
|
||||||
|
shape.Entities.Add(new Line(0, 0, 10, 0));
|
||||||
|
shape.Entities.Add(new Line(10, 0, 10, 4));
|
||||||
|
shape.Entities.Add(new Line(10, 4, 5.1, 4));
|
||||||
|
shape.Entities.Add(new Line(5.1, 4, 5.1, 2));
|
||||||
|
shape.Entities.Add(new Arc(5, 2, 0.1, 0, System.Math.PI, reversed: true));
|
||||||
|
shape.Entities.Add(new Line(4.9, 2, 4.9, 4));
|
||||||
|
shape.Entities.Add(new Line(4.9, 4, 0, 4));
|
||||||
|
shape.Entities.Add(new Line(0, 4, 0, 0));
|
||||||
|
|
||||||
|
var offset = shape.OffsetOutward(0.25);
|
||||||
|
|
||||||
|
AssertClosedChain(offset.Entities);
|
||||||
|
Assert.All(
|
||||||
|
Samples(offset.Entities),
|
||||||
|
p => Assert.True(DistanceTo(shape, p) > 0.25 - 1e-6 || IsInsideSlot(p))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The collapsed slot leaves a line bridging its walls' offsets, which lies inside
|
||||||
|
// the offset envelope (closer than the spacing) by design.
|
||||||
|
private static bool IsInsideSlot(Vector p) => p.X > 4.8 && p.X < 5.2 && p.Y > 1.7;
|
||||||
|
|
||||||
|
private static void AssertClosedChain(List<Entity> entities)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < entities.Count; i++)
|
||||||
|
{
|
||||||
|
var end = End(entities[i]);
|
||||||
|
var start = Start(entities[(i + 1) % entities.Count]);
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
end.DistanceTo(start) < 1e-6,
|
||||||
|
$"Gap of {end.DistanceTo(start)} after entity {i} ({entities[i].Type})."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Vector> Samples(List<Entity> entities)
|
||||||
|
{
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
for (var t = 0.0; t <= 1.0; t += 0.1)
|
||||||
|
{
|
||||||
|
yield return entity switch
|
||||||
|
{
|
||||||
|
Line l => l.StartPoint + (l.EndPoint - l.StartPoint) * t,
|
||||||
|
Arc a => ArcPoint(a, t),
|
||||||
|
_ => Start(entity),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector ArcPoint(Arc arc, double t)
|
||||||
|
{
|
||||||
|
var sweep = arc.SweepAngle();
|
||||||
|
var angle = arc.StartAngle + (arc.IsReversed ? -sweep : sweep) * t;
|
||||||
|
return new Vector(
|
||||||
|
arc.Center.X + arc.Radius * System.Math.Cos(angle),
|
||||||
|
arc.Center.Y + arc.Radius * System.Math.Sin(angle)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double DistanceTo(Shape shape, Vector p) =>
|
||||||
|
shape.Entities.Min(e => e.ClosestPointTo(p).DistanceTo(p));
|
||||||
|
|
||||||
|
private static Vector Start(Entity e) =>
|
||||||
|
e switch
|
||||||
|
{
|
||||||
|
Line l => l.StartPoint,
|
||||||
|
Arc a => a.StartPoint(),
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Vector End(Entity e) =>
|
||||||
|
e switch
|
||||||
|
{
|
||||||
|
Line l => l.EndPoint,
|
||||||
|
Arc a => a.EndPoint(),
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user