diff --git a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs index fb7f33f4..b0b1a1d3 100644 --- a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs +++ b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs @@ -11,6 +11,7 @@ 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 OverlapGeometryStamp(Plate plate) { @@ -18,13 +19,36 @@ public sealed class OverlapGeometryStamp entries = new Entry[plate.Parts.Count]; for (var i = 0; i < entries.Length; i++) entries[i] = new Entry(plate.Parts[i]); + zones = new (string, long, long, long, long)[plate.ClampZones.Count]; + for (var i = 0; i < zones.Length; i++) + { + var zone = plate.ClampZones[i]; + var box = zone.Bounds; + zones[i] = (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width)); + } } + private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); + public static OverlapGeometryStamp Capture(Plate plate) => new(plate); + internal bool SameZones(Plate current) + { + if (!ReferenceEquals(plate, current) || current.ClampZones.Count != zones.Length) + return false; + for (var i = 0; i < zones.Length; i++) + { + var zone = current.ClampZones[i]; + var box = zone.Bounds; + if (zones[i] != (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width))) + return false; + } + return true; + } + public bool Matches(Plate current) { - if (!ReferenceEquals(plate, current) || current.Parts.Count != entries.Length) + if (current.Parts.Count != entries.Length || !SameZones(current)) return false; for (var i = 0; i < entries.Length; i++) if (!entries[i].Matches(current.Parts[i])) diff --git a/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs b/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs index 36669a8d..ee4fc473 100644 --- a/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs +++ b/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs @@ -9,12 +9,19 @@ namespace OpenNest.Diagnostics; /// Read-only pair presentation; no geometry preparation or collision queries. public static class OverlapPairPresentation { - public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}"; + /// True for part-vs-clamp findings carried on the report (negative clamp slot). + public static bool IsClampFinding(PlateOverlapPair pair) => pair.PartBId < 0; + + public static string Label(PlateOverlapPair pair) => + IsClampFinding(pair) ? $"{pair.PartAId + 1}/clamp" : $"{pair.PartAId + 1}/{pair.PartBId + 1}"; public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null) { var units = UnitsHelper.GetShortString(capturedUnits); - return $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}\n" + var head = IsClampFinding(pair) + ? $"Clamp overlap: {pair.PartAName} / {pair.PartBName}" + : $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}"; + return head + "\n" + $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n" + $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}"; } diff --git a/OpenNest.Core/Diagnostics/OverlapReportState.cs b/OpenNest.Core/Diagnostics/OverlapReportState.cs index 3da9309b..b88d5e5f 100644 --- a/OpenNest.Core/Diagnostics/OverlapReportState.cs +++ b/OpenNest.Core/Diagnostics/OverlapReportState.cs @@ -26,9 +26,12 @@ public sealed class OverlapReportState public string Message => Status switch { OverlapCheckStatus.Checking => "Checking overlaps…", - OverlapCheckStatus.Current => Report.Pairs.Count == 0 - ? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs", + OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 + ? (Report.Pairs.Count == 0 ? "No material overlaps detected" + : $"Overlaps: {Report.Pairs.Count} pairs") + : $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps", OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; " + + (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ") + $"{uncheckedPartCount} parts could not be checked", OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again", OverlapCheckStatus.Canceled => "Overlap check canceled", @@ -55,7 +58,8 @@ public sealed class OverlapReportState if (!CanComplete(generation, plate)) return false; Report = report; - DisplayPairs = report.Pairs; + DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs + : report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly(); displayStamp = stamp; observedDisplayStamp = stamp; uncheckedPartCount = CountUncheckedParts(report.Issues); @@ -106,7 +110,9 @@ public sealed class OverlapReportState if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true) return; var unchanged = displayStamp.UnchangedSlots(plate); - var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList(); + var sameZones = displayStamp.SameZones(plate); + 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) diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs index 607b30e6..a5453ac4 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs @@ -23,7 +23,16 @@ public static class PlateOverlapAnalyzer /// public static PlateOverlapSnapshot Capture(IReadOnlyList parts, CancellationToken cancellationToken = default) => - Capture(parts, new OverlapMaterialCache(), cancellationToken); + Capture(parts, null, new OverlapMaterialCache(), cancellationToken); + + /// + /// As , additionally capturing + /// the plate's clamp keep-out rectangles: Analyze then reports part-vs-clamp findings + /// (C1) alongside part pairs. Null means no zones. + /// + public static PlateOverlapSnapshot Capture(IReadOnlyList parts, + IReadOnlyList clampZones, CancellationToken cancellationToken = default) => + Capture(parts, clampZones, new OverlapMaterialCache(), cancellationToken); /// /// As , but reuses converted @@ -31,6 +40,12 @@ public static class PlateOverlapAnalyzer /// cache before any in-place clean-program edit (see ). /// public static PlateOverlapSnapshot Capture(IReadOnlyList parts, OverlapMaterialCache cache, + CancellationToken cancellationToken = default) => + Capture(parts, null, cache, cancellationToken); + + /// + public static PlateOverlapSnapshot Capture(IReadOnlyList parts, + IReadOnlyList clampZones, OverlapMaterialCache cache, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(parts); @@ -64,8 +79,21 @@ public static class PlateOverlapAnalyzer issues.Add(new PlateOverlapIssue(id, null, exception.Message)); } } + var clamps = new List(); + if (clampZones != null) + { + for (var ordinal = 0; ordinal < clampZones.Count; ordinal++) + { + cancellationToken.ThrowIfCancellationRequested(); + var zone = clampZones[ordinal]; + // ClampZone validates finiteness and positive size at construction; the + // snapshot still owns its own copy so later plate edits never move it. + clamps.Add(new CapturedClamp(ordinal, zone.Id, + new Box(zone.Bounds.X, zone.Bounds.Y, zone.Bounds.Length, zone.Bounds.Width))); + } + } cancellationToken.ThrowIfCancellationRequested(); - return new PlateOverlapSnapshot(captured, issues); + return new PlateOverlapSnapshot(captured, issues, clamps); } private static OverlapSource CaptureSource(Program program) @@ -194,9 +222,85 @@ public static class PlateOverlapAnalyzer } } cancellationToken.ThrowIfCancellationRequested(); + var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken); return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId) .ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId) - .ThenBy(issue => issue.PartBId).ToList(), snapshot); + .ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings + .OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList()); + } + + /// + /// C1 part-vs-clamp findings: the prepared hole-aware material against each captured + /// zone rectangle with the same exact Collision oracle as part pairs. Deterministic in + /// (part input id, clamp ordinal) order; failures become part issues like any other + /// pair geometry failure. Empty zone lists return without touching the material. + /// + private static List FindClampOverlaps(List prepared, + PlateOverlapSnapshot snapshot, List issues, + CancellationToken cancellationToken) + { + var findings = new List(); + if (snapshot.Clamps.Count == 0) + return findings; + + foreach (var part in prepared) + { + var partBounds = part.Material.Outer.BoundingBox; + foreach (var clamp in snapshot.Clamps) + { + cancellationToken.ThrowIfCancellationRequested(); + var zone = clamp.Bounds; + if (partBounds.Right <= zone.Left || zone.Right <= partBounds.Left + || partBounds.Top <= zone.Bottom || zone.Top <= partBounds.Bottom) + continue; + try + { + var origin = part.Material.Outer.Vertices[0]; + var local = part.Material.Transform(0, origin * -1); + var rect = RectanglePolygon(zone, origin * -1); + var result = Collision.Check(local.Outer, rect, local.Holes, null); + if (!result.Overlaps) + continue; + var moments = new List(); + var regions = new List(); + foreach (var region in result.OverlapRegions) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment)) + throw new ArithmeticException("Clamp overlap fragment area moments are invalid."); + moments.Add(fragment); + regions.Add(new PlateOverlapRegion( + region.Vertices.Select(point => point + origin), fragment.Area)); + } + if (!PolygonAreaMoments.TryCombine(moments, out var combined)) + throw new ArithmeticException("Combined clamp overlap area moments are invalid."); + var centroid = combined.Centroid + origin; + if (!OverlapMaterial.IsFinite(centroid)) + throw new ArithmeticException("Clamp overlap centroid is not finite in world coordinates."); + findings.Add(new PlateOverlapPair(part.Input.Id, + ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name, + $"Clamp '{clamp.Id}'", regions, centroid)); + } + catch (Exception exception) when (IsGeometryFailure(exception)) + { + issues.Add(new PlateOverlapIssue(part.Input.Id, null, exception.Message)); + } + } + } + + return findings; + } + + private static Polygon RectanglePolygon(Box box, Vector offset) + { + var polygon = new Polygon(); + polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Bottom + offset.Y)); + polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Bottom + offset.Y)); + polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Top + offset.Y)); + polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Top + offset.Y)); + polygon.Close(); + polygon.UpdateBounds(); + return polygon; } /// diff --git a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs index 900483dc..436773b9 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs @@ -8,11 +8,12 @@ namespace OpenNest.Diagnostics; public sealed class PlateOverlapReport { internal PlateOverlapReport(List pairs, List issues, - PlateOverlapSnapshot snapshot) + PlateOverlapSnapshot snapshot, List clampFindings = null) { Pairs = pairs.AsReadOnly(); Issues = issues.AsReadOnly(); Snapshot = snapshot; + ClampFindings = (clampFindings ?? new List()).AsReadOnly(); } /// The owned input this report was computed from; the baseline for incremental rechecks. @@ -20,6 +21,14 @@ public sealed class PlateOverlapReport public IReadOnlyList Pairs { get; } public IReadOnlyList Issues { get; } + + /// + /// Part-vs-clamp-zone findings (C1): the same owned-fragment shape as part pairs, with a + /// negative slot encoding the zone ordinal. Findings + /// are reported like other overlap findings; they do not change . + /// + public IReadOnlyList ClampFindings { get; } + public bool IsComplete => Issues.Count == 0; public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance; } @@ -102,15 +111,30 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message /// public sealed class PlateOverlapSnapshot { - internal PlateOverlapSnapshot(List parts, List issues) + internal PlateOverlapSnapshot(List parts, List issues, + List clamps = null) { Parts = parts.AsReadOnly(); Issues = issues.AsReadOnly(); + Clamps = (clamps ?? new List()).AsReadOnly(); } internal IReadOnlyList Parts { get; } internal IReadOnlyList Issues { get; } + + /// Owned clamp keep-out rectangles in plate coordinates, capture order. + internal IReadOnlyList Clamps { get; } } internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source, double Rotation, Vector Location); + +/// A clamp keep-out rectangle owned by the snapshot; Ordinal is its capture index. +internal sealed record CapturedClamp(int Ordinal, string Id, Box Bounds); + +/// Deterministic negative PartBId for a part-vs-clamp finding's clamp slot. +internal static class ClampFindingSlot +{ + public static int IdFor(int ordinal) => -2 - ordinal; + public static int OrdinalOf(int pairPartBId) => -2 - pairPartBId; +} diff --git a/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs b/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs new file mode 100644 index 00000000..fe342715 --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Fill; + +/// +/// Slice 4: interactive pushes and spacing expansion never target poses inside a +/// clamp keep-out. The plate's zones act as static obstacles for Compactor.Push, +/// and as static walls for Expander — including the Expander edge inputs (clusters +/// that already overlap or are pinned to an edge), which must degrade to a +/// violation report instead of a crash. +/// +public class ClampZonePushTests +{ + // Plate: 24 x 24, 0.5 uniform edge spacing, part spacing 1.0. Zones on the + // front edge; validation clearance (bottom edge spacing) is 0.5. + private const double Edge = 0.5; + + private static readonly Box ZoneA = new(3, 0, 1.5, 0.625); + private static readonly Box ZoneB = new(8, 0, 1.5, 0.625); + + private static Plate MakePlate(params ClampZone[] zones) + { + var plate = new Plate(new Size(24, 24)); + plate.EdgeSpacing = new Spacing(Edge, Edge); + plate.PartSpacing = 1.0; + foreach (var zone in zones) + plate.ClampZones.Add(zone); + return plate; + } + + private static Program SquareProgram(double size) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(size, 0); + program.LineTo(size, size); + program.LineTo(0, size); + program.LineTo(0, size); + program.LineTo(0, 0); + return program; + } + + private static Part AddSquare(Plate plate, double x, double y, double size = 3) + { + var part = new Part(new Drawing($"sq{plate.Parts.Count}", SquareProgram(size)), new Vector(x, y)); + plate.Parts.Add(part); + return part; + } + + private static Box Inflated(Box zone, double clearance) => + new(zone.X - clearance, zone.Y - clearance, zone.Length + 2 * clearance, zone.Width + 2 * clearance); + + private static bool Overlaps(Box a, Box b) => + a.Left < b.Right - 1e-6 && b.Left < a.Right - 1e-6 && a.Bottom < b.Top - 1e-6 && b.Bottom < a.Top - 1e-6; + + private static void AssertClear(Plate plate, params Part[] parts) + { + foreach (var part in parts) + foreach (var zone in plate.ClampZones) + Assert.False( + Overlaps(part.BoundingBox, Inflated(zone.Bounds, Edge)), + $"'{part.BaseDrawing.Name}' at ({part.Location.X:R},{part.Location.Y:R}) enters clamp '{zone.Id}'" + ); + } + + [Fact] + public void PushDownTowardTheClampEdgeStopsClearOfTheKeepOut() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + var mover = AddSquare(plate, 3, 8); // column crosses zone front-1 + + var moved = Compactor.Push(new List { mover }, plate, PushDirection.Down); + + Assert.True(moved > 0.1, $"expected a downward move, got {moved}"); + AssertClear(plate, mover); + } + + [Fact] + public void PushDownBetweenTheClampsStillReachesTheEdge() + { + // Material between clamps stays usable (D6): the free column pushes to the edge. + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + var mover = AddSquare(plate, 14, 8); + + var moved = Compactor.Push(new List { mover }, plate, PushDirection.Down); + + Assert.True(moved > 7, $"free column should reach near the edge, moved {moved}"); + AssertClear(plate, mover); + } + + [Fact] + public void SettleKeepsGroupOutsideClampsInsideItsFillArea() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA)); + var first = AddSquare(plate, 3, 8); + var second = AddSquare(plate, 12, 8); + Compactor.Settle(new List { first, second }, plate, plate.WorkArea()); + AssertClear(plate, first, second); + } + + [Fact] + public void PushWithAlreadyOverlappingMemberDoesNotMoveClearMemberIntoClamp() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA)); + var straddler = AddSquare(plate, 3, 0.5); + var clear = AddSquare(plate, 3, 8); + Compactor.Push(new List { straddler, clear }, plate, PushDirection.Down); + AssertClear(plate, clear); + } + + [Fact] + public void ExpanderNeverTargetsAPoseInsideAClampWhenGrowthWouldEnterOne() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + // Overlapping pair directly above zone front-2 (validation keep-out top y = 1.125): + // separating them drives the lower square down through the keep-out boundary. + var upper = AddSquare(plate, 8, 4.0, 3); + var lower = AddSquare(plate, 8, 1.2, 3); + + var result = Expander.Expand(new List { upper, lower }, plate); + + Assert.False(result.Cancelled); + AssertClear(plate, upper, lower); + } + + [Fact] + public void ExpanderDegradesToAReportWhenAMemberAlreadyOverlapsAClamp() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + var straddler = AddSquare(plate, 3, 0.3, 3); // starts inside the clamp keep-out + var clear = AddSquare(plate, 15, 0.3, 3); + + var result = Expander.Expand(new List { straddler, clear }, plate); + + Assert.False(result.Cancelled); + // The clear member must not be driven into any clamp while its partner is stuck. + AssertClear(plate, clear); + } + + [Fact] + public void ExpanderOnEdgePinnedClusterWithClampsReportsInsteadOfCrashing() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + // Three tight squares away from the clamp edge (the Expander edge-input class: + // an overlapping, mutually blocked cluster). All start clear of the zones. + var a = AddSquare(plate, 15, 0.5, 3); + var b = AddSquare(plate, 17.75, 0.5, 3); + var c = AddSquare(plate, 15, 3.25, 3); + + var result = Expander.Expand(new List { a, b, c }, plate); + + Assert.False(result.Cancelled); + // Whatever the achieved spacing or reported violations, no clear member may + // end inside a clamp keep-out. + AssertClear(plate, a, b, c); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneInteractiveFillTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneInteractiveFillTests.cs new file mode 100644 index 00000000..d36b3dd6 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampZoneInteractiveFillTests.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC; +using OpenNest.Engine.Jobs.Placement; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// Slice 4 (C1): the interactive single-plate surface - PlateFillService strategies +/// (Fill/Strip/Vertical+Horizontal Remnant), PackArea and whole-plate Nest - never +/// proposes a part inside a clamp keep-out inflated by the plate's bottom-edge +/// spacing, while material outside the zones stays usable. Oracles are analytic: +/// strict positive-area overlap against independently inflated zone rectangles. +/// +public class ClampZoneInteractiveFillTests +{ + // Plate: 48 (X) by 24 (Y), uniform 0.25 edge spacing -> bottom clearance 0.25. + // Two 1.5 x 0.625 clamps on the front edge at X 10 and X 20. + private const double Edge = 0.25; + private const double PartSpacing = 0.5; + + private static readonly Box ZoneA = new(10, 0, 1.5, 0.625); + private static readonly Box ZoneB = new(20, 0, 1.5, 0.625); + + private static Plate MakePlate(params ClampZone[] zones) + { + var plate = new Plate(new Size(24, 48)); + plate.EdgeSpacing = new Spacing(Edge, Edge); + plate.PartSpacing = PartSpacing; + foreach (var zone in zones) + plate.ClampZones.Add(zone); + return plate; + } + + private static Program RectProgram(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 RectDrawing(double lengthX, double widthY) => + new($"rect-{lengthX}-{widthY}", RectProgram(lengthX, widthY)); + + private static Box Inflated(Box zone, double clearance) => + new(zone.X - clearance, zone.Y - clearance, zone.Length + 2 * clearance, zone.Width + 2 * clearance); + + /// Strict positive-area overlap: touching edges (the admitted closure) are clear. + private static bool Overlaps(Box a, Box b) => + a.Left < b.Right - 1e-6 && b.Left < a.Right - 1e-6 && a.Bottom < b.Top - 1e-6 && b.Bottom < a.Top - 1e-6; + + private static void AssertClearOfClamps(IEnumerable parts, params Box[] zones) + { + foreach (var part in parts) + foreach (var zone in zones) + { + var keepOut = Inflated(zone, Edge); + Assert.False( + Overlaps(part.BoundingBox, keepOut), + $"'{part.BaseDrawing.Name}' at ({part.Location.X:R},{part.Location.Y:R}) " + + $"box [{part.BoundingBox.Left:R}..{part.BoundingBox.Right:R} x " + + $"{part.BoundingBox.Bottom:R}..{part.BoundingBox.Top:R}] enters the clamp keep-out" + ); + } + } + + public static TheoryData Strategies => new() { "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" }; + + [Theory] + [MemberData(nameof(Strategies))] + public void FillItemNeverProposesPartInsideClampKeepOut(string strategy) + { + var occupant = new Part(RectDrawing(6, 6), new Vector(38, 14)); // occupied-plate fixture + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + plate.Parts.Add(occupant); + + var item = new NestItem { Drawing = RectDrawing(4, 4), Quantity = 3 }; + var parts = PlateFillService.FillItem( + strategy, plate, item, plate.WorkArea(), null, CancellationToken.None); + + Assert.Equal(3, parts.Count); + AssertClearOfClamps(parts, ZoneA, ZoneB); + } + + [Theory] + [MemberData(nameof(Strategies))] + public void FillUsesMaterialBetweenAndBehindTheClamps(string strategy) + { + // The band covers both clamps only where y < 3: parts must land in the strip + // above the clamps and in the slot between them - not merely fewer parts. + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + var band = new Box(9, 0.25, 13, 2.75); + + var item = new NestItem { Drawing = RectDrawing(3, 1.8), Quantity = 2 }; + var parts = PlateFillService.FillItem(strategy, plate, item, band, null, CancellationToken.None); + + Assert.Equal(2, parts.Count); + AssertClearOfClamps(parts, ZoneA, ZoneB); + } + + [Fact] + public void PackAreaNeverPacksInsideClampKeepOut() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + var items = new List { new() { Drawing = RectDrawing(4, 4), Quantity = 8 } }; + + var parts = PlateFillService.PackArea( + "Fill", plate, plate.WorkArea(), items, null, CancellationToken.None); + + Assert.True(parts.Count >= 5, $"expected the unclamped material to pack parts, got {parts.Count}"); + AssertClearOfClamps(parts, ZoneA, ZoneB); + } + + [Fact] + public void ProgressNeverPublishesUnverifiedClampPlacements() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA)); + var updates = new List(); + var progress = new InlineProgress(update => updates.Add(update)); + var item = new NestItem { Drawing = RectDrawing(4, 4), Quantity = 4 }; + var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(), + progress, CancellationToken.None); + Assert.NotEmpty(parts); + Assert.NotEmpty(updates); + Assert.All(updates, update => AssertClearOfClamps(update.BestParts, ZoneA)); + } + + private sealed class InlineProgress(System.Action report) : System.IProgress + { + public void Report(NestProgress value) => report(value); + } + + [Theory] + [MemberData(nameof(Strategies))] + public void WholePlateNestAvoidsClampsOnAnOccupiedPlate(string strategy) + { + var occupant = new Part(RectDrawing(6, 6), new Vector(38, 14)); + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + plate.Parts.Add(occupant); + + var items = new List { new() { Drawing = RectDrawing(4, 4), Quantity = 10 } }; + var parts = PlateFillService.Nest(strategy, plate, items, null, CancellationToken.None); + + Assert.Equal(10, parts.Count); + AssertClearOfClamps(parts, ZoneA, ZoneB); + } +} diff --git a/OpenNest.Engine/Fill/Compactor.cs b/OpenNest.Engine/Fill/Compactor.cs index dc3b0191..3c4dd3cd 100644 --- a/OpenNest.Engine/Fill/Compactor.cs +++ b/OpenNest.Engine/Fill/Compactor.cs @@ -12,11 +12,22 @@ namespace OpenNest.Engine.Fill /// public static class Compactor { + /// + /// The plate's clamp zones as static push obstacles (C1). Unlike ordinary + /// already-overlapping parts, a clamp stays a wall even when a selected member + /// straddles it: dropping it would let other clear members move into the zone. + /// Empty zone lists change every caller's arithmetic by nothing (C2). + /// + private static List ZoneObstacles(Plate plate) => + Jobs.InteractiveClamps.ObstacleParts(plate); public static double Push(List movingParts, Plate plate, PushDirection direction) { - var obstacleParts = plate - .Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) - .ToList(); + var obstacleParts = ZoneObstacles(plate); + obstacleParts.InsertRange( + 0, + plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) + .ToList() + ); return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); } @@ -26,9 +37,12 @@ namespace OpenNest.Engine.Fill /// public static double Push(List movingParts, Plate plate, double angle) { - var obstacleParts = plate - .Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) - .ToList(); + var obstacleParts = ZoneObstacles(plate); + obstacleParts.InsertRange( + 0, + plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) + .ToList() + ); var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle)); return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); @@ -214,9 +228,12 @@ namespace OpenNest.Engine.Fill PushDirection direction ) { - var obstacleParts = plate - .Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) - .ToList(); + var obstacleParts = ZoneObstacles(plate); + obstacleParts.InsertRange( + 0, + plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) + .ToList() + ); return PushBoundingBox( movingParts, @@ -374,5 +391,44 @@ namespace OpenNest.Engine.Fill break; } } + + /// + /// As , settling inside the + /// plate's work area with its clamp zones as static obstacles: settling never parks + /// a part inside a keep-out (C1), and a clear cluster still compacts against zones + /// like any other obstacle. With no zones this behaves exactly like settling in the + /// plate's work area (C2). + /// + public static void Settle( + List parts, + Plate plate, + double threshold = 0.01, + int maxIterations = 20 + ) => Settle(parts, plate, plate.WorkArea(), threshold, maxIterations); + + /// Keep a fill group's own work-area bounds while excluding plate clamps. + public static void Settle( + List parts, + Plate plate, + Box workArea, + double threshold = 0.01, + int maxIterations = 20 + ) + { + if (parts.Count < 2) + return; + + var obstacles = Jobs.InteractiveClamps.ObstacleParts(plate); + + for (var i = 0; i < maxIterations; i++) + { + var moved = 0.0; + moved += Push(parts, obstacles, workArea, plate.PartSpacing, PushDirection.Left); + moved += Push(parts, obstacles, workArea, plate.PartSpacing, PushDirection.Down); + + if (moved < threshold) + break; + } + } } } diff --git a/OpenNest.Engine/Fill/Expander.cs b/OpenNest.Engine/Fill/Expander.cs index d378658e..7c8c1991 100644 --- a/OpenNest.Engine/Fill/Expander.cs +++ b/OpenNest.Engine/Fill/Expander.cs @@ -479,6 +479,11 @@ namespace OpenNest.Engine.Fill { var movers = new List(selected); var walls = plate.Parts.Where(p => !movers.Contains(p)).ToList(); + // Slice 4 (C1): the plate's clamp keep-outs are static walls - growth that + // would consume a zone stops against it like an edge, and violations + // against zones are reported instead of crashed through. Zone rectangles + // carry no program of their own part spacing: walls are the raw keep-outs. + walls.AddRange(Jobs.InteractiveClamps.WallParts(plate)); var parts = new List(movers.Count + walls.Count); parts.AddRange(movers); diff --git a/OpenNest.Engine/Fill/RemnantFiller.cs b/OpenNest.Engine/Fill/RemnantFiller.cs index 22b18aa0..8b123ff1 100644 --- a/OpenNest.Engine/Fill/RemnantFiller.cs +++ b/OpenNest.Engine/Fill/RemnantFiller.cs @@ -15,10 +15,18 @@ namespace OpenNest.Engine.Fill private readonly RemnantFinder finder; private readonly double spacing; - public RemnantFiller(Box workArea, double spacing) + /// + /// are kept-out rectangles (e.g. inflated clamp + /// zones) treated like occupied space: they are excluded from the remnant areas + /// every fill attempt sees. Null keeps today's behaviour exactly. + /// + public RemnantFiller(Box workArea, double spacing, IEnumerable boxObstacles = null) { this.spacing = spacing; finder = new RemnantFinder(workArea); + if (boxObstacles != null) + foreach (var box in boxObstacles) + finder.AddObstacle(box); } public void AddObstacles(IEnumerable parts) diff --git a/OpenNest.Engine/Jobs/InteractiveClamps.cs b/OpenNest.Engine/Jobs/InteractiveClamps.cs new file mode 100644 index 00000000..cac9116e --- /dev/null +++ b/OpenNest.Engine/Jobs/InteractiveClamps.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Jobs; + +/// +/// Interactive-path clamp keep-outs (Slice 4): the inflated rectangles and static obstacle +/// parts that make the single-plate fill, push and pack surfaces respect +/// like existing parts. Zones are never material: the fake +/// obstacle parts exist only inside a single call - they are never added to +/// , plates, jobs or files. +/// +internal static class InteractiveClamps +{ + public static bool HasZones(Plate plate) => plate.ClampZones.Count > 0; + + /// Raw zone rectangles, detached copies, capture order. + public static IReadOnlyList ZoneRectangles(Plate plate) + { + if (plate.ClampZones.Count == 0) + return Array.Empty(); + var boxes = new Box[plate.ClampZones.Count]; + for (var i = 0; i < boxes.Length; i++) + boxes[i] = Copy(plate.ClampZones[i].Bounds); + return boxes; + } + + /// + /// Placement keep-outs inflated by the full bottom-edge clearance (D6) - the same + /// convention the Rectangles lane blocks and engines honour, so fill and pack produce + /// placements with at least that clearance from the raw zone. + /// + public static IReadOnlyList ClearanceRectangles(Plate plate) => + InflateZones(plate, System.Math.Max(0, plate.EdgeSpacing.Bottom)); + + /// + /// The validator's exact C1 rectangle (NestLayoutCheck: bottom spacing less the shared + /// spacing slack). Used by the service safety net so flush engine-clear placements are + /// never filtered by rounding. + /// + public static IReadOnlyList ValidationKeepOuts(Plate plate) => + InflateZones(plate, System.Math.Max(0, plate.EdgeSpacing.Bottom - NestTolerances.SpacingSlack)); + + private static IReadOnlyList InflateZones(Plate plate, double clearance) + { + if (plate.ClampZones.Count == 0) + return Array.Empty(); + var boxes = new Box[plate.ClampZones.Count]; + for (var i = 0; i < boxes.Length; i++) + boxes[i] = plate.ClampZones[i].Bounds.Offset(clearance); + return boxes; + } + + /// + /// Static obstacle parts for the directional push kernels. The kernel enforces + /// part-spacing between every obstacle pair, so the rectangles carry only the + /// clearance the kernel would otherwise miss: max(0, bottom spacing - part spacing). + /// Final material clearance from a raw zone is then at least max(bottom, partSpacing), + /// never below the C1 keep-out. + /// + public static List ObstacleParts(Plate plate) => + ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom - plate.PartSpacing)); + + /// Expander walls: the raw keep-out rectangles themselves (no part spacing). + public static List WallParts(Plate plate) => ObstaclePartsFor(ZoneRectangles(plate), 0); + + private static List ObstaclePartsFor(IReadOnlyList rectangles, double inflate) + { + var parts = new List(rectangles.Count); + foreach (var box in rectangles) + { + var inflated = box.Offset(inflate); + var drawing = RectangleDrawing(inflated.Length, inflated.Width); + parts.Add(new Part(drawing, new Vector(inflated.Left, inflated.Bottom))); + } + return parts; + } + + /// + /// Exact material overlap oracle for the service safety net: part outline with its + /// holes subtracted against the keep-out rectangle, matching the C1 checker's verdict + /// (positive-area overlap only - flush contact is clear). + /// + public static bool MaterialInKeepOut(Part part, Box keepOut) + { + var partProfile = new ShapeProfile(PartGeometry.GetPartEntities(part)); + if (partProfile.Perimeter == null) + return true; + var zoneProfile = new ShapeProfile(RectangleEntities(keepOut)); + if (zoneProfile.Perimeter == null) + return true; + return Collision.HasOverlap( + partProfile.Perimeter.ToPolygonWithTolerance(0.001), + zoneProfile.Perimeter.ToPolygonWithTolerance(0.001), + HolePolygons(partProfile), + HolePolygons(zoneProfile)); + } + + private static List HolePolygons(ShapeProfile profile) + { + if (profile.Cutouts.Count == 0) + return null; + var holes = new List(profile.Cutouts.Count); + foreach (var cutout in profile.Cutouts) + holes.Add(cutout.ToPolygonWithTolerance(0.001)); + return holes; + } + + private static List RectangleEntities(Box box) + { + var program = new Program(); + program.MoveTo(box.Left, box.Bottom); + program.LineTo(box.Right, box.Bottom); + program.LineTo(box.Right, box.Top); + program.LineTo(box.Left, box.Top); + program.LineTo(box.Left, box.Bottom); + return ConvertProgram.ToGeometry(program); + } + + private static Box Copy(Box box) => new(box.X, box.Y, box.Length, box.Width); + + // Rectangular obstacle drawings are immutable and shared across calls; rail clamps + // all share one size, so this stays tiny. Key equality implies identical geometry. + private static readonly ConcurrentDictionary<(double Length, double Width), Drawing> Drawings = new(); + + private static Drawing RectangleDrawing(double length, double width) => + Drawings.GetOrAdd((length, width), size => + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(size.Length, 0); + program.LineTo(size.Length, size.Width); + program.LineTo(0, size.Width); + program.LineTo(0, 0); + return new Drawing("clamp-zone-obstacle", program); + }); +} diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs index f85033db..888bc0c6 100644 --- a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs +++ b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs @@ -27,6 +27,9 @@ internal static class PlateFillOrchestrator return new List(); var workArea = plate.WorkArea(); + // Slice 4 (C1): inflated clamp keep-outs act like occupied space for every stage + // below - fill remnants, pack areas, pair placement - so a zone is never covered. + var clampKeepOuts = InteractiveClamps.ClearanceRectangles(plate); var allParts = new List(); var plateArea = workArea.Width * workArea.Length; @@ -41,7 +44,7 @@ internal static class PlateFillOrchestrator if (fillItems.Count > 0) { - var remnantFiller = new RemnantFiller(workArea, plate.PartSpacing); + var remnantFiller = new RemnantFiller(workArea, plate.PartSpacing, clampKeepOuts); var fillParts = remnantFiller.FillItems( fillItems, @@ -58,6 +61,7 @@ internal static class PlateFillOrchestrator var placedObstacles = fillParts .Select(part => part.BoundingBox.Offset(plate.PartSpacing)) .ToList(); + placedObstacles.AddRange(clampKeepOuts); var finder = new RemnantFinder(workArea, placedObstacles); var remnants = finder.FindRemnants(); if (remnants.Count > 0) @@ -90,11 +94,11 @@ internal static class PlateFillOrchestrator if (pairItems.Count > 0 && !token.IsCancellationRequested) { - var placed = PlaceBestFitPairs(plate, comparer, pairItems, allParts, plate.WorkArea()); + var placed = PlaceBestFitPairs(plate, comparer, pairItems, allParts, plate.WorkArea(), clampKeepOuts); allParts.AddRange(placed); } - Compactor.Settle(allParts, plate.WorkArea(), plate.PartSpacing); + Compactor.Settle(allParts, plate); return allParts; } @@ -113,13 +117,16 @@ internal static class PlateFillOrchestrator IFillComparer comparer, List pairItems, List existingParts, - Box fullWorkArea + Box fullWorkArea, + IReadOnlyList clampKeepOuts = null ) { var result = new List(); var obstacles = existingParts .Select(part => part.BoundingBox.Offset(plate.PartSpacing)) .ToList(); + if (clampKeepOuts != null) + obstacles.AddRange(clampKeepOuts); var finder = new RemnantFinder(fullWorkArea, obstacles); foreach (var item in pairItems) diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs index ca8f4deb..497dbe53 100644 --- a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs +++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; +using OpenNest.Engine.Fill; using OpenNest.Engine.Jobs.Placement.Fillers; using OpenNest.Geometry; @@ -39,7 +41,13 @@ public static class PlateFillService CancellationToken token ) { - return RequireFiller(strategy, plate).Fill(item, workArea, progress, token); + var filler = RequireFiller(strategy, plate); + if (!InteractiveClamps.HasZones(plate)) + return filler.Fill(item, workArea, progress, token); + var result = FillClearAreas(plate, workArea, item, token, + (area, demand) => filler.Fill(demand, area, null, token)); + ReportSafe(progress, result, workArea); + return result; } public static List FillGroup( @@ -51,7 +59,13 @@ public static class PlateFillService CancellationToken token ) { - return RequireFiller(strategy, plate).Fill(groupParts, workArea, progress, token); + var filler = RequireFiller(strategy, plate); + if (!InteractiveClamps.HasZones(plate)) + return filler.Fill(groupParts, workArea, progress, token); + var result = ClearAreas(plate, workArea, token, (area, _) => + filler.Fill(groupParts, area, null, token), 0); + ReportSafe(progress, result, workArea); + return result; } public static List PackArea( @@ -63,7 +77,21 @@ public static class PlateFillService CancellationToken token ) { - return RequireFiller(strategy, plate).PackArea(box, items, progress, token); + var filler = RequireFiller(strategy, plate); + if (!InteractiveClamps.HasZones(plate)) + return filler.PackArea(box, items, progress, token); + var result = new List(); + foreach (var area in ClearRegionBoxes(plate, box)) + { + token.ThrowIfCancellationRequested(); + var remaining = items.Select(item => CopyDemand(item, + item.Quantity > 0 ? System.Math.Max(0, item.Quantity - result.Count(part => + ReferenceEquals(part.BaseDrawing, item.Drawing))) : item.Quantity)).ToList(); + var proposed = filler.PackArea(area, remaining, null, token); + AddClear(result, proposed, plate, area, items); + } + ReportSafe(progress, result, box); + return result; } public static List Nest( @@ -93,9 +121,112 @@ public static class PlateFillService ResolveStrategy(strategy, allowEmpty: false); var filler = CreateFiller(strategy, plate); filler.PlateNumber = plateNumber; - return filler.Nest(items, progress, token); + if (!InteractiveClamps.HasZones(plate)) + return filler.Nest(items, progress, token); + + // Interactive proposals are never committed here. Unlike the unconstrained + // strategies, partition the sheet at static keep-outs before searching, then + // certify each accepted material footprint against the full zone and occupants. + var result = new List(); + foreach (var item in items.OrderBy(item => item.Priority)) + { + token.ThrowIfCancellationRequested(); + var proposed = FillClearAreas(plate, plate.WorkArea(), item, token, + (area, demand) => filler.Fill(demand, area, null, token), result); + result.AddRange(proposed); + if (item.Quantity > 0) + item.Quantity = System.Math.Max(0, item.Quantity - proposed.Count); + } + ReportSafe(progress, result, plate.WorkArea(), plateNumber); + return result; } + // Do not expose a strategy's unverified intermediate placements: the UI can + // accept a progress preview before this service returns its certified proposal. + private static void ReportSafe(IProgress progress, List parts, + Box area, int plateNumber = 0) => progress?.Report(new NestProgress + { + BestParts = parts, + ActiveWorkArea = area, + PlateNumber = plateNumber, + IsOverallBest = true, + Description = $"Clamp-clear proposal: {parts.Count} parts", + }); + + private static NestItem CopyDemand(NestItem item, int quantity) => new() + { + Drawing = item.Drawing, + Quantity = quantity, + Priority = item.Priority, + RotationStart = item.RotationStart, + RotationEnd = item.RotationEnd, + StepAngle = item.StepAngle, + }; + + private static List FillClearAreas(Plate plate, Box box, NestItem item, + CancellationToken token, Func> fill, + List previouslyPlaced = null) => + ClearAreas(plate, box, token, fill, item.Quantity, item, previouslyPlaced); + + private static List ClearAreas(Plate plate, Box box, CancellationToken token, + Func> fill, int quantity, NestItem item = null, + List previouslyPlaced = null) + { + var result = new List(); + foreach (var area in ClearRegionBoxes(plate, box)) + { + token.ThrowIfCancellationRequested(); + if (quantity > 0 && result.Count >= quantity) + break; + var demand = item == null ? null : CopyDemand(item, + quantity > 0 ? quantity - result.Count : quantity); + AddClear(result, fill(area, demand), plate, area, + item == null ? null : new List { item }, previouslyPlaced); + } + return result; + } + + private static List ClearRegionBoxes(Plate plate, Box area) + { + var obstacles = InteractiveClamps.ClearanceRectangles(plate).ToList(); + obstacles.AddRange(plate.Parts.Where(part => !part.BaseDrawing.IsCutOff) + .Select(part => part.BoundingBox.Offset(plate.PartSpacing))); + return new RemnantFinder(area, obstacles).FindRemnants(); + } + + private static void AddClear(List accepted, List proposed, Plate plate, + Box area, List demand = null, List previouslyPlaced = null) + { + if (proposed == null) + return; + foreach (var part in proposed) + { + var bounds = part.BoundingBox; + if (bounds.Left < area.Left - 1e-6 || bounds.Right > area.Right + 1e-6 + || bounds.Bottom < area.Bottom - 1e-6 || bounds.Top > area.Top + 1e-6) + continue; + if (demand != null && !demand.Any(item => + ReferenceEquals(item.Drawing, part.BaseDrawing) && + (item.Quantity <= 0 || accepted.Count(p => + ReferenceEquals(p.BaseDrawing, item.Drawing)) < item.Quantity))) + continue; + if (InteractiveClamps.ValidationKeepOuts(plate).Any(zone => + Overlaps(bounds, zone) && InteractiveClamps.MaterialInKeepOut(part, zone))) + continue; + if (plate.Parts.Any(existing => !existing.BaseDrawing.IsCutOff && + Overlaps(bounds, existing.BoundingBox.Offset(plate.PartSpacing))) + || accepted.Any(existing => Overlaps(bounds, + existing.BoundingBox.Offset(plate.PartSpacing))) + || (previouslyPlaced?.Any(existing => Overlaps(bounds, + existing.BoundingBox.Offset(plate.PartSpacing))) ?? false)) + continue; + accepted.Add(part); + } + } + + private static bool Overlaps(Box a, Box b) => a.Left < b.Right && a.Right > b.Left + && a.Bottom < b.Top && a.Top > b.Bottom; + /// /// Resolves a caller-supplied strategy name: null or empty means "Fill"; otherwise the name /// must match a built-in strategy, matched case-insensitively like the legacy registry's diff --git a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs new file mode 100644 index 00000000..ac1ba125 --- /dev/null +++ b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs @@ -0,0 +1,181 @@ +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; + +/// +/// 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. +/// +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 PartOverlappingClampProducesHoleAwareFinding() + { + // 4x4 square at (9.25, 0): the shared fragment with front-1 is exactly its footprint. + var parts = new List { 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 + { + 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 + { + 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())); + Assert.Equal(Describe(legacy), Describe(explicitEmpty)); + } + + [Fact] + public void IncrementalRecheckAfterMovingAClampMatchesAFullAnalysis() + { + var parts = new List + { + 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 + { + 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); + } + + 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}")); +} diff --git a/OpenNest/Controls/OverlapOverlayController.cs b/OpenNest/Controls/OverlapOverlayController.cs index 96e71e61..b367c76a 100644 --- a/OpenNest/Controls/OverlapOverlayController.cs +++ b/OpenNest/Controls/OverlapOverlayController.cs @@ -36,6 +36,7 @@ internal sealed class OverlapOverlayController : IDisposable private IReadOnlyList pathPairs; private float pathScale; private Units capturedUnits; + private Plate observedPlate; private IReadOnlyList hoveredPairs = Array.Empty(); private string hoverText; private OverlapHoverPages hoverPages; @@ -110,6 +111,9 @@ internal sealed class OverlapOverlayController : IDisposable autoCheck.Reset(); baseline = null; ReleasePath(); + observedPlate = plate; + if (observedPlate != null) + observedPlate.ClampZonesChanged += ZonesChanged; observedParts = plate?.Parts; if (observedParts != null) { @@ -144,7 +148,8 @@ 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(), materialCache, token); + var snapshot = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), + plate.ClampZones.ToArray(), materialCache, token); var previous = baseline; var analyze = Analyze ?? ((input, cancel) => PlateOverlapAnalyzer.Analyze(input, previous, cancel)); var report = await Task.Run(() => analyze(snapshot, token), token); @@ -331,6 +336,7 @@ internal sealed class OverlapOverlayController : IDisposable || System.Windows.Forms.Application.OpenForms.Cast() .Any(form => form.Modal || form is NestProgressForm); + private void ZonesChanged(object sender, EventArgs e) => Invalidate(); private void PartAdded(object sender, ItemAddedEventArgs e) => Invalidate(); private void PartRemoved(object sender, ItemRemovedEventArgs e) => Invalidate(); private void PartChanged(object sender, ItemChangedEventArgs e) => Invalidate(); @@ -338,6 +344,11 @@ internal sealed class OverlapOverlayController : IDisposable private void Unsubscribe() { + if (observedPlate != null) + { + observedPlate.ClampZonesChanged -= ZonesChanged; + observedPlate = null; + } if (observedParts == null) return; observedParts.ItemAdded -= PartAdded; @@ -418,7 +429,7 @@ internal sealed class OverlapOverlayController : IDisposable // Graph coordinates are already screen-scaled; remove pan from the pointer once. var graphPoint = view.PointControlToGraph(point); - var hits = OverlapPairPresentation.HitTest(state.Report.Pairs, world => + var hits = OverlapPairPresentation.HitTest(state.DisplayPairs, world => { var graph = view.PointWorldToGraph(world); return new Vector(graph.X, graph.Y); diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index ab83db29..41f49aa5 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -870,7 +870,10 @@ namespace OpenNest.Controls progress, cts.Token ); - Compactor.Settle(result, workArea, spacing); + if (Plate.ClampZones.Count == 0) + Compactor.Settle(result, workArea, spacing); + else + Compactor.Settle(result, Plate, workArea); return result; }); operationActive = false; diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md index f7820fcd..f4b1f2a1 100644 --- a/docs/geometry/visual-overlap-check.md +++ b/docs/geometry/visual-overlap-check.md @@ -8,7 +8,10 @@ selection, cutting paths, or export behavior. Cutoffs and temporary preview part are excluded. **Cancel Check** discards the running request. **Display > Off / Areas / Centroids / Both** changes visibility without rerunning analysis. Areas is the default; rechecking preserves a visible mode, while checking from Off -shows Areas. Display preferences belong to the current document and are not saved. +shows Areas. Clamp overlaps are shown as part/clamp findings with the clamp ID +in hover details; clamps are not selectable parts and are not added to quantity +or machining. A clamp edit invalidates the diagnostic and schedules a fresh +check. Display preferences belong to the current document and are not saved. Centroids and Both show fixed-screen-size, DPI-scaled pair crosshairs with a contrasting halo. Labels such as `1/3` identify the two plate sequence positions @@ -96,7 +99,7 @@ vertices cannot mutate the snapshot, report, or live geometry. ```csharp // UI thread, while parts/drawings are stable: -var snapshot = PlateOverlapAnalyzer.Capture(parts, cancellationToken); +var snapshot = PlateOverlapAnalyzer.Capture(parts, plate.ClampZones.ToArray(), cancellationToken); // Worker thread, no access to live Part/Drawing/Program objects: var report = await Task.Run(