diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs
index 955d03eb..adafa4f8 100644
--- a/OpenNest.Core/AutomaticCutOffPlanner.cs
+++ b/OpenNest.Core/AutomaticCutOffPlanner.cs
@@ -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)),
diff --git a/OpenNest.Core/CutOff.cs b/OpenNest.Core/CutOff.cs
index 1af5a2a8..af8cfe91 100644
--- a/OpenNest.Core/CutOff.cs
+++ b/OpenNest.Core/CutOff.cs
@@ -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;
}
+ ///
+ /// Where the line at 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 past the far one unless a limit
+ /// or a head-locked far edge ends it there.
+ ///
+ 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();
+ 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(
diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs
index ba93ec56..c1d639d0 100644
--- a/OpenNest.Core/Plate.cs
+++ b/OpenNest.Core/Plate.cs
@@ -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();
+ // 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;
+ ///
+ /// The sheet's material when it is not a rectangle (see ), in
+ /// plate coordinates; null for a rectangular sheet. Setting one makes 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
+ /// is its material area.
+ ///
+ 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
///
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
///
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
}
///
- /// Gets the area of the top surface of the plate.
+ /// Gets the area of the top surface of the plate: the material inside its
+ /// when it has one, otherwise the whole rectangle.
///
///
public double Area()
{
- return Size.Width * Size.Length;
+ return Outline?.Area ?? Size.Width * Size.Length;
}
///
diff --git a/OpenNest.Core/PlateManager.cs b/OpenNest.Core/PlateManager.cs
index 92bca444..bdf79219 100644
--- a/OpenNest.Core/PlateManager.cs
+++ b/OpenNest.Core/PlateManager.cs
@@ -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
diff --git a/OpenNest.Core/PlateRemnant.cs b/OpenNest.Core/PlateRemnant.cs
index cb2b3153..309ca77e 100644
--- a/OpenNest.Core/PlateRemnant.cs
+++ b/OpenNest.Core/PlateRemnant.cs
@@ -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
- /// finds. Material bounds ignore cut-off parts and marks.
+ /// finds. Material bounds ignore cut-off parts and marks. A plate
+ /// with an reports none.
///
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)
diff --git a/OpenNest.Core/SheetOutline.cs b/OpenNest.Core/SheetOutline.cs
new file mode 100644
index 00000000..5bd8d917
--- /dev/null
+++ b/OpenNest.Core/SheetOutline.cs
@@ -0,0 +1,293 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Clipper2Lib;
+using OpenNest.Geometry;
+
+namespace OpenNest
+{
+ ///
+ /// 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 ) 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 ; rectangular code paths keep using that
+ /// box. Immutable.
+ ///
+ public sealed class SheetOutline : IEquatable
+ {
+ /// 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.
+ 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;
+
+ ///
+ /// 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.
+ ///
+ /// A polygon is missing, degenerate, nonfinite or
+ /// self-intersecting.
+ public SheetOutline(IEnumerable points, IEnumerable> 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)o.Select(p => new Vector(p.x, p.y)).ToArray())
+ .ToArray();
+ }
+
+ /// Vertices of the outline, counterclockwise, in plate coordinates.
+ public IReadOnlyList Points { get; }
+
+ /// Vertices of each obstacle, counterclockwise, in plate coordinates.
+ public IReadOnlyList> Obstacles { get; }
+
+ /// The bounding box of the outline: the plate's size.
+ public Size Size { get; }
+
+ /// Material area: the outline less its obstacles.
+ public double Area { get; }
+
+ ///
+ /// Where the plate's lower-left corner lies in the placement frame of its parts for
+ /// : -Length on X in quadrants 2 and 3, -Width on Y in 3 and 4,
+ /// exactly as and the engine stock translate.
+ ///
+ public Vector PlacementOrigin(int quadrant) =>
+ new(quadrant is 2 or 3 ? -Size.Length : 0, quadrant is 3 or 4 ? -Size.Width : 0);
+
+ ///
+ /// The outline less its obstacles, translated by : outer paths
+ /// positive, holes negative.
+ ///
+ public PathsD Material(Vector origin) => Translate(material, origin);
+
+ ///
+ /// The material eroded by (round joins) and translated by
+ /// : where a part may lie when it must keep
+ /// from every edge and obstacle. Empty when nothing is left. A nonpositive inset returns
+ /// the material itself.
+ ///
+ 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);
+ }
+
+ ///
+ /// Whether a region lies inside the material eroded by , allowing
+ /// outside it: nothing of (outer paths
+ /// positive, holes negative, in the frame of ) is left after
+ /// subtracting the usable area grown by the slack. The test is exact on the stored polygons;
+ /// it never loosens for arcs.
+ ///
+ 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;
+ }
+
+ /// This outline turned a quarter turn about the origin, re-normalized to start
+ /// at the origin, as turns the sheet.
+ 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)));
+ }
+
+ /// This outline turned half a turn, re-normalized to start at the origin.
+ 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))));
+
+ ///
+ /// An outline from imported geometry: the largest closed contour becomes the outline and
+ /// every other closed contour an obstacle. Arcs are chorded at ,
+ /// 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.
+ ///
+ /// No closed contour with a positive area.
+ public static SheetOutline FromGeometry(IEnumerable 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>();
+ 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)");
+
+ /// Exact bits: two outlines are the same stock only when nothing moved.
+ 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);
+
+ /// A counterclockwise copy without the closing or repeated vertices, after
+ /// checking it is one simple polygon of positive area.
+ private static PathD SimplePolygon(IEnumerable 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;
+ }
+ }
+}
diff --git a/OpenNest.Tests/SheetOutlineTests.cs b/OpenNest.Tests/SheetOutlineTests.cs
new file mode 100644
index 00000000..c3986dc8
--- /dev/null
+++ b/OpenNest.Tests/SheetOutlineTests.cs
@@ -0,0 +1,238 @@
+using Clipper2Lib;
+using OpenNest.Geometry;
+using OpenNest.Shapes;
+
+namespace OpenNest.Tests;
+
+///
+/// : the material of a non-rectangular sheet, and what a plate, its
+/// cut-offs and its remnant do with one.
+///
+public class SheetOutlineTests
+{
+ /// A 96 x 48 sheet (X by Y) missing its upper-right 48 x 24 corner.
+ 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(() => new SheetOutline(new[] { new Vector(0, 0), new Vector(1, 0) }));
+ Assert.Throws(() => new SheetOutline(new[] { new Vector(0, 0), new Vector(1, 0), new Vector(2, 0) }));
+ Assert.Throws(() => new SheetOutline(new[] { new Vector(0, 0), new Vector(1, double.NaN), new Vector(0, 1) }));
+ // A bow tie.
+ Assert.Throws(() => 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(() => 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
+ {
+ 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(() => SheetOutline.FromGeometry(new List { 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(() =>
+ 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) } };
+}
diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md
index a4a5a6a4..c61c7d6b 100644
--- a/docs/automatic-scrap-cutoffs.md
+++ b/docs/automatic-scrap-cutoffs.md
@@ -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.