refactor(geometry): move polygon offset callers onto ClipperBridge
Per-entity offsetting left spikes and inverted loops wherever a feature is narrower than the spacing (1.nest), and RemoveSelfIntersections only caught proper crossings. The callers that already flatten to polygons now take a single Clipper region offset instead: - PolygonHelper (BestFit) and PartBoundary use the conservative mode, which keeps their never-under-estimate guarantee. PartBoundary also keeps holes that appear when a perimeter curls back on itself. - NestValidator offsets perimeter and cutouts in one region; Clipper drops collapsed cutouts, so the collapsed-or-flipped heuristic goes away. - CutOff.IntersectPerimeter offsets through the bridge. The old OffsetEntity(Left) grew CW perimeters but shrank CCW ones, so with the plate's perimeter cache a cut-off ran through the part; slots narrower than twice the clearance now close up instead of leaving a gap. - GetOffsetPartLines (3 overloads) and the AddOffset* helpers had no callers and are removed, as is EntityView's never-defined DRAW_OFFSET block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -315,6 +315,8 @@ namespace OpenNest.Benchmark
|
|||||||
? requirement.Name
|
? requirement.Name
|
||||||
: part.BaseDrawing.Name;
|
: part.BaseDrawing.Name;
|
||||||
|
|
||||||
|
private const double OutlineTolerance = 0.01;
|
||||||
|
|
||||||
private sealed class PartOutline
|
private sealed class PartOutline
|
||||||
{
|
{
|
||||||
public Polygon Perimeter { get; init; }
|
public Polygon Perimeter { get; init; }
|
||||||
@@ -324,9 +326,12 @@ namespace OpenNest.Benchmark
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts a part's material as world-space polygons - the perimeter and
|
/// Extracts a part's material as world-space polygons - the perimeter and
|
||||||
/// its cutouts - grown by <paramref name="inflateBy"/> (perimeter offset
|
/// its cutouts - grown by <paramref name="inflateBy"/> (perimeter offset
|
||||||
/// outward, cutouts offset inward). A cutout that closes up under the
|
/// outward, cutouts offset inward, in one Clipper region offset). A cutout
|
||||||
/// offset is dropped, which treats it as solid: conservative, since it
|
/// that closes up under the offset is dropped, which treats it as solid:
|
||||||
/// has no room for another part at the required spacing anyway.
|
/// conservative, since it has no room for another part at the required
|
||||||
|
/// spacing anyway. The flattening is conservative too (perimeter arcs
|
||||||
|
/// circumscribed, cutout arcs inscribed), so the check never passes a
|
||||||
|
/// layout that is closer than the spacing.
|
||||||
/// part.Program is already rotated; only a Location offset is needed.
|
/// part.Program is already rotated; only a Location offset is needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static PartOutline Outline(Part part, double inflateBy)
|
private static PartOutline Outline(Part part, double inflateBy)
|
||||||
@@ -344,57 +349,33 @@ namespace OpenNest.Benchmark
|
|||||||
if (profile.Perimeter == null)
|
if (profile.Perimeter == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var perimeter = profile.Perimeter;
|
|
||||||
|
|
||||||
if (inflateBy > Tolerance.Epsilon)
|
|
||||||
perimeter = perimeter.OffsetOutward(inflateBy) ?? perimeter;
|
|
||||||
|
|
||||||
var polygon = ToWorldPolygon(perimeter, part.Location);
|
|
||||||
|
|
||||||
if (polygon == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var holes = new List<Polygon>();
|
|
||||||
|
|
||||||
foreach (var cutout in profile.Cutouts)
|
|
||||||
{
|
|
||||||
var hole = cutout;
|
|
||||||
|
|
||||||
if (inflateBy > Tolerance.Epsilon)
|
|
||||||
{
|
|
||||||
hole = cutout.OffsetInward(inflateBy);
|
|
||||||
|
|
||||||
// An offset that collapsed or flipped inside-out leaves no usable room.
|
|
||||||
if (
|
|
||||||
hole == null
|
|
||||||
|| hole.Area() <= Tolerance.Epsilon
|
|
||||||
|| hole.Area() >= cutout.Area()
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var holePolygon = ToWorldPolygon(hole, part.Location);
|
|
||||||
|
|
||||||
if (holePolygon != null)
|
|
||||||
holes.Add(holePolygon);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PartOutline { Perimeter = polygon, Holes = holes };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Polygon ToWorldPolygon(Shape shape, Vector location)
|
|
||||||
{
|
|
||||||
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000
|
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000
|
||||||
// segments per arc) - arc-heavy real parts otherwise produce thousands
|
// segments per arc) - arc-heavy real parts otherwise produce thousands
|
||||||
// of vertices, which is needlessly slow for a spacing check.
|
// of vertices, which is needlessly slow for a spacing check.
|
||||||
var polygon = shape.ToPolygonWithTolerance(0.01, circumscribe: true);
|
var region = ClipperBridge.Offset(
|
||||||
|
profile,
|
||||||
|
inflateBy > Tolerance.Epsilon ? inflateBy : 0,
|
||||||
|
OutlineTolerance,
|
||||||
|
circumscribe: true
|
||||||
|
);
|
||||||
|
|
||||||
if (polygon == null)
|
var perimeter = region.LargestOuter();
|
||||||
|
|
||||||
|
if (perimeter == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
ToWorld(perimeter, part.Location);
|
||||||
|
|
||||||
|
foreach (var hole in region.Holes)
|
||||||
|
ToWorld(hole, part.Location);
|
||||||
|
|
||||||
|
return new PartOutline { Perimeter = perimeter, Holes = region.Holes };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ToWorld(Polygon polygon, Vector location)
|
||||||
|
{
|
||||||
polygon.Offset(location);
|
polygon.Offset(location);
|
||||||
polygon.UpdateBounds();
|
polygon.UpdateBounds();
|
||||||
return polygon;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-12
@@ -13,6 +13,8 @@ namespace OpenNest
|
|||||||
|
|
||||||
public class CutOff
|
public class CutOff
|
||||||
{
|
{
|
||||||
|
private const double OffsetTolerance = 0.001;
|
||||||
|
|
||||||
public Vector Position { get; set; }
|
public Vector Position { get; set; }
|
||||||
public CutOffAxis Axis { get; set; }
|
public CutOffAxis Axis { get; set; }
|
||||||
public double? StartLimit { get; set; }
|
public double? StartLimit { get; set; }
|
||||||
@@ -163,14 +165,23 @@ namespace OpenNest
|
|||||||
double clearance
|
double clearance
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var target = OffsetOutward(perimeter, clearance) ?? perimeter;
|
var offset = OffsetOutward(perimeter, clearance);
|
||||||
var usedOffset = target != perimeter;
|
var usedOffset = offset != null;
|
||||||
|
var targets = offset ?? new List<Entity> { perimeter };
|
||||||
var cutLine = new Line(
|
var cutLine = new Line(
|
||||||
MakePoint(cutPosition, lineStart),
|
MakePoint(cutPosition, lineStart),
|
||||||
MakePoint(cutPosition, lineEnd)
|
MakePoint(cutPosition, lineEnd)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!target.Intersects(cutLine, out var pts) || pts.Count < 2)
|
var pts = new List<Vector>();
|
||||||
|
|
||||||
|
foreach (var target in targets)
|
||||||
|
{
|
||||||
|
if (target.Intersects(cutLine, out var targetPts))
|
||||||
|
pts.AddRange(targetPts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pts.Count < 2)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var coords = pts.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
|
var coords = pts.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
|
||||||
@@ -188,21 +199,37 @@ namespace OpenNest
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Entity OffsetOutward(Entity perimeter, double clearance)
|
/// <summary>
|
||||||
|
/// Grows the perimeter by the clearance as one Clipper region offset, so slots
|
||||||
|
/// narrower than twice the clearance close up instead of leaving a gap the cut
|
||||||
|
/// could run into. Holes appear only where the perimeter curls back on itself.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Entity> OffsetOutward(Entity perimeter, double clearance)
|
||||||
{
|
{
|
||||||
if (clearance <= 0)
|
if (clearance <= 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
try
|
var offset = perimeter switch
|
||||||
{
|
|
||||||
var offset = perimeter.OffsetEntity(clearance, OffsetSide.Left);
|
|
||||||
offset?.UpdateBounds();
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
{
|
||||||
|
Shape shape => ClipperBridge.OffsetPerimeter(
|
||||||
|
shape,
|
||||||
|
clearance,
|
||||||
|
OffsetTolerance,
|
||||||
|
circumscribe: true
|
||||||
|
),
|
||||||
|
Polygon polygon => ClipperBridge.OffsetPerimeter(
|
||||||
|
polygon,
|
||||||
|
clearance,
|
||||||
|
OffsetTolerance,
|
||||||
|
circumscribe: true
|
||||||
|
),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (offset == null || offset.Outers.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
return offset.Outers.Concat(offset.Holes).Cast<Entity>().ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Vector MakePoint(double cutCoord, double lineCoord) =>
|
private Vector MakePoint(double cutCoord, double lineCoord) =>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
private const double MiterLimit = 2.0;
|
private const double MiterLimit = 2.0;
|
||||||
|
|
||||||
|
private const double ConservativeJoinFactor = 0.25;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts a polygon to a Clipper path, dropping the closing vertex and
|
/// Converts a polygon to a Clipper path, dropping the closing vertex and
|
||||||
/// orienting it positive (CCW) or negative (CW).
|
/// orienting it positive (CCW) or negative (CW).
|
||||||
@@ -90,9 +92,9 @@ namespace OpenNest.Geometry
|
|||||||
/// no more than <paramref name="tolerance"/> from the true arc.
|
/// no more than <paramref name="tolerance"/> from the true arc.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="circumscribe">
|
/// <param name="circumscribe">
|
||||||
/// When true, the result never under-estimates the offset: arcs are flattened
|
/// When true, the result never under-estimates the offset: perimeter arcs are
|
||||||
/// outside the true curve and the inflation is padded by the chord tolerance
|
/// flattened outside the true curve, cutout arcs inside it, and the inflation is
|
||||||
/// and Clipper's rounding.
|
/// padded by the round-join chord error and Clipper's rounding.
|
||||||
/// </param>
|
/// </param>
|
||||||
public static OffsetRegion Offset(
|
public static OffsetRegion Offset(
|
||||||
ShapeProfile profile,
|
ShapeProfile profile,
|
||||||
@@ -105,8 +107,39 @@ namespace OpenNest.Geometry
|
|||||||
return Offset(region, distance, tolerance, circumscribe);
|
return Offset(region, distance, tolerance, circumscribe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offsets a single closed shape outward, ignoring any cutouts. A perimeter that
|
||||||
|
/// curls back on itself (a C shape with a narrow mouth) can gain holes.
|
||||||
|
/// </summary>
|
||||||
|
public static OffsetRegion OffsetPerimeter(
|
||||||
|
Shape perimeter,
|
||||||
|
double distance,
|
||||||
|
double tolerance,
|
||||||
|
bool circumscribe = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var polygon = perimeter.ToPolygonWithTolerance(tolerance, circumscribe);
|
||||||
|
return OffsetPerimeter(polygon, distance, tolerance, circumscribe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offsets a closed polygon outward, whatever its winding.
|
||||||
|
/// </summary>
|
||||||
|
public static OffsetRegion OffsetPerimeter(
|
||||||
|
Polygon perimeter,
|
||||||
|
double distance,
|
||||||
|
double tolerance,
|
||||||
|
bool circumscribe = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var region = new PathsD(1);
|
||||||
|
AddPolygon(region, perimeter, positive: true);
|
||||||
|
return Offset(region, distance, tolerance, circumscribe);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Offsets an already-flattened region (outers positive, holes negative).
|
/// Offsets an already-flattened region (outers positive, holes negative).
|
||||||
|
/// A distance of zero only unions the region, with no conservative padding.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static OffsetRegion Offset(
|
public static OffsetRegion Offset(
|
||||||
PathsD region,
|
PathsD region,
|
||||||
@@ -115,13 +148,20 @@ namespace OpenNest.Geometry
|
|||||||
bool circumscribe = false
|
bool circumscribe = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
// Round joins put their vertices on the true arc, so each chord sits inside
|
||||||
|
// it by up to the join tolerance. In conservative mode, joins use a finer
|
||||||
|
// tolerance and the inflation is padded by it (plus Clipper's rounding).
|
||||||
var delta = distance;
|
var delta = distance;
|
||||||
|
var joinTolerance = tolerance;
|
||||||
|
|
||||||
if (circumscribe)
|
if (circumscribe && distance > 0)
|
||||||
delta += tolerance + 0.5 * System.Math.Pow(10, -Precision);
|
{
|
||||||
|
joinTolerance = tolerance * ConservativeJoinFactor;
|
||||||
|
delta += joinTolerance + 0.5 * System.Math.Pow(10, -Precision);
|
||||||
|
}
|
||||||
|
|
||||||
var inflated =
|
var inflated =
|
||||||
delta == 0
|
delta <= 0
|
||||||
? Union(region)
|
? Union(region)
|
||||||
: Clipper.InflatePaths(
|
: Clipper.InflatePaths(
|
||||||
region,
|
region,
|
||||||
@@ -130,7 +170,7 @@ namespace OpenNest.Geometry
|
|||||||
EndType.Polygon,
|
EndType.Polygon,
|
||||||
MiterLimit,
|
MiterLimit,
|
||||||
Precision,
|
Precision,
|
||||||
tolerance
|
joinTolerance
|
||||||
);
|
);
|
||||||
|
|
||||||
var result = new OffsetRegion(new List<Polygon>(), new List<Polygon>());
|
var result = new OffsetRegion(new List<Polygon>(), new List<Polygon>());
|
||||||
@@ -167,9 +207,12 @@ namespace OpenNest.Geometry
|
|||||||
bool positive
|
bool positive
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var polygon = shape.ToPolygonWithTolerance(tolerance, circumscribe);
|
AddPolygon(region, shape.ToPolygonWithTolerance(tolerance, circumscribe), positive);
|
||||||
|
}
|
||||||
|
|
||||||
if (polygon.Vertices.Count < 4)
|
private static void AddPolygon(PathsD region, Polygon polygon, bool positive)
|
||||||
|
{
|
||||||
|
if (polygon.Vertices.Count < 3)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var path = ToPath(polygon, positive);
|
var path = ToPath(polygon, positive);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ namespace OpenNest
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the perimeter entities (Line, Arc, Circle) with spacing offset applied,
|
/// Returns the perimeter entities (Line, Arc, Circle) with spacing offset applied,
|
||||||
/// without tessellation. Much faster than GetOffsetPartLines for parts with many arcs.
|
/// without tessellation, which keeps arc-heavy parts fast in directional-distance loops.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
|
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
|
||||||
{
|
{
|
||||||
@@ -149,75 +149,6 @@ namespace OpenNest
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<Line> GetOffsetPartLines(
|
|
||||||
Part part,
|
|
||||||
double spacing,
|
|
||||||
double chordTolerance = 0.001,
|
|
||||||
bool perimeterOnly = false
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
|
||||||
var profile = new ShapeProfile(
|
|
||||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList()
|
|
||||||
);
|
|
||||||
var lines = new List<Line>();
|
|
||||||
var totalSpacing = spacing;
|
|
||||||
|
|
||||||
AddOffsetLines(
|
|
||||||
lines,
|
|
||||||
profile.Perimeter.OffsetOutward(totalSpacing),
|
|
||||||
chordTolerance,
|
|
||||||
part.Location
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!perimeterOnly)
|
|
||||||
{
|
|
||||||
foreach (var cutout in profile.Cutouts)
|
|
||||||
AddOffsetLines(
|
|
||||||
lines,
|
|
||||||
cutout.OffsetInward(totalSpacing),
|
|
||||||
chordTolerance,
|
|
||||||
part.Location
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<Line> GetOffsetPartLines(
|
|
||||||
Part part,
|
|
||||||
double spacing,
|
|
||||||
PushDirection facingDirection,
|
|
||||||
double chordTolerance = 0.001
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
|
||||||
var profile = new ShapeProfile(
|
|
||||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList()
|
|
||||||
);
|
|
||||||
var lines = new List<Line>();
|
|
||||||
var totalSpacing = spacing;
|
|
||||||
|
|
||||||
AddOffsetDirectionalLines(
|
|
||||||
lines,
|
|
||||||
profile.Perimeter.OffsetOutward(totalSpacing),
|
|
||||||
chordTolerance,
|
|
||||||
part.Location,
|
|
||||||
facingDirection
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach (var cutout in profile.Cutouts)
|
|
||||||
AddOffsetDirectionalLines(
|
|
||||||
lines,
|
|
||||||
cutout.OffsetInward(totalSpacing),
|
|
||||||
chordTolerance,
|
|
||||||
part.Location,
|
|
||||||
facingDirection
|
|
||||||
);
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<Line> GetPartLines(
|
public static List<Line> GetPartLines(
|
||||||
Part part,
|
Part part,
|
||||||
Vector facingDirection,
|
Vector facingDirection,
|
||||||
@@ -240,40 +171,6 @@ namespace OpenNest
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<Line> GetOffsetPartLines(
|
|
||||||
Part part,
|
|
||||||
double spacing,
|
|
||||||
Vector facingDirection,
|
|
||||||
double chordTolerance = 0.001
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
|
||||||
var profile = new ShapeProfile(
|
|
||||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList()
|
|
||||||
);
|
|
||||||
var lines = new List<Line>();
|
|
||||||
var totalSpacing = spacing;
|
|
||||||
|
|
||||||
AddOffsetDirectionalLines(
|
|
||||||
lines,
|
|
||||||
profile.Perimeter.OffsetOutward(totalSpacing),
|
|
||||||
chordTolerance,
|
|
||||||
part.Location,
|
|
||||||
facingDirection
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach (var cutout in profile.Cutouts)
|
|
||||||
AddOffsetDirectionalLines(
|
|
||||||
lines,
|
|
||||||
cutout.OffsetInward(totalSpacing),
|
|
||||||
chordTolerance,
|
|
||||||
part.Location,
|
|
||||||
facingDirection
|
|
||||||
);
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns only polygon edges whose outward normal faces the specified direction vector.
|
/// Returns only polygon edges whose outward normal faces the specified direction vector.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -353,55 +250,5 @@ namespace OpenNest
|
|||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddOffsetLines(
|
|
||||||
List<Line> lines,
|
|
||||||
Shape offsetEntity,
|
|
||||||
double chordTolerance,
|
|
||||||
Vector location
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (offsetEntity == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
|
||||||
polygon.RemoveSelfIntersections();
|
|
||||||
polygon.Offset(location);
|
|
||||||
lines.AddRange(polygon.ToLines());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddOffsetDirectionalLines(
|
|
||||||
List<Line> lines,
|
|
||||||
Shape offsetEntity,
|
|
||||||
double chordTolerance,
|
|
||||||
Vector location,
|
|
||||||
PushDirection facingDirection
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (offsetEntity == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
|
||||||
polygon.RemoveSelfIntersections();
|
|
||||||
polygon.Offset(location);
|
|
||||||
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddOffsetDirectionalLines(
|
|
||||||
List<Line> lines,
|
|
||||||
Shape offsetEntity,
|
|
||||||
double chordTolerance,
|
|
||||||
Vector location,
|
|
||||||
Vector facingDirection
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (offsetEntity == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
|
||||||
polygon.RemoveSelfIntersections();
|
|
||||||
polygon.Offset(location);
|
|
||||||
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,16 +26,15 @@ namespace OpenNest.Engine.BestFit
|
|||||||
if (perimeter == null)
|
if (perimeter == null)
|
||||||
return new PolygonExtractionResult(null, Vector.Zero);
|
return new PolygonExtractionResult(null, Vector.Zero);
|
||||||
|
|
||||||
// Ensure CW winding for correct outward offset direction.
|
// Circumscribe so the polygon never under-estimates the part (or its offset).
|
||||||
definedShape.NormalizeWinding();
|
var polygon =
|
||||||
|
halfSpacing > 0
|
||||||
|
? ClipperBridge
|
||||||
|
.OffsetPerimeter(perimeter, halfSpacing, 0.01, circumscribe: true)
|
||||||
|
.LargestOuter()
|
||||||
|
: perimeter.ToPolygonWithTolerance(0.01, circumscribe: true);
|
||||||
|
|
||||||
var inflated =
|
if (polygon == null || polygon.Vertices.Count < 3)
|
||||||
halfSpacing > 0 ? (perimeter.OffsetOutward(halfSpacing) ?? perimeter) : perimeter;
|
|
||||||
|
|
||||||
// Convert to polygon with circumscribed arcs for tight nesting.
|
|
||||||
var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true);
|
|
||||||
|
|
||||||
if (polygon.Vertices.Count < 3)
|
|
||||||
return new PolygonExtractionResult(null, Vector.Zero);
|
return new PolygonExtractionResult(null, Vector.Zero);
|
||||||
|
|
||||||
// Normalize: move polygon to origin.
|
// Normalize: move polygon to origin.
|
||||||
|
|||||||
@@ -34,19 +34,16 @@ namespace OpenNest.Engine.Fill
|
|||||||
|
|
||||||
if (perimeter != null)
|
if (perimeter != null)
|
||||||
{
|
{
|
||||||
var offsetEntity = perimeter.OffsetOutward(spacing);
|
// Conservative offset: the boundary never under-estimates the spacing.
|
||||||
|
// Holes appear only where the perimeter curls back on itself.
|
||||||
if (offsetEntity != null)
|
var offset = ClipperBridge.OffsetPerimeter(
|
||||||
{
|
perimeter,
|
||||||
// Circumscribe arcs so polygon vertices are always outside
|
spacing,
|
||||||
// the true arc — guarantees the boundary never under-estimates.
|
|
||||||
var polygon = offsetEntity.ToPolygonWithTolerance(
|
|
||||||
PolygonTolerance,
|
PolygonTolerance,
|
||||||
circumscribe: true
|
circumscribe: true
|
||||||
);
|
);
|
||||||
polygon.RemoveSelfIntersections();
|
_polygons.AddRange(offset.Outers);
|
||||||
_polygons.Add(polygon);
|
_polygons.AddRange(offset.Holes);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PrecomputeDirectionalEdges(
|
PrecomputeDirectionalEdges(
|
||||||
|
|||||||
@@ -121,6 +121,49 @@ public class CutOffTests
|
|||||||
Assert.Equal(4, codes.Count);
|
Assert.Equal(4, codes.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.3, 19.0)] // Narrower than twice the clearance: the slot closes up.
|
||||||
|
[InlineData(4.0, 24.0)] // Wide slot: the cut runs in to 1 short of the slot's end.
|
||||||
|
public void CutOff_UpASlot_KeepsClearanceFromPart(double slotWidth, double firstEnd)
|
||||||
|
{
|
||||||
|
// 10x10 part at (20,20) with a 5-deep slot up from the bottom edge, centered
|
||||||
|
// on the cut line.
|
||||||
|
var h = slotWidth / 2;
|
||||||
|
var pgm = new Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(5 - h, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(5 - h, 5)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(5 + h, 5)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(5 + h, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
|
||||||
|
var plate = new Plate(50, 50);
|
||||||
|
var part = Part.CreateAtOrigin(new Drawing("slot", pgm));
|
||||||
|
part.Location = new Vector(20, 20);
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
|
||||||
|
var settings = new CutOffSettings { PartClearance = 1.0 };
|
||||||
|
var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical);
|
||||||
|
cutoff.Regenerate(plate, settings, Plate.BuildPerimeterCache(plate));
|
||||||
|
|
||||||
|
var ys = cutoff
|
||||||
|
.Drawing.Program.Codes.OfType<Motion>()
|
||||||
|
.Select(m => m.EndPoint.Y)
|
||||||
|
.OrderBy(y => y)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Equal(4, ys.Count);
|
||||||
|
Assert.Equal(0, ys[0], 6);
|
||||||
|
// A closed slot leaves a shallow dent at its mouth: the cut stops 1 from the
|
||||||
|
// mouth corners, at 20 - sqrt(1 - 0.15^2) = 19.011.
|
||||||
|
Assert.InRange(ys[1], firstEnd - 0.02, firstEnd + 0.02);
|
||||||
|
Assert.InRange(ys[2], 30.999, 31.02);
|
||||||
|
Assert.Equal(50, ys[3], 6);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CutOff_ShortSegment_FilteredByMinLength()
|
public void CutOff_ShortSegment_FilteredByMinLength()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -165,17 +165,6 @@ namespace OpenNest.Controls
|
|||||||
DrawArc(e.Graphics, SimplifierPreview, previewPen);
|
DrawArc(e.Graphics, SimplifierPreview, previewPen);
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DRAW_OFFSET
|
|
||||||
|
|
||||||
var offsetShape = new Shape();
|
|
||||||
offsetShape.Entities.AddRange(Entities);
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var entity in ((Shape)offsetShape.OffsetEntity(0.25, OffsetSide.Left)).Entities
|
|
||||||
)
|
|
||||||
DrawEntity(e.Graphics, entity, Pens.RoyalBlue);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
PaintOverlay?.Invoke(e.Graphics);
|
PaintOverlay?.Invoke(e.Graphics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user