fix(core): compare clamp cut checks in the plate's quadrant frame

Plate.ClampZones are stored relative to the sheet's lower-left corner,
but cut-off positions, motions and part poses use the signed quadrant
frame. The automatic cutoff planner, manual cut-off contacts, clamp
rapid warnings and whole-plate overlap capture compared the two
directly, so in quadrants 2-4 a cut or rapid through a real rail clamp
was neither avoided nor warned about.

Add ClampZoneFrame.ToPlacement, which maps live zones into the
placement frame exactly like the sheet and engine stock, and use it in
every Slice 5 consumer. It rejects missing or malformed zones (bounds
stay publicly mutable); pre-post verification reports those as an
incomplete clamp check instead of a clear result. Overlap freshness now
treats a size or quadrant change as moving the zones.

Tests use real ClampRail zones in all quadrants; the hand-signed
quadrant fixtures that hid the defect are converted to rails.

Project Memory: opennest 274836d5-fb76-4619-b3cc-0c811e767dd6
This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent bde332fb10
commit 9a293bf221
11 files changed
+437 -40

No files matched your search

+4 -4
View File
@@ -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));
+54
View File
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest;
/// <summary>
/// Maps a live plate's <see cref="Plate.ClampZones"/>, stored in the sheet's lower-left frame
/// (<see cref="ClampRail.ResolveFor"/>), 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 <c>NestPlateStock</c> already
/// carry placement-frame zones and must not be mapped again.
/// </summary>
public static class ClampZoneFrame
{
/// <summary>
/// Detached placement-frame copies in the plate's zone order; empty when there are none.
/// Throws <see cref="ArgumentException"/> for a missing or malformed zone: zone bounds stay
/// publicly mutable, so construction-time validation is not proof of current geometry.
/// </summary>
public static IReadOnlyList<ClampZone> ToPlacement(Plate plate)
{
ArgumentNullException.ThrowIfNull(plate);
var zones = plate.ClampZones;
if (zones == null || zones.Count == 0)
return Array.Empty<ClampZone>();
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;
}
}
@@ -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 (<see cref="ClampZoneFrame"/>).
/// </summary>
public static class ClampCutDiagnostics
{
@@ -20,6 +21,7 @@ public static class ClampCutDiagnostics
var findings = new List<CutOffClampFinding>();
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));
}
@@ -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]))
@@ -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 <see cref="ArgumentException"/>.
/// </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(),
var material = Capture(plate.Parts.ToArray(), ClampZoneFrame.ToPlacement(plate).ToArray(),
cache ?? new OverlapMaterialCache(), cancellationToken);
var cutoffs = ClampCutDiagnostics.Find(plate);
cancellationToken.ThrowIfCancellationRequested();
@@ -56,29 +56,45 @@ public static class PostVerificationAnalyzer
}
var materialParts = new List<Part>();
var indices = new List<int>();
// 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<ClampZone> 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<ClampZone>()).ToArray(),
cancellationToken),
cancellationToken);
foreach (var pair in overlap.Pairs)
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
@@ -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)]
@@ -0,0 +1,258 @@
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("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 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);
}
}
+1 -1
View File
@@ -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.
+4 -2
View File
@@ -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
+2
View File
@@ -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