Files
OpenNest/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs
T
aj 3e9e336072 fix(diagnostics): tolerate missing cut-offs and zones in overlap freshness
The desktop captures the overlap freshness stamp before its error
boundary, and a null cut-off or clamp zone in the plate's lists threw
NullReferenceException there. Stamp missing entries as a distinct empty
state so the whole-plate capture can report the malformed input as a
failed check instead.

Project Memory: opennest cdbe2629-b562-46ee-9509-2b120a511654
2026-10-13 18:15:49 -04:00

320 lines
13 KiB
C#

using System.Linq;
using OpenNest.CNC;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Diagnostics;
/// <summary>
/// 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 <see cref="ClampRail"/> 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.
/// </summary>
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<LinearMove>())
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<ArgumentException>(() => ClampZoneFrame.ToPlacement(plate));
zone.Bounds.Width = 0.625;
zone.Bounds.X = double.NaN;
Assert.Throws<ArgumentException>(() => ClampZoneFrame.ToPlacement(plate));
plate.ClampZones.Clear();
plate.ClampZones.Add(null!);
Assert.Throws<ArgumentException>(() => 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(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void StalePlacedCutoffAcrossRailClampStillRequiresConsent(int quadrant)
{
// The placed vertical cut runs across the whole sheet width, through the clamp.
var plate = RailPlate(quadrant, 35);
var cut = new CutOff(new Vector(XSign(quadrant) * 35, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(cut);
plate.RegenerateCutOffs(new CutOffSettings { CutDirection = CutDirection.TowardOrigin });
// In quadrants 3 and 4 the incoming rapid also reaches the clamp; only the cut is asserted.
var current = PostVerificationAnalyzer.Analyze(NestOf(plate));
var nominal = Assert.Single(current.Findings, f => f.Kind == PostVerificationKind.Overlap);
Assert.Equal("clamp-1", nominal.ClampId);
Assert.Equal(1, nominal.PartNumber);
// The definition moves clear of the clamp, but the placed program is not regenerated.
cut.Position = new Vector(XSign(quadrant) * 50, 0);
Assert.Empty(ClampCutDiagnostics.Find(plate));
var stale = PostVerificationAnalyzer.Analyze(NestOf(plate));
var placed = Assert.Single(stale.Findings, f => f.Kind == PostVerificationKind.Overlap);
Assert.Equal("clamp-1", placed.ClampId);
Assert.Equal(1, placed.PartNumber);
Assert.Contains("placed", placed.Message);
var footprint = WorldFootprint(quadrant, 35, 1.5, 0.625);
var location = Assert.NotNull(placed.Location);
Assert.InRange(location.X, footprint.Left, footprint.Right);
Assert.InRange(location.Y, footprint.Bottom, footprint.Top);
Assert.False(stale.CanPost(false));
Assert.True(stale.CanPost(true));
}
[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<ArgumentException>(() => PlateOverlapAnalyzer.CapturePlate(plate));
}
[Fact]
public void FreshnessCaptureToleratesMissingCutOffsAndZones()
{
var plate = new Plate(24, 48);
plate.CutOffs.Add(null!);
plate.ClampZones.Add(null!);
var stamp = OverlapGeometryStamp.Capture(plate);
var state = new OverlapReportState();
state.Begin(plate);
Assert.True(stamp.Matches(plate));
// The whole-plate capture refuses the malformed input inside the desktop's error boundary.
Assert.Throws<ArgumentException>(() => PlateOverlapAnalyzer.CapturePlate(plate));
plate.CutOffs.Clear();
plate.CutOffs.Add(new CutOff(new Vector(5, 0), CutOffAxis.Vertical));
Assert.False(stamp.Matches(plate));
plate.CutOffs.Clear();
plate.CutOffs.Add(null!);
Assert.True(stamp.Matches(plate));
plate.ClampZones.Clear();
plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625)));
Assert.False(stamp.Matches(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);
}
}