feat(core): give a plate a sheet outline for irregular stock

A SheetOutline is a closed polygon in plate coordinates with optional
obstacle polygons, normalized to start at the origin; the plate's Size
stays its bounding box. Plate.Area is the material area, rotations turn
the outline, AutoSize and Snap to Standard Size leave it, manual cut-offs
are trimmed to the material the line crosses, the automatic planner
refuses it and no expected remnant is reported.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Fable 5.1 committed 2026-10-28 22:16:35 -04:00
1 parent e5dc67346d
commit 672ed09074
8 files changed
+645 -17

No files matched your search

+2
View File
@@ -544,6 +544,8 @@ public static class AutomaticCutOffPlanner
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
"Sheet length and width must be positive and finite.", nameof(plate));
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
// Strips are planned across a rectangle's known span; an outlined sheet gets manual cut-offs only.
Require(plate.Outline == null, "Automatic cut-offs need a rectangular sheet; this plate has an outline.", nameof(plate));
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
Nonnegative(plate.EdgeSpacing.Bottom)),
+64 -12
View File
@@ -262,7 +262,8 @@ namespace OpenNest
{
var bounds = plate.BoundingBox(includeParts: false);
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
var overtravel = LockedFarEdge(plate) is { High: true } ? 0 : settings.Overtravel;
var lockedFarEdge = LockedFarEdge(plate);
var overtravel = lockedFarEdge is { High: true } ? 0 : settings.Overtravel;
double lineStart,
lineEnd,
@@ -281,6 +282,13 @@ namespace OpenNest
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
}
// An outlined sheet is cut only where the line crosses material: each crossing is a
// span of its own, started at the material's edge and overtravelled past the far
// one, within the user's limits.
var spans = plate.Outline == null
? new List<(double Start, double End)> { (lineStart, lineEnd) }
: MaterialSpans(plate, cutPosition, lineStart, lineEnd, settings.Overtravel, lockedFarEdge);
var exclusions = new List<(double Start, double End)>();
foreach (var part in plate.Parts)
@@ -325,27 +333,71 @@ namespace OpenNest
}
var segments = new List<(double Start, double End)>();
var current = lineStart;
foreach (var ex in merged)
foreach (var (spanStart, spanEnd) in spans)
{
var clampedStart = System.Math.Max(ex.Start, lineStart);
var clampedEnd = System.Math.Min(ex.End, lineEnd);
var current = spanStart;
if (clampedStart > current)
segments.Add((current, clampedStart));
foreach (var ex in merged)
{
var clampedStart = System.Math.Max(ex.Start, spanStart);
var clampedEnd = System.Math.Min(ex.End, spanEnd);
current = System.Math.Max(current, clampedEnd);
if (clampedStart > current)
segments.Add((current, clampedStart));
current = System.Math.Max(current, clampedEnd);
}
if (current < spanEnd)
segments.Add((current, spanEnd));
}
if (current < lineEnd)
segments.Add((current, lineEnd));
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
return segments;
}
/// <summary>
/// Where the line at <paramref name="cutPosition"/> crosses the sheet's outlined material,
/// in ascending order along the axis and clipped to the line's limits. Each span starts at
/// the material edge and runs <paramref name="overtravel"/> past the far one unless a limit
/// or a head-locked far edge ends it there.
/// </summary>
private List<(double Start, double End)> MaterialSpans(Plate plate, double cutPosition,
double lineStart, double lineEnd, double overtravel, (double Edge, bool High)? lockedFarEdge)
{
var vertical = Axis == CutOffAxis.Vertical;
var crossings = new List<double>();
foreach (var path in plate.Outline.Material(plate.Outline.PlacementOrigin(plate.Quadrant)))
{
for (var i = 0; i < path.Count; i++)
{
var a = path[i];
var b = path[(i + 1) % path.Count];
var (aAcross, aAlong) = vertical ? (a.x, a.y) : (a.y, a.x);
var (bAcross, bAlong) = vertical ? (b.x, b.y) : (b.y, b.x);
// Half-open on the across axis, so a line through a vertex counts one edge.
if (aAcross <= cutPosition == bAcross <= cutPosition)
continue;
crossings.Add(aAlong + (bAlong - aAlong) * (cutPosition - aAcross) / (bAcross - aAcross));
}
}
crossings.Sort();
var spans = new List<(double Start, double End)>();
for (var i = 0; i + 1 < crossings.Count; i += 2)
{
var start = System.Math.Max(crossings[i], lineStart);
var end = crossings[i + 1];
var limited = EndLimit != null && end >= EndLimit.Value
|| lockedFarEdge is var (edge, high) && high && end == edge;
end = System.Math.Min(limited ? end : end + overtravel, lineEnd);
if (end > start)
spans.Add((start, end));
}
return spans;
}
private static readonly List<(double Start, double End)> EmptyExclusions = new();
private List<(double Start, double End)> GetPartExclusions(
+33 -4
View File
@@ -98,6 +98,9 @@ namespace OpenNest
// A turned sheet is the same sheet; any other size is no longer the coded item.
if (stockItemCodes.Count > 0 && !SameSheet(size, value))
stockItemCodes = Array.Empty<string>();
// An outline is a particular sheet; any other size is a rectangle again.
if (outline != null && (outline.Size.Width != value.Width || outline.Size.Length != value.Length))
outline = null;
size = value;
ResolveClampRail();
}
@@ -105,6 +108,26 @@ namespace OpenNest
private Size size;
/// <summary>
/// The sheet's material when it is not a rectangle (see <see cref="SheetOutline"/>), in
/// plate coordinates; null for a rectangular sheet. Setting one makes <see cref="Size"/> its
/// bounding box, and setting any other size drops it. Only the Irregular whole-job engine
/// nests on an outlined sheet; cut-offs are trimmed to it, automatic cut-offs refuse it, and
/// <see cref="Area"/> is its material area.
/// </summary>
public SheetOutline Outline
{
get { return outline; }
set
{
outline = value;
if (value != null)
Size = value.Size;
}
}
private SheetOutline outline;
private static bool SameSheet(Size a, Size b) =>
a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
@@ -526,7 +549,9 @@ namespace OpenNest
{
const double oneAndHalfPI = System.Math.PI * 1.5;
var turned = Outline?.Rotated90(rotationDirection);
Size = new Size(Size.Length, Size.Width);
Outline = turned;
// After Size swap above, new Size.Width = old Length (old X extent),
// new Size.Length = old Width (old Y extent).
@@ -632,10 +657,12 @@ namespace OpenNest
}
Rotate(System.Math.PI, centerpt);
Outline = Outline?.Rotated180();
}
else
{
Rotate(System.Math.PI);
Outline = Outline?.Rotated180();
Quadrant = (Quadrant + 2) % 4;
if (Quadrant == 0)
@@ -841,7 +868,8 @@ namespace OpenNest
/// </example>
public void AutoSize(double roundingFactor = 1.0)
{
if (Parts.Count == 0)
// An outlined sheet is a particular piece of material; it cannot be resized to fit.
if (Parts.Count == 0 || Outline != null)
return;
var bounds = Parts.GetBoundingBox();
@@ -890,7 +918,7 @@ namespace OpenNest
/// </summary>
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
{
if (Parts.Count == 0)
if (Parts.Count == 0 || Outline != null)
return default;
var bounds = Parts.GetBoundingBox();
@@ -942,12 +970,13 @@ namespace OpenNest
}
/// <summary>
/// Gets the area of the top surface of the plate.
/// Gets the area of the top surface of the plate: the material inside its
/// <see cref="Outline"/> when it has one, otherwise the whole rectangle.
/// </summary>
/// <returns></returns>
public double Area()
{
return Size.Width * Size.Length;
return Outline?.Area ?? Size.Width * Size.Length;
}
/// <summary>
+1
View File
@@ -133,6 +133,7 @@ namespace OpenNest
var followsDefaults = trailing != null
&& trailing.Parts.Count == 0
&& trailing.CutOffs.Count == 0
&& trailing.Outline == null
&& trailing.Size.Width == defaults.Size.Width
&& trailing.Size.Length == defaults.Size.Length
&& trailing.Quadrant == defaults.Quadrant
+5 -1
View File
@@ -38,10 +38,14 @@ public sealed class PlateRemnant
/// (no limit short of either sheet edge) with no part material beyond it defines the drop: the
/// sheet past its nominal position, less the plate's kerf for each bounding cut-off line. It
/// wins over a larger edge offcut. Otherwise the drop is the largest edge offcut that
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks.
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks. A plate
/// with an <see cref="Plate.Outline"/> reports none.
/// </summary>
public static PlateRemnant Expected(Plate plate, double minimumDimension)
{
// No rectangular strip is guaranteed inside an outlined sheet.
if (plate.Outline != null)
return null;
// A part with no material (marks only) bounds nothing.
var bounds = plate.Parts
.Where(p => !p.BaseDrawing.IsCutOff && p.Program != null)
+293
View File
@@ -0,0 +1,293 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// The material of a sheet that is not a rectangle: a remnant, a drop, or a sheet with a
/// burned or damaged area. A closed polygon in plate coordinates (the sheet's lower-left
/// frame, as <see cref="Plate.ClampZones"/>) with optional obstacle polygons for areas inside
/// it that must stay empty. The polygon lies inside the true material and every obstacle
/// polygon contains its true area, so chording never adds room. Its bounding box starts at
/// the origin and is the sheet's <see cref="Size"/>; rectangular code paths keep using that
/// box. Immutable.
/// </summary>
public sealed class SheetOutline : IEquatable<SheetOutline>
{
/// <summary>Chord deviation when importing arcs; the outline is also eroded, and each
/// obstacle inflated, by this much so the stored polygons never reach past the true edges.</summary>
public const double ChordTolerance = SpacingOutline.ChordTolerance;
private const int Precision = ClipperBridge.Precision;
private const double ArcTolerance = 0.001;
private const double MiterLimit = 2.0;
private readonly PathD outline;
private readonly PathsD obstacles;
private readonly PathsD material;
/// <summary>
/// Builds an outline from its vertices and optional obstacles, each a simple polygon of at
/// least three finite vertices with a positive area. Winding is normalized and the whole
/// is translated so the outline's bounds start at the origin; an obstacle wholly outside
/// the outline is dropped.
/// </summary>
/// <exception cref="ArgumentException">A polygon is missing, degenerate, nonfinite or
/// self-intersecting.</exception>
public SheetOutline(IEnumerable<Vector> points, IEnumerable<IEnumerable<Vector>> obstacles = null)
{
ArgumentNullException.ThrowIfNull(points);
var path = SimplePolygon(points, "outline");
var box = Clipper.GetBounds(path);
var shift = new PointD(-box.left, -box.top); // RectD top is the minimum Y.
outline = Clipper.TranslatePath(path, shift.x, shift.y);
Size = new Size(box.Height, box.Width);
this.obstacles = new PathsD();
if (obstacles != null)
{
var index = 0;
foreach (var obstacle in obstacles)
{
index++;
var obstaclePath = Clipper.TranslatePath(SimplePolygon(obstacle, $"obstacle {index}"), shift.x, shift.y);
var inside = Clipper.Intersect(new PathsD { outline }, new PathsD { obstaclePath }, FillRule.NonZero, Precision);
if (inside.Sum(Clipper.Area) > 0)
this.obstacles.Add(obstaclePath);
}
}
material = Clipper.Difference(new PathsD { outline }, this.obstacles, FillRule.NonZero, Precision);
Area = material.Sum(Clipper.Area);
if (!(Area > 0))
throw new ArgumentException("The obstacles leave no material inside the outline.", nameof(obstacles));
Points = outline.Select(p => new Vector(p.x, p.y)).ToArray();
Obstacles = this.obstacles
.Select(o => (IReadOnlyList<Vector>)o.Select(p => new Vector(p.x, p.y)).ToArray())
.ToArray();
}
/// <summary>Vertices of the outline, counterclockwise, in plate coordinates.</summary>
public IReadOnlyList<Vector> Points { get; }
/// <summary>Vertices of each obstacle, counterclockwise, in plate coordinates.</summary>
public IReadOnlyList<IReadOnlyList<Vector>> Obstacles { get; }
/// <summary>The bounding box of the outline: the plate's size.</summary>
public Size Size { get; }
/// <summary>Material area: the outline less its obstacles.</summary>
public double Area { get; }
/// <summary>
/// Where the plate's lower-left corner lies in the placement frame of its parts for
/// <paramref name="quadrant"/>: -Length on X in quadrants 2 and 3, -Width on Y in 3 and 4,
/// exactly as <see cref="ClampZoneFrame"/> and the engine stock translate.
/// </summary>
public Vector PlacementOrigin(int quadrant) =>
new(quadrant is 2 or 3 ? -Size.Length : 0, quadrant is 3 or 4 ? -Size.Width : 0);
/// <summary>
/// The outline less its obstacles, translated by <paramref name="origin"/>: outer paths
/// positive, holes negative.
/// </summary>
public PathsD Material(Vector origin) => Translate(material, origin);
/// <summary>
/// The material eroded by <paramref name="inset"/> (round joins) and translated by
/// <paramref name="origin"/>: where a part may lie when it must keep <paramref name="inset"/>
/// from every edge and obstacle. Empty when nothing is left. A nonpositive inset returns
/// the material itself.
/// </summary>
public PathsD Usable(double inset, Vector origin)
{
if (!(inset > 0))
return Material(origin);
var eroded = Clipper.InflatePaths(material, -inset, JoinType.Round, EndType.Polygon,
MiterLimit, Precision, ArcTolerance);
eroded.RemoveAll(p => p.Count < 3);
return Translate(eroded, origin);
}
/// <summary>
/// Whether a region lies inside the material eroded by <paramref name="inset"/>, allowing
/// <paramref name="slack"/> outside it: nothing of <paramref name="region"/> (outer paths
/// positive, holes negative, in the frame of <paramref name="origin"/>) is left after
/// subtracting the usable area grown by the slack. The test is exact on the stored polygons;
/// it never loosens for arcs.
/// </summary>
public bool Holds(PathsD region, double inset, Vector origin, double slack)
{
var usable = Usable(inset, origin);
if (slack > 0)
{
usable = Clipper.InflatePaths(usable, slack, JoinType.Miter, EndType.Polygon,
MiterLimit, Precision, ArcTolerance);
}
var outside = Clipper.Difference(region, usable, FillRule.NonZero, Precision);
return outside.Sum(p => System.Math.Abs(Clipper.Area(p))) <= 0;
}
/// <summary>This outline turned a quarter turn about the origin, re-normalized to start
/// at the origin, as <see cref="Plate.Rotate90"/> turns the sheet.</summary>
public SheetOutline Rotated90(RotationType direction)
{
Vector Turn(Vector p) => direction == RotationType.CW ? new Vector(p.Y, -p.X) : new Vector(-p.Y, p.X);
return new SheetOutline(Points.Select(Turn), Obstacles.Select(o => o.Select(Turn)));
}
/// <summary>This outline turned half a turn, re-normalized to start at the origin.</summary>
public SheetOutline Rotated180() =>
new(Points.Select(p => new Vector(-p.X, -p.Y)), Obstacles.Select(o => o.Select(p => new Vector(-p.X, -p.Y))));
/// <summary>
/// An outline from imported geometry: the largest closed contour becomes the outline and
/// every other closed contour an obstacle. Arcs are chorded at <see cref="ChordTolerance"/>,
/// then the outline is eroded and each obstacle inflated by that much, so the stored
/// polygons stay within the true edges. Marks and open contours are ignored.
/// </summary>
/// <exception cref="ArgumentException">No closed contour with a positive area.</exception>
public static SheetOutline FromGeometry(IEnumerable<Entity> entities)
{
ArgumentNullException.ThrowIfNull(entities);
var material = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList();
ShapeProfile profile;
try
{
profile = material.Count == 0 ? null : new ShapeProfile(material);
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
{
throw new ArgumentException("The geometry has no closed outline.", nameof(entities), exception);
}
if (profile?.Perimeter == null)
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
var perimeter = ClipperBridge.ToPath(ClipperBridge.Flatten(profile.Perimeter, ChordTolerance, circumscribe: false), positive: true);
if (perimeter.Count < 3 || !(Clipper.Area(perimeter) > 0))
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
var outer = Clipper.InflatePaths(new PathsD { perimeter },
-ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
var outline = outer.Where(p => p.Count >= 3).OrderByDescending(Clipper.Area).FirstOrDefault()
?? throw new ArgumentException("The outline is too thin to hold material.", nameof(entities));
var obstacles = new List<IEnumerable<Vector>>();
foreach (var cutout in profile.Cutouts)
{
var hole = ClipperBridge.ToPath(ClipperBridge.Flatten(cutout, ChordTolerance, circumscribe: false), positive: true);
if (hole.Count < 3 || !(Clipper.Area(hole) > 0))
continue;
var grown = Clipper.InflatePaths(new PathsD { hole },
ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
foreach (var path in grown.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
obstacles.Add(path.Select(p => new Vector(p.x, p.y)).ToList());
}
return new SheetOutline(outline.Select(p => new Vector(p.x, p.y)), obstacles);
}
public bool Equals(SheetOutline other)
{
if (other is null)
return false;
if (ReferenceEquals(this, other))
return true;
if (Points.Count != other.Points.Count || Obstacles.Count != other.Obstacles.Count)
return false;
for (var i = 0; i < Points.Count; i++)
if (!Same(Points[i], other.Points[i]))
return false;
for (var k = 0; k < Obstacles.Count; k++)
{
if (Obstacles[k].Count != other.Obstacles[k].Count)
return false;
for (var i = 0; i < Obstacles[k].Count; i++)
if (!Same(Obstacles[k][i], other.Obstacles[k][i]))
return false;
}
return true;
}
public override bool Equals(object obj) => Equals(obj as SheetOutline);
public override int GetHashCode()
{
var hash = new System.HashCode();
hash.Add(Points.Count);
hash.Add(Obstacles.Count);
foreach (var p in Points)
{
hash.Add(BitConverter.DoubleToInt64Bits(p.X));
hash.Add(BitConverter.DoubleToInt64Bits(p.Y));
}
return hash.ToHashCode();
}
public override string ToString() =>
$"{Size} outline, {Points.Count} vertices" + (Obstacles.Count == 0 ? "" : $", {Obstacles.Count} obstacle(s)");
/// <summary>Exact bits: two outlines are the same stock only when nothing moved.</summary>
private static bool Same(Vector a, Vector b) =>
BitConverter.DoubleToInt64Bits(a.X) == BitConverter.DoubleToInt64Bits(b.X)
&& BitConverter.DoubleToInt64Bits(a.Y) == BitConverter.DoubleToInt64Bits(b.Y);
private static PathsD Translate(PathsD paths, Vector origin) =>
origin.X == 0 && origin.Y == 0
? new PathsD(paths.Select(p => new PathD(p)))
: Clipper.TranslatePaths(paths, origin.X, origin.Y);
/// <summary>A counterclockwise copy without the closing or repeated vertices, after
/// checking it is one simple polygon of positive area.</summary>
private static PathD SimplePolygon(IEnumerable<Vector> points, string what)
{
ArgumentNullException.ThrowIfNull(points, nameof(points));
var path = new PathD();
foreach (var p in points)
{
if (!double.IsFinite(p.X) || !double.IsFinite(p.Y))
throw new ArgumentException($"The {what} has a nonfinite vertex.", nameof(points));
if (path.Count > 0 && path[^1].x == p.X && path[^1].y == p.Y)
continue;
path.Add(new PointD(p.X, p.Y));
}
if (path.Count > 1 && path[0].x == path[^1].x && path[0].y == path[^1].y)
path.RemoveAt(path.Count - 1);
if (path.Count < 3)
throw new ArgumentException($"The {what} needs at least three distinct vertices.", nameof(points));
var area = Clipper.Area(path);
if (!(System.Math.Abs(area) > 0))
throw new ArgumentException($"The {what} has no area.", nameof(points));
if (area < 0)
path.Reverse();
// Start at the lowest-left vertex so equal polygons list their vertices alike.
var start = 0;
for (var i = 1; i < path.Count; i++)
if (path[i].y < path[start].y || path[i].y == path[start].y && path[i].x < path[start].x)
start = i;
if (start != 0)
path = new PathD(path.Skip(start).Concat(path.Take(start)));
// A simple polygon unions to itself: one path of the same area. A figure eight or a
// bow tie does not, and a sheet edge that crosses itself is not a sheet.
var union = Clipper.Union(new PathsD { path }, new PathsD(), FillRule.NonZero, Precision);
var scale = System.Math.Pow(10, -Precision);
if (union.Count != 1 || System.Math.Abs(Clipper.Area(union[0]) - System.Math.Abs(area)) > scale * (path.Count + 1) * Perimeter(path))
throw new ArgumentException($"The {what} must be a simple polygon that does not cross itself.", nameof(points));
return path;
}
private static double Perimeter(PathD path)
{
var length = 0.0;
for (var i = 0; i < path.Count; i++)
{
var a = path[i];
var b = path[(i + 1) % path.Count];
length += System.Math.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
}
return length;
}
}
}
+238
View File
@@ -0,0 +1,238 @@
using Clipper2Lib;
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Tests;
/// <summary>
/// <see cref="SheetOutline"/>: the material of a non-rectangular sheet, and what a plate, its
/// cut-offs and its remnant do with one.
/// </summary>
public class SheetOutlineTests
{
/// <summary>A 96 x 48 sheet (X by Y) missing its upper-right 48 x 24 corner.</summary>
public static SheetOutline EllSheet() => new(new[]
{
new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(48, 24), new Vector(48, 48), new Vector(0, 48),
});
[Fact]
public void Constructor_NormalizesToTheOriginAndCounterclockwise()
{
// Clockwise, offset from the origin, with a repeated closing vertex.
var outline = new SheetOutline(new[]
{
new Vector(10, 10), new Vector(10, 30), new Vector(50, 30), new Vector(50, 10), new Vector(10, 10),
});
Assert.Equal(new[] { new Vector(0, 0), new Vector(40, 0), new Vector(40, 20), new Vector(0, 20) }, outline.Points);
Assert.Equal(20, outline.Size.Width);
Assert.Equal(40, outline.Size.Length);
Assert.Equal(800, outline.Area, 9);
}
[Fact]
public void Constructor_RejectsDegenerateAndSelfCrossingPolygons()
{
Assert.Throws<ArgumentException>(() => new SheetOutline(new[] { new Vector(0, 0), new Vector(1, 0) }));
Assert.Throws<ArgumentException>(() => new SheetOutline(new[] { new Vector(0, 0), new Vector(1, 0), new Vector(2, 0) }));
Assert.Throws<ArgumentException>(() => new SheetOutline(new[] { new Vector(0, 0), new Vector(1, double.NaN), new Vector(0, 1) }));
// A bow tie.
Assert.Throws<ArgumentException>(() => new SheetOutline(new[]
{
new Vector(0, 0), new Vector(10, 10), new Vector(10, 0), new Vector(0, 10),
}));
}
[Fact]
public void Obstacles_ReduceTheAreaAndAreDroppedWhenOutside()
{
var outline = new SheetOutline(
new[] { new Vector(0, 0), new Vector(100, 0), new Vector(100, 50), new Vector(0, 50) },
new[]
{
new[] { new Vector(10, 10), new Vector(20, 10), new Vector(20, 20), new Vector(10, 20) },
new[] { new Vector(200, 200), new Vector(210, 200), new Vector(210, 210) },
});
Assert.Single(outline.Obstacles);
Assert.Equal(5000 - 100, outline.Area, 6);
Assert.Throws<ArgumentException>(() => new SheetOutline(
new[] { new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10) },
new[] { new[] { new Vector(-1, -1), new Vector(11, -1), new Vector(11, 11), new Vector(-1, 11) } }));
}
[Fact]
public void Equality_IsExactOnVertices()
{
Assert.Equal(EllSheet(), EllSheet());
Assert.Equal(EllSheet().GetHashCode(), EllSheet().GetHashCode());
var moved = new SheetOutline(EllSheet().Points.Select((p, i) => i == 2 ? new Vector(96, 24.001) : p));
Assert.NotEqual(EllSheet(), moved);
}
[Fact]
public void Usable_ErodesTheMaterialAndHoldsOnlyRegionsInsideIt()
{
var sheet = EllSheet();
var usable = sheet.Usable(1, new Vector());
var bounds = Clipper.GetBounds(usable);
Assert.Equal(1, bounds.left, 6);
Assert.Equal(1, bounds.top, 6);
Assert.Equal(95, bounds.right, 6);
Assert.Equal(47, bounds.bottom, 6);
// A 10 x 10 square in the lower-left corner, exactly at the inset.
Assert.True(sheet.Holds(Square(1, 1, 10), 1, new Vector(), 0));
// The same square in the missing corner.
Assert.False(sheet.Holds(Square(60, 30, 10), 1, new Vector(), 0));
// Just across the inset by more than the slack.
Assert.False(sheet.Holds(Square(0.9, 1, 10), 1, new Vector(), 1e-5));
Assert.True(sheet.Holds(Square(0.9999999, 1, 10), 1, new Vector(), 1e-5));
}
[Fact]
public void Usable_FollowsThePlacementOrigin()
{
var sheet = EllSheet();
Assert.Equal(new Vector(-96, 0), sheet.PlacementOrigin(2));
Assert.Equal(new Vector(-96, -48), sheet.PlacementOrigin(3));
Assert.Equal(new Vector(0, -48), sheet.PlacementOrigin(4));
Assert.True(sheet.Holds(Square(-95, -47, 10), 1, sheet.PlacementOrigin(3), 0));
Assert.False(sheet.Holds(Square(1, 1, 10), 1, sheet.PlacementOrigin(3), 0));
}
[Fact]
public void Rotated_TurnsTheSheetAboutItsOwnCorner()
{
var sheet = EllSheet();
var ccw = sheet.Rotated90(RotationType.CCW);
Assert.Equal(96, ccw.Size.Width);
Assert.Equal(48, ccw.Size.Length);
// The missing corner moves from upper right to upper left.
Assert.False(ccw.Holds(Square(2, 60, 10), 0, new Vector(), 0));
Assert.True(ccw.Holds(Square(36, 60, 10), 0, new Vector(), 0));
var cw = sheet.Rotated90(RotationType.CW);
Assert.False(cw.Holds(Square(36, 2, 10), 0, new Vector(), 0));
Assert.True(cw.Holds(Square(2, 2, 10), 0, new Vector(), 0));
var half = sheet.Rotated180();
Assert.Equal(sheet.Size, half.Size);
Assert.False(half.Holds(Square(2, 2, 10), 0, new Vector(), 0));
Assert.True(half.Holds(Square(80, 30, 10), 0, new Vector(), 0));
Assert.Equal(sheet, half.Rotated180());
}
[Fact]
public void FromGeometry_TakesTheLargestLoopAndKeepsInsideTheTrueEdges()
{
var entities = new List<Entity>
{
new Line(0, 0, 96, 0), new Line(96, 0, 96, 24), new Line(96, 24, 48, 24),
new Line(48, 24, 48, 48), new Line(48, 48, 0, 48), new Line(0, 48, 0, 0),
// A round burn mark.
new Circle(new Vector(20, 20), 3),
};
var outline = SheetOutline.FromGeometry(entities);
Assert.Equal(48 - 2 * SheetOutline.ChordTolerance, outline.Size.Width, 6);
Assert.Equal(96 - 2 * SheetOutline.ChordTolerance, outline.Size.Length, 6);
var obstacle = Assert.Single(outline.Obstacles);
var radius = obstacle.Max(p => p.X) - obstacle.Min(p => p.X);
Assert.InRange(radius, 6 + 2 * SheetOutline.ChordTolerance - 1e-6, 6 + 4 * SheetOutline.ChordTolerance);
Assert.Throws<ArgumentException>(() => SheetOutline.FromGeometry(new List<Entity> { new Line(0, 0, 10, 0) }));
}
[Fact]
public void Plate_OutlineSetsTheSizeAndAnotherSizeDropsIt()
{
var plate = new Plate(10, 10) { Outline = EllSheet() };
Assert.Equal(48, plate.Size.Width);
Assert.Equal(96, plate.Size.Length);
Assert.Equal(96 * 48 - 48 * 24, plate.Area(), 6);
plate.Size = new Size(48, 96); // the same box keeps it
Assert.NotNull(plate.Outline);
plate.Size = new Size(60, 120);
Assert.Null(plate.Outline);
Assert.Equal(60 * 120, plate.Area());
}
[Fact]
public void Plate_RotationsTurnTheOutlineAndAutoSizeLeavesIt()
{
var plate = new Plate { Outline = EllSheet() };
plate.Parts.Add(new Part(new RectangleShape { Name = "SQ", Length = 10, Width = 10 }.GetDrawing(), new Vector(1, 1)));
plate.Rotate90(RotationType.CCW);
Assert.Equal(96, plate.Size.Width);
Assert.Equal(48, plate.Size.Length);
Assert.Equal(EllSheet().Rotated90(RotationType.CCW), plate.Outline);
plate.Rotate180();
Assert.Equal(EllSheet().Rotated90(RotationType.CCW).Rotated180(), plate.Outline);
plate.AutoSize();
Assert.Equal(96, plate.Size.Width);
Assert.NotNull(plate.Outline);
Assert.Equal(default, plate.SnapToStandardSize());
Assert.NotNull(plate.Outline);
}
[Fact]
public void Plate_OutlinedSheetHasNoExpectedRemnantAndRefusesAutomaticCutOffs()
{
var plate = new Plate { Outline = EllSheet() };
plate.Parts.Add(new Part(new RectangleShape { Name = "SQ", Length = 10, Width = 10 }.GetDrawing(), new Vector(0, 0)));
Assert.Null(PlateRemnant.Expected(plate, 12));
var exception = Assert.Throws<ArgumentException>(() =>
AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, new CutOffSettings()));
Assert.Contains("outline", exception.Message);
}
[Fact]
public void CutOff_IsTrimmedToTheOutlinedMaterial()
{
var settings = new CutOffSettings { Overtravel = 0.5 };
var plate = new Plate { Outline = EllSheet() };
// Through the arm: material from Y 0 to 24 only.
plate.CutOffs.Add(new CutOff(new Vector(70, 0), CutOffAxis.Vertical));
// Through the full height.
plate.CutOffs.Add(new CutOff(new Vector(20, 0), CutOffAxis.Vertical));
// Along Y 36: material from X 0 to 48 only.
plate.CutOffs.Add(new CutOff(new Vector(0, 36), CutOffAxis.Horizontal));
plate.RegenerateCutOffs(settings);
var arm = Assert.Single(plate.Parts.Where(p => p.BaseDrawing == plate.CutOffs[0].Drawing)).Program.BoundingBox();
Assert.Equal(0, arm.Bottom, 9);
Assert.Equal(24.5, arm.Top, 9);
var full = Assert.Single(plate.Parts.Where(p => p.BaseDrawing == plate.CutOffs[1].Drawing)).Program.BoundingBox();
Assert.Equal(0, full.Bottom, 9);
Assert.Equal(48.5, full.Top, 9);
var band = Assert.Single(plate.Parts.Where(p => p.BaseDrawing == plate.CutOffs[2].Drawing)).Program.BoundingBox();
Assert.Equal(0, band.Left, 9);
Assert.Equal(48.5, band.Right, 9);
}
[Fact]
public void CutOff_OnARectangleIsUnchanged()
{
var settings = new CutOffSettings { Overtravel = 0.5 };
var rectangle = new Plate(48, 96);
rectangle.CutOffs.Add(new CutOff(new Vector(70, 0), CutOffAxis.Vertical));
rectangle.RegenerateCutOffs(settings);
var box = Assert.Single(rectangle.Parts).Program.BoundingBox();
Assert.Equal(0, box.Bottom, 9);
Assert.Equal(48.5, box.Top, 9);
}
private static PathsD Square(double x, double y, double side) =>
new() { new PathD { new(x, y), new(x + side, y), new(x + side, y + side), new(x, y + side) } };
}
+9
View File
@@ -51,6 +51,15 @@ Each trimmed segment of a cut-off is materialized as a part of its own with its
- Internal-hole scrap is not processed. This command reuses the outside-skeleton behavior of manual cutoffs; it does not force cuts through parts or remove clearance to guarantee separation.
- Review the preview and posted NC using the normal machine-review process before production cutting.
## Outlined sheets
A plate with a sheet outline (see [irregular stock](irregular-stock.md)) gets manual cut-offs
only: the planner refuses it ("Automatic cut-offs need a rectangular sheet"), and a batch with
such a plate is blocked like any invalid plate. A manual cut-off on it is cut only where its
line crosses material: each crossing is a segment of its own that starts at the material's edge
and overtravels past the far one, trimmed around parts as usual and within the user's limits.
The head-locked overcut applies only to a span that reaches the bounding box's far edge.
## Head-locked overcut at the far edge
A cut-off starts only where an edge is certain. The origin-side edge always is. Stock often arrives up to 1.5 in wider than nominal, so the far edge (the one away from the plate's origin, by quadrant) may still have material past it. Without a head-height lock a cut-off never starts at the far edge, and Plan Cutting never reverses one.