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(