feat(diagnostics): warn on cutoffs and rapids crossing clamps

This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent 3014ab3480
commit d7187f65c7
14 files changed
+376 -21

No files matched your search

@@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>A nominal manual cut-off line meeting an active, physical clamp footprint.</summary>
public sealed record CutOffClampFinding(int CutOffIndex, string ClampId, Vector Location);
/// <summary>
/// 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.
/// </summary>
public static class ClampCutDiagnostics
{
public static IReadOnlyList<CutOffClampFinding> Find(Plate plate)
{
ArgumentNullException.ThrowIfNull(plate);
var findings = new List<CutOffClampFinding>();
if (plate.ClampZones.Count == 0 || plate.CutOffs.Count == 0)
return findings.AsReadOnly();
var sheet = plate.BoundingBox(false);
for (var index = 0; index < plate.CutOffs.Count; index++)
{
var cut = plate.CutOffs[index];
if (cut == null || !Enum.IsDefined(cut.Axis))
throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate));
var start = cut.Axis == CutOffAxis.Vertical
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom)
: new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y);
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)
if (Intersects(start, end, zone.Bounds, out var location))
findings.Add(new CutOffClampFinding(index, zone.Id, location));
}
return findings.AsReadOnly();
}
/// <summary>True for contact with the whole rectangle, including a boundary touch.</summary>
public static bool Intersects(Vector from, Vector to, Box box, out Vector location)
{
location = default;
if (box == null || !Finite(from) || !Finite(to) || !Finite(box.Location)
|| !double.IsFinite(box.Right) || !double.IsFinite(box.Top)
|| box.Length <= 0 || box.Width <= 0)
throw new ArgumentException("Clamp contact geometry must be finite and nonempty.");
var dx = to.X - from.X;
var dy = to.Y - from.Y;
if (!double.IsFinite(dx) || !double.IsFinite(dy)
|| !double.IsFinite(from.X - box.Left) || !double.IsFinite(box.Right - from.X)
|| !double.IsFinite(from.Y - box.Bottom) || !double.IsFinite(box.Top - from.Y))
throw new ArgumentException("Clamp contact motion is too large.");
var enter = 0.0;
var exit = 1.0;
if (!Clip(-dx, from.X - box.Left, ref enter, ref exit)
|| !Clip(dx, box.Right - from.X, ref enter, ref exit)
|| !Clip(-dy, from.Y - box.Bottom, ref enter, ref exit)
|| !Clip(dy, box.Top - from.Y, ref enter, ref exit))
return false;
var midpoint = (enter + exit) * 0.5;
location = new Vector(from.X + dx * midpoint, from.Y + dy * midpoint);
return true;
}
private static bool Clip(double p, double q, ref double enter, ref double exit)
{
if (p == 0)
return q >= 0;
var ratio = q / p;
if (p < 0)
enter = System.Math.Max(enter, ratio);
else
exit = System.Math.Min(exit, ratio);
return enter <= exit;
}
private static bool Finite(Vector value) => double.IsFinite(value.X) && double.IsFinite(value.Y);
}
@@ -12,10 +12,13 @@ public sealed class OverlapGeometryStamp
private readonly Plate plate;
private readonly Entry[] entries;
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End)[] cutoffs;
private readonly (long Width, long Length, int Quadrant) sheet;
private OverlapGeometryStamp(Plate plate)
{
this.plate = plate;
sheet = (Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant);
entries = new Entry[plate.Parts.Count];
for (var i = 0; i < entries.Length; i++)
entries[i] = new Entry(plate.Parts[i]);
@@ -26,8 +29,16 @@ public sealed class OverlapGeometryStamp
var box = zone.Bounds;
zones[i] = (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
}
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?)[plate.CutOffs.Count];
for (var i = 0; i < cutoffs.Length; i++)
cutoffs[i] = CutOffState(plate.CutOffs[i]);
}
private static (CutOff, CutOffAxis, long, long, long?, long?) CutOffState(CutOff cut) =>
(cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null);
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
@@ -46,9 +57,20 @@ public sealed class OverlapGeometryStamp
return true;
}
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))
return false;
for (var i = 0; i < cutoffs.Length; i++)
if (cutoffs[i] != CutOffState(current.CutOffs[i]))
return false;
return true;
}
public bool Matches(Plate current)
{
if (current.Parts.Count != entries.Length || !SameZones(current))
if (current.Parts.Count != entries.Length || !SameZones(current) || !SameCutOffs(current))
return false;
for (var i = 0; i < entries.Length; i++)
if (!entries[i].Matches(current.Parts[i]))
@@ -21,17 +21,22 @@ public sealed class OverlapReportState
public PlateOverlapReport Report { get; private set; }
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary>
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
public IReadOnlyList<CutOffClampFinding> DisplayCutOffClampFindings { get; private set; } =
Array.Empty<CutOffClampFinding>();
public bool IsRunning => Status == OverlapCheckStatus.Checking;
public string Message => Status switch
{
OverlapCheckStatus.Checking => "Checking overlaps…",
OverlapCheckStatus.Current => Report.ClampFindings.Count == 0
OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
? (Report.Pairs.Count == 0 ? "No material overlaps detected"
: $"Overlaps: {Report.Pairs.Count} pairs")
: $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps",
: $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, "
+ $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts",
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
+ (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ")
+ (Report.CutOffClampFindings.Count == 0 ? ""
: $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ")
+ $"{uncheckedPartCount} parts could not be checked",
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
OverlapCheckStatus.Canceled => "Overlap check canceled",
@@ -60,6 +65,7 @@ public sealed class OverlapReportState
Report = report;
DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs
: report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly();
DisplayCutOffClampFindings = report.CutOffClampFindings;
displayStamp = stamp;
observedDisplayStamp = stamp;
uncheckedPartCount = CountUncheckedParts(report.Issues);
@@ -111,11 +117,13 @@ public sealed class OverlapReportState
return;
var unchanged = displayStamp.UnchangedSlots(plate);
var sameZones = displayStamp.SameZones(plate);
if (!sameZones || !displayStamp.SameCutOffs(plate))
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId]
&& (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList();
if (retained.Count != DisplayPairs.Count)
DisplayPairs = retained.AsReadOnly();
if (DisplayPairs.Count == 0)
if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0)
ClearDisplayPairs();
else
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
@@ -124,6 +132,7 @@ public sealed class OverlapReportState
private void ClearDisplayPairs()
{
DisplayPairs = Array.Empty<PlateOverlapPair>();
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
displayStamp = null;
observedDisplayStamp = null;
}
@@ -17,6 +17,23 @@ public static class PlateOverlapAnalyzer
{
public const double ChordTolerance = 0.001;
/// <summary>
/// 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.
/// </summary>
public static PlateOverlapSnapshot CapturePlate(Plate plate, OverlapMaterialCache cache = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plate);
var material = Capture(plate.Parts.ToArray(), plate.ClampZones.ToArray(),
cache ?? new OverlapMaterialCache(), cancellationToken);
var cutoffs = ClampCutDiagnostics.Find(plate);
cancellationToken.ThrowIfCancellationRequested();
return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(),
material.Clamps.ToList(), cutoffs.ToList());
}
/// <summary>
/// Captures poses and converts each distinct clean source program to owned entities once.
/// Inputs must not change during capture. Later analysis never reads live domain objects.
@@ -226,7 +243,8 @@ public static class PlateOverlapAnalyzer
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
.ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList());
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(),
snapshot.CutOffClampFindings.ToList());
}
/// <summary>
@@ -8,12 +8,14 @@ namespace OpenNest.Diagnostics;
public sealed class PlateOverlapReport
{
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null)
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null,
List<CutOffClampFinding> cutOffClampFindings = null)
{
Pairs = pairs.AsReadOnly();
Issues = issues.AsReadOnly();
Snapshot = snapshot;
ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly();
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
}
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
@@ -28,6 +30,8 @@ public sealed class PlateOverlapReport
/// are reported like other overlap findings; they do not change <see cref="IsComplete"/>.
/// </summary>
public IReadOnlyList<PlateOverlapPair> ClampFindings { get; }
/// <summary>Nominal cut-off/clamp contacts. Lines have no material area or part pair.</summary>
public IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
public bool IsComplete => Issues.Count == 0;
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
@@ -112,11 +116,12 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message
public sealed class PlateOverlapSnapshot
{
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues,
List<CapturedClamp> clamps = null)
List<CapturedClamp> clamps = null, List<CutOffClampFinding> cutOffClampFindings = null)
{
Parts = parts.AsReadOnly();
Issues = issues.AsReadOnly();
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
}
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
@@ -124,6 +129,7 @@ public sealed class PlateOverlapSnapshot
/// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary>
internal IReadOnlyList<CapturedClamp> Clamps { get; }
internal IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
}
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
@@ -56,6 +56,30 @@ public static class PostVerificationAnalyzer
}
var materialParts = new List<Part>();
var indices = new List<int>();
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;
for (var index = 0; index < plate.Parts.Count; index++)
@@ -95,6 +119,27 @@ 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)
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)
if (ClampCutDiagnostics.Intersects(from, move.End, zone.Bounds, out var contact))
findings.Add(new(PostVerificationKind.RapidCrossing, plateNumber,
partNumber, null,
$"Direct XY rapid crosses active clamp '{zone.Id}'. "
+ "Head height is not modeled; review the posted route and clamp setup.")
{
Location = (from + move.End) * 0.5,
RapidStart = from,
RapidEnd = move.End,
ContactPoints = new[] { contact },
ClampId = zone.Id
});
}
position = moves[^1].End;
}
catch (Exception exception) when (IsInvalid(exception))
@@ -110,11 +155,16 @@ public static class PostVerificationAnalyzer
}
cancellationToken.ThrowIfCancellationRequested();
var overlap = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
PlateOverlapAnalyzer.Capture(materialParts, plate.ClampZones.ToArray(), cancellationToken),
cancellationToken);
foreach (var pair in overlap.Pairs)
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).")
{ Location = pair.Centroid, Overlap = pair });
foreach (var pair in overlap.ClampFindings)
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
null, $"Clean drawing material overlaps {pair.PartBName} (holes subtracted).")
{ Location = pair.Centroid, Overlap = pair });
foreach (var issue in overlap.Issues)
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
@@ -24,6 +24,8 @@ public sealed record PostVerificationFinding(PostVerificationKind Kind, int Plat
/// <summary>Rapid boundary contacts in travel order; collinear contact spans use their endpoints.</summary>
public IReadOnlyList<Vector> ContactPoints { get; init; } = Array.Empty<Vector>();
public PlateOverlapPair Overlap { get; init; }
/// <summary>Active clamp contacted by a cut-off or direct XY rapid, if applicable.</summary>
public string ClampId { get; init; }
}
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
@@ -0,0 +1,54 @@
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Tests.Diagnostics;
public class ClampCutDiagnosticsTests
{
[Theory]
[InlineData(CutOffAxis.Vertical, 10.5, 0, 1, true)]
[InlineData(CutOffAxis.Vertical, 10.5, 2, 4, false)]
[InlineData(CutOffAxis.Vertical, 14, 0, 2, false)]
[InlineData(CutOffAxis.Horizontal, 0.25, 9, 13, true)]
[InlineData(CutOffAxis.Horizontal, 2, 9, 13, false)]
[InlineData(CutOffAxis.Horizontal, 0.25, 12, 13, false)]
public void NominalLimitsAndBothAxesDetermineContact(CutOffAxis axis, double coordinate,
double start, double end, bool crosses)
{
var plate = new Plate(24, 48);
plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625)));
plate.CutOffs.Add(new CutOff(axis == CutOffAxis.Vertical
? new Vector(coordinate, 0) : new Vector(0, coordinate), axis)
{ StartLimit = start, EndLimit = end });
var findings = ClampCutDiagnostics.Find(plate);
Assert.Equal(crosses, findings.Count != 0);
if (crosses)
Assert.Equal("front", Assert.Single(findings).ClampId);
}
[Fact]
public void SegmentChecksPhysicalFootprintNotInfiniteLineOrPartSpacing()
{
var zone = new Box(10, 0, 1.5, 0.625);
Assert.True(ClampCutDiagnostics.Intersects(new Vector(9, 0.3), new Vector(12, 0.3),
zone, out var contact));
Assert.InRange(contact.X, 10, 11.5);
Assert.False(ClampCutDiagnostics.Intersects(new Vector(9, 3), new Vector(12, 3),
zone, out _));
Assert.False(ClampCutDiagnostics.Intersects(new Vector(9, 0.3), new Vector(9.9, 0.3),
zone, out _));
Assert.Throws<ArgumentException>(() => ClampCutDiagnostics.Intersects(
new Vector(double.NaN, 0), new Vector(12, 0), zone, out _));
}
[Fact]
public void OnlyActiveClampsAreReported()
{
var plate = new Plate(24, 48);
plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625)));
plate.CutOffs.Add(new CutOff(new Vector(10.5, 0), CutOffAxis.Vertical));
Assert.Single(ClampCutDiagnostics.Find(plate));
plate.ClampZones.Clear(); // Removed on this sheet; no longer an obstacle.
Assert.Empty(ClampCutDiagnostics.Find(plate));
}
}
@@ -31,6 +31,28 @@ public class PlateOverlapAnalyzerClampZoneTests
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()
{
@@ -98,6 +98,53 @@ public class PostVerificationAnalyzerTests
Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
}
[Fact]
public void ManualCutoffAndRapidCrossingClampRequireFreshPostingConsent()
{
var nest = NestWith(Rectangle(50, 20, 4));
var plate = Assert.Single(nest.Plates);
plate.ClampZones.Add(new ClampZone("front-3", new Box(10, 0, 1.5, 0.625)));
var cutoff = new CutOff(new Vector(10.5, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(cutoff);
plate.RegenerateCutOffs(new CutOffSettings());
var report = PostVerificationAnalyzer.Analyze(nest);
Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Overlap
&& f.Message.Contains("front-3") && f.Message.Contains("cut-off") && f.PlateNumber == 1);
Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.RapidCrossing
&& f.Message.Contains("front-3") && f.RapidStart.HasValue && f.RapidEnd.HasValue);
Assert.False(report.CanPost(false));
Assert.True(report.CanPost(true));
Assert.False(PostVerificationAnalyzer.Analyze(nest).CanPost(false));
plate.ClampZones.Clear();
var withoutClamps = PostVerificationAnalyzer.Analyze(nest);
Assert.DoesNotContain(withoutClamps.Findings, f => f.Message.Contains("front-3"));
}
[Fact]
public void InvalidManualCutoffGeometryIsIncompleteNotClear()
{
var nest = NestWith(Rectangle(40, 20, 4));
var plate = nest.Plates[0];
plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625)));
plate.CutOffs.Add(new CutOff(new Vector(double.NaN, 0), CutOffAxis.Vertical));
var report = PostVerificationAnalyzer.Analyze(nest);
Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Incomplete
&& f.Message.Contains("Clamp/cut-off check"));
Assert.False(report.CanPost(false));
}
[Fact]
public void PartMaterialOnClampAppearsInPrePostOverlapFindings()
{
var nest = NestWith(Rectangle(10, 0, 4));
nest.Plates[0].ClampZones.Add(new ClampZone("front-1", new Box(10, 0, 1.5, 0.625)));
var finding = Assert.Single(PostVerificationAnalyzer.Analyze(nest).Findings.Where(f =>
f.Kind == PostVerificationKind.Overlap && f.Message.Contains("front-1")));
Assert.Equal(1, finding.PartNumber);
Assert.Null(finding.OtherPartNumber);
Assert.NotNull(finding.Overlap);
}
[Fact]
public void ScribeAndCutoff_AreExemptFromOverlapAndLeadInChecks()
{
+29 -2
View File
@@ -148,8 +148,7 @@ internal sealed class OverlapOverlayController : IDisposable
try
{
// Do not include PreviewManager parts. Capture owns clean geometry and stable names.
var snapshot = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(),
plate.ClampZones.ToArray(), materialCache, token);
var snapshot = PlateOverlapAnalyzer.CapturePlate(plate, materialCache, token);
var previous = baseline;
var analyze = Analyze ?? ((input, cancel) => PlateOverlapAnalyzer.Analyze(input, previous, cancel));
var report = await Task.Run(() => analyze(snapshot, token), token);
@@ -381,6 +380,8 @@ internal sealed class OverlapOverlayController : IDisposable
if (ShowsCentroids)
DrawCentroids(graphics);
}
if (state.DisplayMode != OverlapDisplayMode.Off)
DrawCutOffClampContacts(graphics);
DrawStateLabel(graphics);
}
@@ -598,6 +599,32 @@ internal sealed class OverlapOverlayController : IDisposable
}
}
private void DrawCutOffClampContacts(Graphics graphics)
{
var dpi = view.DeviceDpi / 96f;
using var halo = new Pen(Color.White, 4 * dpi);
using var warning = new Pen(Color.DarkRed, 2 * dpi);
foreach (var finding in state.DisplayCutOffClampFindings)
{
var point = view.PointWorldToGraph(finding.Location);
if (!float.IsFinite(point.X) || !float.IsFinite(point.Y))
continue;
foreach (var pen in new[] { halo, warning })
{
graphics.DrawLine(pen, point.X - 8 * dpi, point.Y - 8 * dpi,
point.X + 8 * dpi, point.Y + 8 * dpi);
graphics.DrawLine(pen, point.X - 8 * dpi, point.Y + 8 * dpi,
point.X + 8 * dpi, point.Y - 8 * dpi);
}
var label = $"Cut-off {finding.CutOffIndex + 1} / {finding.ClampId}";
var size = graphics.MeasureString(label, view.Font);
var x = point.X + 10 * dpi;
var y = point.Y - size.Height;
graphics.FillRectangle(Brushes.White, x, y, size.Width, size.Height);
graphics.DrawString(label, view.Font, Brushes.DarkRed, x, y);
}
}
private void DrawStateLabel(Graphics graphics)
{
var saved = graphics.Save();
+4
View File
@@ -302,7 +302,11 @@ internal sealed class CuttingInspectionPanel : UserControl
view.ShowValidationFinding(finding with { Location = location });
var hint = finding.Kind switch
{
PostVerificationKind.Overlap when finding.ClampId != null =>
"The ring marks a nominal cut-off contacting a clamp; no material-overlap area is implied.",
PostVerificationKind.Overlap => "Shaded material is the overlap. The ring marks its area centroid, which can lie between separate patches.",
PostVerificationKind.RapidCrossing when finding.ClampId != null =>
"The crosshair marks the rapid's contact with the clamp. Head height is not modeled.",
PostVerificationKind.RapidCrossing => finding.ContactPoints.Count > 0
? "Crosshairs mark contacts with the completed contour. The pulsing dashed orange line shows the rapid."
: "The pulsing dashed orange line shows the rapid inside the completed contour; there is no boundary contact point to mark.",
+10 -2
View File
@@ -98,8 +98,8 @@ vertices cannot mutate the snapshot, report, or live geometry.
## Capture once, analyze off-thread
```csharp
// UI thread, while parts/drawings are stable:
var snapshot = PlateOverlapAnalyzer.Capture(parts, plate.ClampZones.ToArray(), cancellationToken);
// UI thread, while the plate and drawings are stable:
var snapshot = PlateOverlapAnalyzer.CapturePlate(plate, cancellationToken: cancellationToken);
// Worker thread, no access to live Part/Drawing/Program objects:
var report = await Task.Run(
@@ -159,6 +159,14 @@ triangulation, or `Collision.Check` call is not internally interruptible.
A report with any issue has `IsComplete == false`, even if `Pairs` is empty.
- No part, drawing, quantity, pose, cutting state, or selection is modified.
For a full plate, `PlateOverlapAnalyzer.CapturePlate(plate)` also captures active
clamps and nominal manual cut-off definitions. `CutOffClampFindings` is a separate
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.
## Interpretation and limits
Regions are the kernel's convex, hole-subtracted fragments, not merged connected
+14 -9
View File
@@ -52,18 +52,23 @@ The desktop's **Nest > Post > (post processor)** workflow checks every plate aft
post settings are accepted and before asking where to save CNC output. The review
shows all three check categories, even when there are no findings:
- **Overlapping parts:** shared material between placed parts, including containment.
Holes are subtracted; placing a part inside a sufficiently large cutout is not
itself an overlap. This uses clean drawing geometry, not added lead-in/out paths.
- **Overlapping parts and clamps:** shared material between placed parts, including containment,
and physical material overlapping an active clamp. Holes are subtracted; placing a part inside
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.
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
contours. Scribe-only work and scrap cutoff lines do not require lead-ins here.
The cutting planner supports direct contour piercing without lead-ins; that does
not suppress this pre-post review warning or grant posting consent.
- **Rapid crossings:** direct XY moves crossing closed, already-cut, untabbed
contours in cutting order. This includes earlier holes within the same part and
previously cut parts. A future cut is not an obstacle yet. Actual uncut gaps are
respected; enabling tabs in settings alone is not evidence that a gap exists.
contours in cutting order, or an active clamp's physical footprint. This includes
earlier holes within the same part and previously cut parts. A future cut is not
an obstacle yet. Actual uncut gaps are respected; enabling tabs in settings alone
is not evidence that a gap exists. Clamp-rapid warnings are conservative: head
height/retracts are not modeled, so inspect the emitted route and machine setup.
Findings identify the plate and part's position in the cutting sequence. Read the
scrollable report, cancel to fix the nest, then post again to rerun verification.
@@ -120,9 +125,9 @@ change does not silently alter their existing CNC output order.
These checks are warnings, not a machine safety certification. They inspect the
nest's placed programs and direct XY travel, not a simulation of final NC output.
A post processor or controller may change routing, cutting order, lead-ins,
retract height, or parking moves. The checks do not model tipping, slats, clamps,
actual tab strength, kerf, or safe Z height. Review the generated CNC program and
machine setup before cutting, even when this report has no warnings.
retract height, or parking moves. They check planar clamp footprints but not clamp
height, actual head clearance, tipping, slats or tab strength. Review the generated
CNC program and machine setup before cutting, even when this report has no warnings.
`OpenNest.Core/Diagnostics/PostVerificationAnalyzer` owns the cross-platform logic;
its report exposes findings, display text, and `CanPost(risksAcknowledged)`. Use