diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs
index d65559f..818105f 100644
--- a/OpenNest.Core/AutomaticCutOffPlanner.cs
+++ b/OpenNest.Core/AutomaticCutOffPlanner.cs
@@ -94,6 +94,8 @@ public static class AutomaticCutOffPlanner
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(settings);
ValidateInputs(plate, options, settings);
+ // Stored zones use the sheet's lower-left frame; cut positions are signed.
+ var clamps = ClampZoneFrame.ToPlacement(plate);
var bounds = plate.BoundingBox(false);
Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel),
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
@@ -193,7 +195,7 @@ public static class AutomaticCutOffPlanner
// footprint when it hangs off the sheet. A same-line manual limit conflict above
// still blocks the whole plan, clamp or not.
var clearance = plate.EdgeSpacing.Bottom;
- var blocked = plate.ClampZones.FirstOrDefault(zone =>
+ var blocked = clamps.FirstOrDefault(zone =>
matches.Select(c => c.Position.X).Prepend(x).Any(position =>
position >= zone.Bounds.Left - clearance && position <= zone.Bounds.Right + clearance));
if (blocked != null)
@@ -265,9 +267,7 @@ public static class AutomaticCutOffPlanner
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", 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) && plate.ClampZones.All(zone =>
- zone?.Bounds != null && ValidBox(zone.Bounds) &&
- zone.Bounds.Length > 0 && zone.Bounds.Width > 0)),
+ Nonnegative(plate.EdgeSpacing.Bottom)),
"Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate));
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
diff --git a/OpenNest.Core/ClampZoneFrame.cs b/OpenNest.Core/ClampZoneFrame.cs
new file mode 100644
index 0000000..94a5898
--- /dev/null
+++ b/OpenNest.Core/ClampZoneFrame.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest;
+
+///
+/// Maps a live plate's , stored in the sheet's lower-left frame
+/// (), into the signed placement frame of its parts,
+/// cut-offs and motions. The translation matches the sheet itself and the engine stock:
+/// -Length on X in quadrants 2 and 3, -Width on Y in quadrants 3 and 4. Whole footprints are
+/// kept, never clipped. Engine-internal plates built from a NestPlateStock already
+/// carry placement-frame zones and must not be mapped again.
+///
+public static class ClampZoneFrame
+{
+ ///
+ /// Detached placement-frame copies in the plate's zone order; empty when there are none.
+ /// Throws for a missing or malformed zone: zone bounds stay
+ /// publicly mutable, so construction-time validation is not proof of current geometry.
+ ///
+ public static IReadOnlyList ToPlacement(Plate plate)
+ {
+ ArgumentNullException.ThrowIfNull(plate);
+ var zones = plate.ClampZones;
+ if (zones == null || zones.Count == 0)
+ return Array.Empty();
+ if (plate.Quadrant is < 1 or > 4)
+ throw new ArgumentException("Quadrant must be 1 through 4.", nameof(plate));
+ var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
+ var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
+ if (!double.IsFinite(left) || !double.IsFinite(bottom))
+ throw new ArgumentException("Sheet size must be finite.", nameof(plate));
+
+ var mapped = new ClampZone[zones.Count];
+ for (var i = 0; i < mapped.Length; i++)
+ {
+ var zone = zones[i];
+ if (zone?.Bounds == null)
+ throw new ArgumentException($"Clamp zone {i + 1} is missing.", nameof(plate));
+ var box = zone.Bounds;
+ var x = box.X + left;
+ var y = box.Y + bottom;
+ if (!(box.Length > 0) || !(box.Width > 0) || !double.IsFinite(box.Length)
+ || !double.IsFinite(box.Width) || !double.IsFinite(box.X) || !double.IsFinite(box.Y)
+ || !double.IsFinite(x) || !double.IsFinite(y)
+ || !double.IsFinite(x + box.Length) || !double.IsFinite(y + box.Width))
+ throw new ArgumentException(
+ $"Clamp zone '{zone.Id}' must have finite bounds and a positive size.", nameof(plate));
+ mapped[i] = new ClampZone(zone.Id, new Box(x, y, box.Length, box.Width));
+ }
+ return mapped;
+ }
+}
diff --git a/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs b/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs
index 3076928..559483d 100644
--- a/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs
+++ b/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs
@@ -11,6 +11,7 @@ public sealed record CutOffClampFinding(int CutOffIndex, string ClampId, Vector
/// Read-only axis-aligned clamp contact checks. Nominal cut-off definitions are checked
/// even if their current regenerated program is stale or trimmed by nearby parts.
/// A removed clamp is absent from Plate.ClampZones and therefore cannot be reported.
+/// Zones are compared in the plate's signed placement frame ().
///
public static class ClampCutDiagnostics
{
@@ -20,6 +21,7 @@ public static class ClampCutDiagnostics
var findings = new List();
if (plate.ClampZones.Count == 0 || plate.CutOffs.Count == 0)
return findings.AsReadOnly();
+ var zones = ClampZoneFrame.ToPlacement(plate);
var sheet = plate.BoundingBox(false);
for (var index = 0; index < plate.CutOffs.Count; index++)
{
@@ -32,7 +34,7 @@ public static class ClampCutDiagnostics
var end = cut.Axis == CutOffAxis.Vertical
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top)
: new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y);
- foreach (var zone in plate.ClampZones)
+ foreach (var zone in zones)
if (Intersects(start, end, zone.Bounds, out var location))
findings.Add(new CutOffClampFinding(index, zone.Id, location));
}
diff --git a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs
index 2146ff4..54fff1d 100644
--- a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs
+++ b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs
@@ -18,7 +18,7 @@ public sealed class OverlapGeometryStamp
private OverlapGeometryStamp(Plate plate)
{
this.plate = plate;
- sheet = (Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant);
+ sheet = SheetState(plate);
entries = new Entry[plate.Parts.Count];
for (var i = 0; i < entries.Length; i++)
entries[i] = new Entry(plate.Parts[i]);
@@ -41,11 +41,17 @@ public sealed class OverlapGeometryStamp
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
+ private static (long, long, int) SheetState(Plate plate) =>
+ (Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant);
+
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
internal bool SameZones(Plate current)
{
- if (!ReferenceEquals(plate, current) || current.ClampZones.Count != zones.Length)
+ // Stored zones are sheet-relative: a size or quadrant change moves them in the
+ // parts' frame without changing their bits.
+ if (!ReferenceEquals(plate, current) || current.ClampZones.Count != zones.Length
+ || sheet != SheetState(current))
return false;
for (var i = 0; i < zones.Length; i++)
{
@@ -60,7 +66,7 @@ public sealed class OverlapGeometryStamp
internal bool SameCutOffs(Plate current)
{
if (!ReferenceEquals(plate, current) || current.CutOffs.Count != cutoffs.Length
- || sheet != (Bits(current.Size.Width), Bits(current.Size.Length), current.Quadrant))
+ || sheet != SheetState(current))
return false;
for (var i = 0; i < cutoffs.Length; i++)
if (cutoffs[i] != CutOffState(current.CutOffs[i]))
diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
index 7a0aa04..29f4f07 100644
--- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
@@ -21,12 +21,14 @@ public static class PlateOverlapAnalyzer
/// Capture a whole plate including active clamps and nominal cut-off definitions.
/// Use this entry for the desktop overlap check so manual cut-off contacts
/// cannot be mistaken for a clear plate merely because cut-offs are not material.
+ /// Clamp zones are mapped from the sheet's lower-left frame into the parts' signed
+ /// placement frame; a malformed zone throws .
///
public static PlateOverlapSnapshot CapturePlate(Plate plate, OverlapMaterialCache cache = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plate);
- var material = Capture(plate.Parts.ToArray(), plate.ClampZones.ToArray(),
+ var material = Capture(plate.Parts.ToArray(), ClampZoneFrame.ToPlacement(plate).ToArray(),
cache ?? new OverlapMaterialCache(), cancellationToken);
var cutoffs = ClampCutDiagnostics.Find(plate);
cancellationToken.ThrowIfCancellationRequested();
diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
index 8aa8b44..8694e5a 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
@@ -56,29 +56,45 @@ public static class PostVerificationAnalyzer
}
var materialParts = new List();
var indices = new List();
+ // Stored zones use the sheet's lower-left frame; motions and parts are signed.
+ // Validate them before any early exit: zone bounds are publicly mutable.
+ IReadOnlyList clamps;
try
{
- foreach (var contact in ClampCutDiagnostics.Find(plate))
- {
- var cut = plate.CutOffs[contact.CutOffIndex];
- var partIndex = -1;
- for (var partSlot = 0; partSlot < plate.Parts.Count; partSlot++)
- if (ReferenceEquals(plate.Parts[partSlot]?.BaseDrawing, cut.Drawing))
- {
- partIndex = partSlot;
- break;
- }
- findings.Add(new(PostVerificationKind.Overlap, plateNumber,
- partIndex < 0 ? null : partIndex + 1, null,
- $"Manual cut-off {contact.CutOffIndex + 1} crosses active clamp '{contact.ClampId}'. "
- + "Review the clamp and cut before posting; this warning is not a repair.")
- { Location = contact.Location, ClampId = contact.ClampId });
- }
+ clamps = ClampZoneFrame.ToPlacement(plate);
}
catch (Exception exception) when (IsInvalid(exception))
{
+ clamps = null;
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
- "Clamp/cut-off check: " + exception.Message));
+ "Clamp check: " + exception.Message));
+ }
+ if (clamps != null)
+ {
+ try
+ {
+ foreach (var contact in ClampCutDiagnostics.Find(plate))
+ {
+ var cut = plate.CutOffs[contact.CutOffIndex];
+ var partIndex = -1;
+ for (var partSlot = 0; partSlot < plate.Parts.Count; partSlot++)
+ if (ReferenceEquals(plate.Parts[partSlot]?.BaseDrawing, cut.Drawing))
+ {
+ partIndex = partSlot;
+ break;
+ }
+ findings.Add(new(PostVerificationKind.Overlap, plateNumber,
+ partIndex < 0 ? null : partIndex + 1, null,
+ $"Manual cut-off {contact.CutOffIndex + 1} crosses active clamp '{contact.ClampId}'. "
+ + "Review the clamp and cut before posting; this warning is not a repair.")
+ { Location = contact.Location, ClampId = contact.ClampId });
+ }
+ }
+ catch (Exception exception) when (IsInvalid(exception))
+ {
+ findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
+ "Clamp/cut-off check: " + exception.Message));
+ }
}
var obstacles = new ReleasedContourState();
Vector? position = startPoint;
@@ -119,14 +135,14 @@ public static class PostVerificationAnalyzer
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
Incomplete("Placed program has no cutting contour motions for this drawing.");
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
- if (plate.ClampZones.Count != 0)
+ if (clamps is { Count: > 0 })
foreach (var move in moves)
{
cancellationToken.ThrowIfCancellationRequested();
if (!move.Rapid || move.Start is not { } from
|| from.X == move.End.X && from.Y == move.End.Y)
continue;
- foreach (var zone in plate.ClampZones)
+ foreach (var zone in clamps)
if (ClampCutDiagnostics.Intersects(from, move.End, zone.Bounds, out var contact))
findings.Add(new(PostVerificationKind.RapidCrossing, plateNumber,
partNumber, null,
@@ -155,7 +171,8 @@ public static class PostVerificationAnalyzer
}
cancellationToken.ThrowIfCancellationRequested();
var overlap = PlateOverlapAnalyzer.Analyze(
- PlateOverlapAnalyzer.Capture(materialParts, plate.ClampZones.ToArray(), cancellationToken),
+ PlateOverlapAnalyzer.Capture(materialParts, (clamps ?? Array.Empty()).ToArray(),
+ cancellationToken),
cancellationToken);
foreach (var pair in overlap.Pairs)
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs
index f79c81e..51612f1 100644
--- a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs
+++ b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs
@@ -199,10 +199,8 @@ public class AutomaticCutOffPlannerTests
var plate = MakePlate(quadrant);
var sign = quadrant is 2 or 3 ? -1 : 1;
plate.EdgeSpacing = new Spacing(0.25, 0.25);
- plate.ClampZones.Add(new ClampZone("nominal", new Box(sign > 0 ? 35.1 : -35.3,
- quadrant is 3 or 4 ? -0.625 : 0, 0.2, 0.625)));
- plate.ClampZones.Add(new ClampZone("tail", new Box(sign > 0 ? 80.6 : -80.8,
- quadrant is 3 or 4 ? -0.625 : 0, 0.2, 0.625)));
+ // Real rail zones are stored in the sheet's lower-left frame in every quadrant.
+ plate.SetClampRail(new ClampRail(0.2, 0.625, new[] { 35.2, 80.7 }));
var before = plate.Parts.ToArray();
var plan = Plan(plate);
Assert.Equal(new[] { sign * 70.0 }, Positions(plan));
@@ -255,10 +253,8 @@ public class AutomaticCutOffPlannerTests
// but the equivalent existing line that would be certified (80.500005) is inside it.
var plate = MakePlate(quadrant);
var sign = quadrant is 2 or 3 ? -1 : 1;
- var y = quadrant is 3 or 4 ? -0.625 : 0;
- plate.ClampZones.Add(new ClampZone("edge", sign > 0
- ? new Box(79.500005, y, 1.0000025, 0.625)
- : new Box(-80.5000075, y, 1.0000025, 0.625)));
+ // Footprint 79.500005..80.5000075 from the origin along the edge, in every quadrant.
+ plate.SetClampRail(new ClampRail(1.0000025, 0.625, new[] { 80.00000625 }));
var existing = new CutOff(new Vector(sign * 80.500005, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(existing);
existing.Regenerate(plate, new CutOffSettings());
@@ -277,6 +273,64 @@ public class AutomaticCutOffPlannerTests
Assert.Same(existing, Assert.Single(plate.CutOffs));
}
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void RailClampsBlockGridAndTailInEveryQuadrant(int quadrant)
+ {
+ // Rail clamps centered 35 and 80.5 from the origin, 1.5 wide, plus 0.25 clearance:
+ // the grid line at 35 and the separator at 80.50001 both cross a clamp.
+ var plate = MakePlate(quadrant);
+ var sign = quadrant is 2 or 3 ? -1 : 1;
+ plate.EdgeSpacing = new Spacing(0.25, 0.25);
+ plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 35.0, 80.5 }));
+
+ var plan = Plan(plate);
+
+ Assert.Equal(new[] { sign * 70.0 }, Positions(plan));
+ Assert.False(plan.HasSeparatedTail);
+ Assert.Equal(2, plan.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut));
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void RailClampClearanceOnlyContactBlocksAndClearLineSurvives(int quadrant)
+ {
+ // Physical footprint 36..37.5; the 0.25 clearance reaches a line at 35.75 but not 35.
+ var plate = MakePlate(quadrant);
+ var sign = quadrant is 2 or 3 ? -1 : 1;
+ plate.EdgeSpacing = new Spacing(0.25, 0.25);
+ plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 36.75 }));
+ Assert.Equal(new[] { sign * 35.0, sign * 70.0, sign * (80.5 + Tolerance.Epsilon) },
+ Positions(Plan(plate)));
+ Assert.Equal(new[] { sign * 71.5, sign * (80.5 + Tolerance.Epsilon) },
+ Positions(Plan(plate, spacing: 35.75)));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void HangingRailClampKeepsWholeFootprintForTail(int quadrant)
+ {
+ // Clamp centered 120.5 on a 120-long sheet covers 119.75..121.25 from the origin;
+ // with 0.25 clearance it blocks the separator at 119.50001 behind a 119-long envelope.
+ var plate = MakePlate(quadrant, occupied: 119);
+ plate.EdgeSpacing = new Spacing(0.25, 0.25);
+ plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 120.5 }));
+ var plan = Plan(plate, spacing: 119.6);
+ Assert.False(plan.HasSeparatedTail);
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut);
+ Assert.Empty(plan.Definitions);
+ }
+
[Theory]
[InlineData(35)]
[InlineData(80.50001)]
diff --git a/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs b/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs
new file mode 100644
index 0000000..4a4a981
--- /dev/null
+++ b/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs
@@ -0,0 +1,258 @@
+using System.Linq;
+using OpenNest.CNC;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+using Xunit;
+
+namespace OpenNest.Tests.Diagnostics;
+
+///
+/// Live plates store clamp zones in the sheet's lower-left frame; parts, cut-offs and motions
+/// use the signed quadrant frame. Every clamp check must compare in the placement frame.
+/// Fixtures use a real so an already-signed hand-made zone cannot hide
+/// a missing translation. Expected footprints come from the physical rule: a clamp centered c
+/// covers c +/- width/2 from the origin along the edge and depth into the sheet.
+///
+public class ClampZonePlacementFrameTests
+{
+ private const double SheetWidth = 81;
+ private const double SheetLength = 120;
+
+ private static int XSign(int quadrant) => quadrant is 2 or 3 ? -1 : 1;
+ private static int YSign(int quadrant) => quadrant is 3 or 4 ? -1 : 1;
+
+ private static Box WorldFootprint(int quadrant, double center, double width, double depth)
+ {
+ var near = center - width / 2;
+ var far = center + width / 2;
+ var x = XSign(quadrant) > 0 ? near : -far;
+ var y = YSign(quadrant) > 0 ? 0 : -depth;
+ return new Box(x, y, width, depth);
+ }
+
+ private static Plate RailPlate(int quadrant, params double[] centers)
+ {
+ var plate = new Plate(SheetWidth, SheetLength) { Quadrant = quadrant };
+ plate.SetClampRail(new ClampRail(1.5, 0.625, centers));
+ return plate;
+ }
+
+ private static Nest NestOf(Plate plate)
+ {
+ var nest = new Nest();
+ nest.Plates.Add(plate);
+ return nest;
+ }
+
+ private static Part Rectangle(double length, double width, Vector location, bool scribe = false)
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(length, 0);
+ program.LineTo(length, width);
+ program.LineTo(0, width);
+ program.LineTo(0, 0);
+ if (scribe)
+ foreach (var line in program.Codes.OfType())
+ line.Layer = LayerType.Scribe;
+ return new Part(new Drawing(scribe ? "scribe" : "rectangle", program), location);
+ }
+
+ private static void AssertSameBox(Box expected, Box actual)
+ {
+ Assert.Equal(expected.Left, actual.Left, 9);
+ Assert.Equal(expected.Bottom, actual.Bottom, 9);
+ Assert.Equal(expected.Length, actual.Length, 9);
+ Assert.Equal(expected.Width, actual.Width, 9);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void ToPlacement_MapsWholeFootprintsWithoutChangingStoredZones(int quadrant)
+ {
+ var plate = RailPlate(quadrant, 35, 120.5);
+ var stored = plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y)).ToArray();
+
+ var mapped = ClampZoneFrame.ToPlacement(plate);
+
+ Assert.Equal(new[] { "clamp-1", "clamp-2" }, mapped.Select(z => z.Id));
+ AssertSameBox(WorldFootprint(quadrant, 35, 1.5, 0.625), mapped[0].Bounds);
+ AssertSameBox(WorldFootprint(quadrant, 120.5, 1.5, 0.625), mapped[1].Bounds);
+ Assert.Equal(stored, plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y)));
+ Assert.All(mapped, z => Assert.DoesNotContain(z, plate.ClampZones));
+ }
+
+ [Fact]
+ public void ToPlacement_RejectsMissingOrMalformedZones()
+ {
+ var plate = new Plate(24, 48);
+ var zone = new ClampZone("front", new Box(10, 0, 1.5, 0.625));
+ plate.ClampZones.Add(zone);
+ zone.Bounds.Width = -1;
+ Assert.Throws(() => ClampZoneFrame.ToPlacement(plate));
+ zone.Bounds.Width = 0.625;
+ zone.Bounds.X = double.NaN;
+ Assert.Throws(() => ClampZoneFrame.ToPlacement(plate));
+ plate.ClampZones.Clear();
+ plate.ClampZones.Add(null!);
+ Assert.Throws(() => ClampZoneFrame.ToPlacement(plate));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void ManualCutoffsAcrossRailClampAreFoundInEveryQuadrant(int quadrant)
+ {
+ var plate = RailPlate(quadrant, 35);
+ var footprint = WorldFootprint(quadrant, 35, 1.5, 0.625);
+ plate.CutOffs.Add(new CutOff(new Vector(XSign(quadrant) * 35, 0), CutOffAxis.Vertical));
+ plate.CutOffs.Add(new CutOff(new Vector(0, YSign(quadrant) * 0.3), CutOffAxis.Horizontal));
+ plate.CutOffs.Add(new CutOff(new Vector(XSign(quadrant) * 50, 0), CutOffAxis.Vertical));
+
+ var findings = ClampCutDiagnostics.Find(plate);
+
+ Assert.Equal(new[] { 0, 1 }, findings.Select(f => f.CutOffIndex));
+ Assert.All(findings, f =>
+ {
+ Assert.Equal("clamp-1", f.ClampId);
+ Assert.InRange(f.Location.X, footprint.Left, footprint.Right);
+ Assert.InRange(f.Location.Y, footprint.Bottom, footprint.Top);
+ });
+ var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate));
+ Assert.Equal(2, report.CutOffClampFindings.Count);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void PlateCaptureFindsPartMaterialOnRailClampInEveryQuadrant(int quadrant)
+ {
+ var plate = RailPlate(quadrant, 35);
+ // A 4x4 square from 33 to 37 along the edge, flush with the front edge.
+ plate.Parts.Add(Rectangle(4, 4, new Vector(XSign(quadrant) > 0 ? 33 : -37,
+ YSign(quadrant) > 0 ? 0 : -4)));
+ plate.Parts.Add(Rectangle(4, 4, new Vector(XSign(quadrant) > 0 ? 50 : -54,
+ YSign(quadrant) > 0 ? 0 : -4)));
+
+ var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate));
+
+ var finding = Assert.Single(report.ClampFindings);
+ Assert.Equal(0, finding.PartAId);
+ Assert.Contains("clamp-1", finding.PartBName);
+ Assert.Equal(1.5 * 0.625, finding.Area, 3);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void PostingWarnsForCutoffRapidAndPartOnRailClampInEveryQuadrant(int quadrant)
+ {
+ var cutPlate = RailPlate(quadrant, 35);
+ cutPlate.CutOffs.Add(new CutOff(new Vector(XSign(quadrant) * 35, 0), CutOffAxis.Vertical));
+ cutPlate.RegenerateCutOffs(new CutOffSettings());
+ var cutReport = PostVerificationAnalyzer.Analyze(NestOf(cutPlate));
+ Assert.Contains(cutReport.Findings, f => f.Kind == PostVerificationKind.Overlap && f.ClampId == "clamp-1");
+ Assert.False(cutReport.CanPost(false));
+ Assert.True(cutReport.CanPost(true));
+
+ // The incoming rapid (0,0) -> (+/-40, +/-0.3) passes through the clamp at 35.
+ var rapidPlate = RailPlate(quadrant, 35);
+ rapidPlate.Parts.Add(Rectangle(0.1, 0.1,
+ new Vector(XSign(quadrant) * 40, YSign(quadrant) * 0.3), scribe: true));
+ var rapidReport = PostVerificationAnalyzer.Analyze(NestOf(rapidPlate));
+ var rapid = Assert.Single(rapidReport.Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
+ Assert.Equal("clamp-1", rapid.ClampId);
+ Assert.False(rapidReport.CanPost(false));
+
+ var partPlate = RailPlate(quadrant, 35);
+ partPlate.Parts.Add(Rectangle(4, 4, new Vector(XSign(quadrant) > 0 ? 33 : -37,
+ YSign(quadrant) > 0 ? 0 : -4)));
+ var partReport = PostVerificationAnalyzer.Analyze(NestOf(partPlate));
+ Assert.Contains(partReport.Findings, f => f.Kind == PostVerificationKind.Overlap
+ && f.PartNumber == 1 && f.OtherPartNumber == null && f.Overlap != null
+ && f.Message.Contains("clamp-1"));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void RailClampsAwayFromCutoffsAndRapidsProduceNoClampWarnings(int quadrant)
+ {
+ // The cut-off and every rapid stay between the origin and the clamp at 35.
+ var plate = RailPlate(quadrant, 35);
+ plate.CutOffs.Add(new CutOff(new Vector(XSign(quadrant) * 20, 0), CutOffAxis.Vertical));
+ plate.RegenerateCutOffs(new CutOffSettings());
+ plate.Parts.Add(Rectangle(0.1, 0.1, new Vector(XSign(quadrant) * 10, YSign(quadrant) * 5),
+ scribe: true));
+ var report = PostVerificationAnalyzer.Analyze(NestOf(plate));
+ Assert.DoesNotContain(report.Findings, f => f.ClampId != null || f.Message.Contains("clamp"));
+ }
+
+ [Theory]
+ [InlineData("NaN")]
+ [InlineData("negative")]
+ [InlineData("overflow")]
+ [InlineData("null")]
+ public void MalformedActiveClampMakesPostingIncompleteEvenWithoutRapidsOrCutoffs(string invalid)
+ {
+ var plate = new Plate(24, 48);
+ if (invalid == "null")
+ plate.ClampZones.Add(null!);
+ else
+ {
+ var zone = new ClampZone("front", new Box(10, 0, 1.5, 0.625));
+ plate.ClampZones.Add(zone);
+ if (invalid == "NaN")
+ zone.Bounds.Y = double.NaN;
+ else if (invalid == "negative")
+ zone.Bounds.Width = -1;
+ else
+ {
+ zone.Bounds.X = 1e308;
+ zone.Bounds.Length = 1e308;
+ }
+ }
+ // Cut-first scribe: no nonzero rapid, no cut-off definitions.
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Scribe });
+ plate.Parts.Add(new Part(new Drawing("scribe", program)));
+
+ var report = PostVerificationAnalyzer.Analyze(NestOf(plate));
+
+ Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Incomplete
+ && f.Message.StartsWith("Clamp check:"));
+ Assert.False(report.CanPost(false));
+ Assert.Throws(() => PlateOverlapAnalyzer.CapturePlate(plate));
+ }
+
+ [Fact]
+ public void QuadrantChangeMovesExplicitZonesAndStalesDisplayedClampFindings()
+ {
+ var plate = new Plate(24, 48);
+ plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625)));
+ plate.Parts.Add(Rectangle(4, 4, new Vector(9.25, 0)));
+ var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate));
+ Assert.Single(report.ClampFindings);
+ var state = new OverlapReportState();
+ Assert.True(state.TryPublish(state.Begin(plate), plate, report));
+ Assert.Single(state.DisplayPairs);
+
+ // The stored zone bits are unchanged, but in quadrant 4 the zone is at Y -24..-23.375.
+ plate.Quadrant = 4;
+
+ Assert.False(state.EnsureFresh(plate));
+ Assert.Empty(state.DisplayPairs);
+ }
+}
diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md
index 49c9805..c9d1b63 100644
--- a/docs/automatic-scrap-cutoffs.md
+++ b/docs/automatic-scrap-cutoffs.md
@@ -21,7 +21,7 @@ Only real parts contribute to the occupied envelope; existing cutoff parts do no
Cutoff trimming measures clearance from the part contour, excluding tagged lead-in and lead-out moves when building the closed outline. Leads must not turn a recessed outline into a convex hull and leave oversized gaps elsewhere. Genuinely open contours still use the conservative hull/bounds fallback. The conservative curve/offset approximation can leave a small extra gap (about 0.0003 in on a straight edge at the default 0.02-in clearance), not an extra quarter inch.
-If no safe separator fits inside the sheet, there is no retained-tail claim and the nominal cuts may cover the full sheet length. A proposed tail is not reported as separated unless its generated program spans the full sheet width. An active clamp blocks any proposed full-width line whose X coordinate falls in the clamp's whole footprint plus bottom-edge clearance, including a nominal grid line or an equivalent existing line. A tolerance-close existing line is tested at its own X as well as the proposed X, because that existing line is what would be certified. A same-line manual cut with different limits still blocks the whole plan, even on a clamp. Blocked lines are skipped with a clamp diagnostic; a blocked tail also reports `NoSafeTailSeparator` rather than claiming separation. Removed clamps on a sheet are absent from its active zones and do not block cuts. Obstructed or minimum-length-filtered lines produce diagnostics; they are not proof of a completed partition.
+If no safe separator fits inside the sheet, there is no retained-tail claim and the nominal cuts may cover the full sheet length. A proposed tail is not reported as separated unless its generated program spans the full sheet width. An active clamp blocks any proposed full-width line whose X coordinate falls in the clamp's whole footprint plus bottom-edge clearance, including a nominal grid line or an equivalent existing line. A tolerance-close existing line is tested at its own X as well as the proposed X, because that existing line is what would be certified. A same-line manual cut with different limits still blocks the whole plan, even on a clamp. Blocked lines are skipped with a clamp diagnostic; a blocked tail also reports `NoSafeTailSeparator` rather than claiming separation. Removed clamps on a sheet are absent from its active zones and do not block cuts. Stored zones are relative to the sheet's lower-left corner; the planner maps them into the plate's quadrant frame (the same frame as part and cut-off coordinates) before comparing, so the rule holds in all four quadrants. Obstructed or minimum-length-filtered lines produce diagnostics; they are not proof of a completed partition.
For a 120-by-81-inch sheet whose parts extend through 80 inches, the nominal skeleton lines are at 35 and 70 inches, followed by a separator beyond 80 inches at the required margin. There is no line at 105 inches through the retained tail.
diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md
index a56715b..4e40e1e 100644
--- a/docs/geometry/visual-overlap-check.md
+++ b/docs/geometry/visual-overlap-check.md
@@ -164,8 +164,10 @@ clamps and nominal manual cut-off definitions. `CutOffClampFindings` is a separa
list of zero-area line/rectangle contacts, not `Pairs` or material overlap. The
view marks these with an X and a cut-off number/clamp ID, including contacts whose
current regenerated program has been trimmed. Removed clamps produce no contact.
-Editing definitions, sheet size/quadrant or zones stales the result; posting runs
-its own fresh check with per-attempt consent.
+Zones are mapped from the sheet's lower-left frame into the parts' quadrant frame; a
+malformed zone fails the check rather than being skipped. Editing definitions, sheet
+size/quadrant or zones stales the result (including part/clamp findings, because a size or
+quadrant change moves the zones); posting runs its own fresh check with per-attempt consent.
## Interpretation and limits
diff --git a/docs/post-verification.md b/docs/post-verification.md
index 841eae6..b642fbc 100644
--- a/docs/post-verification.md
+++ b/docs/post-verification.md
@@ -57,6 +57,8 @@ shows all three check categories, even when there are no findings:
a sufficiently large cutout is not itself an overlap. The same review also warns if a nominal
manual cut-off line crosses an active clamp, even when its regenerated cut is currently trimmed;
a line contact has no material-overlap area. Removed clamps on that sheet are not checked.
+ Clamp zones are compared in the plate's quadrant frame. A missing or malformed clamp zone
+ makes the clamp checks incomplete (a warning), never a clear result.
This uses clean drawing geometry, not added lead-in/out paths.
- **Missing lead-ins:** cutting contours without an actual, nonzero lead-in in the
placed program. Applying a lead-in to only one contour does not clear the other