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