Files
OpenNest/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs
T
aj c57e0ded83 fix(diagnostics): keep tiny clamps beyond coarse chords in the overlap check
Check Overlaps dismissed a tiny clamp by the part's coarse material bounds
before the fine tolerance-free test ran, but that material is inscribed at
the 0.001 chord tolerance: real material in the sliver beyond a convex arc
(a short arc cap, a rotated crescent) lay outside those bounds and the clamp
read as clear. For clamps below the tolerance the bounds are widened by the
chord tolerance before rejecting.

PM 407f5eb7-329f-41e4-947e-64ad52aa4034 (review s6-delta3-review.md)
2026-10-13 18:15:50 -04:00

316 lines
13 KiB
C#

using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Diagnostics;
/// <summary>
/// Slice 4 (C1): the material-overlap analyzer reports part-vs-clamp findings like
/// other overlap findings - owned fragments, area and centroid - and they flow into
/// hover text and paging. Files/plates without zones behave exactly as before.
/// </summary>
public class PlateOverlapAnalyzerClampZoneTests
{
private static Program Rect(double lengthX, double widthY)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(lengthX, 0);
program.LineTo(lengthX, widthY);
program.LineTo(0, widthY);
program.LineTo(0, 0);
return program;
}
private static Drawing Square(string name, double size) => new(name, Rect(size, size));
private static readonly ClampZone Front1 = new("front-1", new Box(10, 0, 1.5, 0.625));
private static readonly ClampZone Front2 = new("front-2", new Box(20, 0, 1.5, 0.625));
[Fact]
public void ManualCutoffCrossingClampAppearsInOverlapCheckWithoutBeingMaterial()
{
var plate = new Plate(new Size(24, 48));
plate.ClampZones.Add(Front1);
plate.CutOffs.Add(new CutOff(new Vector(10.5, 0), CutOffAxis.Vertical));
var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate));
Assert.Empty(report.Pairs);
Assert.Empty(report.ClampFindings);
Assert.True(report.IsComplete);
var finding = Assert.Single(report.CutOffClampFindings);
Assert.Equal("front-1", finding.ClampId);
Assert.Equal(0, finding.CutOffIndex);
Assert.InRange(finding.Location.X, 10, 11.5);
var state = new OverlapReportState();
Assert.True(state.TryPublish(state.Begin(plate), plate, report));
Assert.Contains("cut-off", state.Message);
plate.CutOffs.Clear();
Assert.False(state.EnsureFresh(plate));
Assert.Equal(OverlapCheckStatus.Stale, state.Status);
}
[Fact]
public void PartOverlappingClampProducesHoleAwareFinding()
{
// 4x4 square at (9.25, 0): the shared fragment with front-1 is exactly its footprint.
var parts = new List<Part> { new(Square("plate-part", 4), new Vector(9.25, 0)) };
var report = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(parts, new[] { Front1, Front2 }));
Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
var finding = Assert.Single(report.ClampFindings);
Assert.Equal(0, finding.PartAId);
Assert.Equal("plate-part", finding.PartAName);
Assert.Contains("front-1", finding.PartBName);
Assert.Equal(1.5 * 0.625, finding.Area, 3);
Assert.InRange(finding.Centroid.X, 10 - 1e-3, 11.5 + 1e-3);
Assert.InRange(finding.Centroid.Y, 0 - 1e-3, 0.625 + 1e-3);
}
[Fact]
public void ClearPartsProduceNoClampFindings()
{
var parts = new List<Part>
{
new(Square("far", 4), new Vector(30, 10)),
new(Square("behind", 2), new Vector(10, 5)), // between the clamps, above them
};
var report = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(parts, new[] { Front1, Front2 }));
Assert.True(report.IsComplete);
Assert.Empty(report.Pairs);
Assert.Empty(report.ClampFindings);
}
[Fact]
public void LegacyCaptureFindsNoClampsAndKeepsPartPairs()
{
var parts = new List<Part>
{
new(Square("a", 4), new Vector(0, 0)),
new(Square("b", 4), new Vector(3, 0)), // overlaps a
};
var legacy = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts));
Assert.Single(legacy.Pairs);
Assert.Empty(legacy.ClampFindings);
var explicitEmpty = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(parts, System.Array.Empty<ClampZone>()));
Assert.Equal(Describe(legacy), Describe(explicitEmpty));
}
[Fact]
public void IncrementalRecheckAfterMovingAClampMatchesAFullAnalysis()
{
var parts = new List<Part>
{
new(Square("a", 4), new Vector(9.25, 0)), // hits the zone at X 10
new(Square("b", 4), new Vector(30, 10)), // clear of the zone at X 10
};
var moved = new ClampZone("front-1", new Box(29, 10, 1.5, 0.625)); // now hits b
var previous = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(parts, new[] { Front1 }));
Assert.Equal(0, Assert.Single(previous.ClampFindings).PartAId);
var incremental = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(parts, new[] { moved }), previous);
var oracle = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(parts, new[] { moved }));
Assert.Equal(Describe(oracle), Describe(incremental));
Assert.Equal(1, Assert.Single(incremental.ClampFindings).PartAId);
}
[Fact]
public void FindingsRenderAsClampDetailsAndPageLikeOtherFindings()
{
var parts = new List<Part>
{
new(Square("hit-one", 4), new Vector(9.25, 0)),
new(Square("hit-two", 4), new Vector(19.25, 0)),
new(Square("also-hit-two", 4), new Vector(20.5, 0.3)),
};
var report = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(parts, new[] { Front1, Front2 }));
Assert.Equal(3, report.ClampFindings.Count); // hit-one/1, hit-two/2, hit-two/3 -> two zones each
var finding = report.ClampFindings.First(f => f.PartAName == "hit-one");
Assert.True(OverlapPairPresentation.IsClampFinding(finding));
var details = OverlapPairPresentation.Details(finding, Units.Inches, CultureInfo.InvariantCulture);
Assert.Contains("front-1", details);
Assert.Contains("hit-one", details);
Assert.DoesNotContain("Pair ", details);
// Hover text joins every finding; paging must reach all of them.
var text = string.Join("\n\n", report.ClampFindings.Select(f =>
OverlapPairPresentation.Details(f, Units.Inches, CultureInfo.InvariantCulture)));
var pages = OverlapHoverPages.Create(text, report.ClampFindings.Count, 6, 60, s => s.Length);
Assert.True(pages.PageCount > 1);
var all = string.Concat(Enumerable.Range(0, pages.PageCount)
.Select(page => { pages.MovePage(page == 0 ? 0 : 1); return string.Concat(pages.Lines); }));
Assert.Contains("front-2", all);
Assert.Contains("also-hit-two", all);
}
[Fact]
public void PublishedClampFindingIsVisibleAndZoneEditInvalidatesIt()
{
var plate = new Plate(new Size(24, 48));
plate.Parts.Add(new Part(Square("hit", 4), new Vector(9.25, 0)));
plate.ClampZones.Add(Front1);
var state = new OverlapReportState();
var generation = state.Begin(plate);
var report = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), plate.ClampZones.ToArray()));
Assert.True(state.TryPublish(generation, plate, report));
Assert.Single(state.DisplayPairs);
Assert.Contains("clamp", state.Message, System.StringComparison.OrdinalIgnoreCase);
plate.ClampZones.Clear();
state.Invalidate(plate);
Assert.Empty(state.DisplayPairs);
Assert.Equal(OverlapCheckStatus.Stale, state.Status);
}
[Fact]
public void PublicationRefusesZonesChangedWhileWorkerWasRunning()
{
var plate = new Plate(new Size(24, 48));
plate.Parts.Add(new Part(Square("hit", 4), new Vector(9.25, 0)));
plate.ClampZones.Add(Front1);
var state = new OverlapReportState();
var generation = state.Begin(plate);
var report = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), plate.ClampZones.ToArray()));
plate.ClampZones.Clear();
Assert.False(state.TryPublish(generation, plate, report));
Assert.NotEqual(OverlapCheckStatus.Current, state.Status);
}
// C16 (PM 6859db53): a 1e-5 square zone, far below the outline tolerance. A part covering
// it is still reported (never silently clear); flush or clear parts are not.
[Theory]
[InlineData(7.5, true)]
[InlineData(6.0, false)]
[InlineData(8.00001, false)]
[InlineData(3.0, false)]
public void TinyClampZoneIsJudgedWithoutOutlineCollapse(double x, bool overlaps)
{
var tiny = new ClampZone("tiny", new Box(8, 0, 1e-5, 1e-5));
var parts = new List<Part> { new(Square("p", 2), new Vector(x, 0)) };
var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, new[] { tiny }));
Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
Assert.Equal(overlaps, report.ClampFindings.Any(f => f.PartBName.Contains("tiny")));
}
// PM 407f5eb7: real material, not the 0.001 arc flattening, decides a tiny clamp.
[Theory]
[InlineData("rect", 8.00002, 0.5, true)]
[InlineData("rect", 8.00005, 0.5, false)]
[InlineData("disk", 1.9998, 0, true)]
[InlineData("disk", 2.0002, 0, false)]
[InlineData("disk-box", 11.935615871667656, 10.503377293956166, true)] // arc cuts a corner
public void TinyClampIsJudgedAgainstRealMaterial(string material, double a, double b, bool overlaps)
{
Program program;
Box zone;
if (material == "rect")
{
program = Rect(8.00004, 2);
zone = new Box(a, b, 1e-5, 1e-5);
}
else
{
program = new Program();
program.MoveTo(12, 10);
program.ArcTo(8, 10, 10, 10, RotationType.CCW);
program.ArcTo(12, 10, 10, 10, RotationType.CCW);
if (material == "disk-box")
{
zone = new Box(a, b, 1e-5, 1e-5);
}
else
{
var cx = 10 + a * System.Math.Cos(0.02);
var cy = 10 + a * System.Math.Sin(0.02);
zone = new Box(cx - 0.000005, cy - 0.000005, 1e-5, 1e-5);
}
}
var parts = new List<Part> { new(new Drawing(material, program), new Vector(0, 0)) };
var report = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(parts, new[] { new ClampZone("tiny", zone) }));
Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
Assert.Equal(overlaps, report.ClampFindings.Count > 0);
}
// Delta review 2: material beside a concave (notch) arc is not lost for a tiny clamp.
[Fact]
public void TinyClampBesideAConcaveArcIsReported()
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(20, 0);
program.LineTo(20, 20);
program.LineTo(12, 20);
program.ArcTo(8, 20, 10, 20, RotationType.CW);
program.LineTo(0, 20);
program.LineTo(0, 0);
var arc = new ShapeProfile(OpenNest.Converters.ConvertProgram.ToGeometry(program))
.Perimeter.Entities.OfType<Arc>().Single();
var native = arc.ToPoints(arc.SegmentsForTolerance(1e-5 / 4)).First(v => v.X > 11.9 && v.Y < 19.5);
var centre = new Vector(10, 20);
var outward = centre + (native - centre) / 2 * (2 + 6e-7);
var zone = new ClampZone("tiny", new Box(outward.X - 1e-5, outward.Y, 1e-5, 1e-5));
var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(
new List<Part> { new(new Drawing("notch", program), new Vector(0, 0)) }, new[] { zone }));
Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
Assert.NotEmpty(report.ClampFindings);
}
// Delta review 3: a tiny clamp in the sliver beyond a convex arc's 0.001 chords must not
// be dismissed by the coarse material bounds before the fine check runs.
[Fact]
public void TinyClampBeyondCoarseChordsIsReported()
{
const double r = 0.01, half = 0.04;
var dx = r * System.Math.Sin(half);
var cy = 20 - r * System.Math.Cos(half);
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(20, 0);
program.LineTo(20, 20);
program.LineTo(10 + dx, 20);
program.ArcTo(10 - dx, 20, 10, cy, RotationType.CCW);
program.LineTo(0, 20);
program.LineTo(0, 0);
// Witness (10, 20.000002) is inside the arc (radius 0.009994 < 0.01) and the zone.
var zone = new ClampZone("tiny", new Box(9.999995, 20.000001199840007, 1e-5, 1e-5));
var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(
new List<Part> { new(new Drawing("cap", program), new Vector(0, 0)) }, new[] { zone }));
Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
Assert.NotEmpty(report.ClampFindings);
}
private static string Describe(PlateOverlapReport report) =>
string.Join(";", report.Pairs.Select(p => $"{p.PartAId}/{p.PartBId}:{p.Area:R}"))
+ "|"
+ string.Join(";", report.ClampFindings.Select(f => $"{f.PartAId}/{f.PartBName}:{f.Area:R}"));
}