feat(engine): respect clamp zones in interactive placement and diagnostics

This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent 87849e5326
commit 3e58a9e93f
17 files changed
+1062 -35

No files matched your search

@@ -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]))
@@ -9,12 +9,19 @@ namespace OpenNest.Diagnostics;
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
public static class OverlapPairPresentation
{
public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}";
/// <summary>True for part-vs-clamp findings carried on the report (negative clamp slot).</summary>
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}";
}
@@ -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)
@@ -23,7 +23,16 @@ public static class PlateOverlapAnalyzer
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
CancellationToken cancellationToken = default) =>
Capture(parts, new OverlapMaterialCache(), cancellationToken);
Capture(parts, null, new OverlapMaterialCache(), cancellationToken);
/// <summary>
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, additionally capturing
/// the plate's clamp keep-out rectangles: Analyze then reports part-vs-clamp findings
/// (C1) alongside part pairs. Null means no zones.
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
IReadOnlyList<ClampZone> clampZones, CancellationToken cancellationToken = default) =>
Capture(parts, clampZones, new OverlapMaterialCache(), cancellationToken);
/// <summary>
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
@@ -31,6 +40,12 @@ public static class PlateOverlapAnalyzer
/// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache,
CancellationToken cancellationToken = default) =>
Capture(parts, null, cache, cancellationToken);
/// <inheritdoc cref="Capture(IReadOnlyList{Part}, IReadOnlyList{ClampZone}, CancellationToken)"/>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
IReadOnlyList<ClampZone> 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<CapturedClamp>();
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());
}
/// <summary>
/// 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.
/// </summary>
private static List<PlateOverlapPair> FindClampOverlaps(List<PreparedPart> prepared,
PlateOverlapSnapshot snapshot, List<PlateOverlapIssue> issues,
CancellationToken cancellationToken)
{
var findings = new List<PlateOverlapPair>();
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<PolygonAreaMoments>();
var regions = new List<PlateOverlapRegion>();
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;
}
/// <summary>
@@ -8,11 +8,12 @@ namespace OpenNest.Diagnostics;
public sealed class PlateOverlapReport
{
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
PlateOverlapSnapshot snapshot)
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null)
{
Pairs = pairs.AsReadOnly();
Issues = issues.AsReadOnly();
Snapshot = snapshot;
ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly();
}
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
@@ -20,6 +21,14 @@ public sealed class PlateOverlapReport
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
/// <summary>
/// Part-vs-clamp-zone findings (C1): the same owned-fragment shape as part pairs, with a
/// negative <see cref="PlateOverlapPair.PartBId"/> slot encoding the zone ordinal. Findings
/// are reported like other overlap findings; they do not change <see cref="IsComplete"/>.
/// </summary>
public IReadOnlyList<PlateOverlapPair> 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
/// </summary>
public sealed class PlateOverlapSnapshot
{
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues)
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues,
List<CapturedClamp> clamps = null)
{
Parts = parts.AsReadOnly();
Issues = issues.AsReadOnly();
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
}
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
/// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary>
internal IReadOnlyList<CapturedClamp> Clamps { get; }
}
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
double Rotation, Vector Location);
/// <summary>A clamp keep-out rectangle owned by the snapshot; Ordinal is its capture index.</summary>
internal sealed record CapturedClamp(int Ordinal, string Id, Box Bounds);
/// <summary>Deterministic negative PartBId for a part-vs-clamp finding's clamp slot.</summary>
internal static class ClampFindingSlot
{
public static int IdFor(int ordinal) => -2 - ordinal;
public static int OrdinalOf(int pairPartBId) => -2 - pairPartBId;
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<Part> { 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<Part> { 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<Part> { 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<Part> { 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<Part> { 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<Part> { 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<Part> { 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);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>Strict positive-area overlap: touching edges (the admitted closure) are clear.</summary>
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<Part> 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<string> 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<NestItem> { 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<NestProgress>();
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<NestProgress> report) : System.IProgress<NestProgress>
{
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<NestItem> { 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);
}
}
+65 -9
View File
@@ -12,11 +12,22 @@ namespace OpenNest.Engine.Fill
/// </summary>
public static class Compactor
{
/// <summary>
/// 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).
/// </summary>
private static List<Part> ZoneObstacles(Plate plate) =>
Jobs.InteractiveClamps.ObstacleParts(plate);
public static double Push(List<Part> 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
/// </summary>
public static double Push(List<Part> 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;
}
}
/// <summary>
/// As <see cref="Settle(List{Part}, Box, double, double, int)"/>, 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).
/// </summary>
public static void Settle(
List<Part> parts,
Plate plate,
double threshold = 0.01,
int maxIterations = 20
) => Settle(parts, plate, plate.WorkArea(), threshold, maxIterations);
/// <summary>Keep a fill group's own work-area bounds while excluding plate clamps.</summary>
public static void Settle(
List<Part> 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;
}
}
}
}
+5
View File
@@ -479,6 +479,11 @@ namespace OpenNest.Engine.Fill
{
var movers = new List<Part>(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<Part>(movers.Count + walls.Count);
parts.AddRange(movers);
+9 -1
View File
@@ -15,10 +15,18 @@ namespace OpenNest.Engine.Fill
private readonly RemnantFinder finder;
private readonly double spacing;
public RemnantFiller(Box workArea, double spacing)
/// <summary>
/// <paramref name="boxObstacles"/> 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.
/// </summary>
public RemnantFiller(Box workArea, double spacing, IEnumerable<Box> boxObstacles = null)
{
this.spacing = spacing;
finder = new RemnantFinder(workArea);
if (boxObstacles != null)
foreach (var box in boxObstacles)
finder.AddObstacle(box);
}
public void AddObstacles(IEnumerable<Part> parts)
+141
View File
@@ -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;
/// <summary>
/// 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
/// <see cref="Plate.ClampZones"/> like existing parts. Zones are never material: the fake
/// obstacle parts exist only inside a single call - they are never added to
/// <see cref="Plate.Parts"/>, plates, jobs or files.
/// </summary>
internal static class InteractiveClamps
{
public static bool HasZones(Plate plate) => plate.ClampZones.Count > 0;
/// <summary>Raw zone rectangles, detached copies, capture order.</summary>
public static IReadOnlyList<Box> ZoneRectangles(Plate plate)
{
if (plate.ClampZones.Count == 0)
return Array.Empty<Box>();
var boxes = new Box[plate.ClampZones.Count];
for (var i = 0; i < boxes.Length; i++)
boxes[i] = Copy(plate.ClampZones[i].Bounds);
return boxes;
}
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<Box> ClearanceRectangles(Plate plate) =>
InflateZones(plate, System.Math.Max(0, plate.EdgeSpacing.Bottom));
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<Box> ValidationKeepOuts(Plate plate) =>
InflateZones(plate, System.Math.Max(0, plate.EdgeSpacing.Bottom - NestTolerances.SpacingSlack));
private static IReadOnlyList<Box> InflateZones(Plate plate, double clearance)
{
if (plate.ClampZones.Count == 0)
return Array.Empty<Box>();
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;
}
/// <summary>
/// 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.
/// </summary>
public static List<Part> ObstacleParts(Plate plate) =>
ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom - plate.PartSpacing));
/// <summary>Expander walls: the raw keep-out rectangles themselves (no part spacing).</summary>
public static List<Part> WallParts(Plate plate) => ObstaclePartsFor(ZoneRectangles(plate), 0);
private static List<Part> ObstaclePartsFor(IReadOnlyList<Box> rectangles, double inflate)
{
var parts = new List<Part>(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;
}
/// <summary>
/// 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).
/// </summary>
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<Polygon> HolePolygons(ShapeProfile profile)
{
if (profile.Cutouts.Count == 0)
return null;
var holes = new List<Polygon>(profile.Cutouts.Count);
foreach (var cutout in profile.Cutouts)
holes.Add(cutout.ToPolygonWithTolerance(0.001));
return holes;
}
private static List<Entity> 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);
});
}
@@ -27,6 +27,9 @@ internal static class PlateFillOrchestrator
return new List<Part>();
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<Part>();
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<NestItem> pairItems,
List<Part> existingParts,
Box fullWorkArea
Box fullWorkArea,
IReadOnlyList<Box> clampKeepOuts = null
)
{
var result = new List<Part>();
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)
@@ -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<Part> 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<Part> 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<Part>();
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<Part> 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<Part>();
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<NestProgress> progress, List<Part> 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<Part> FillClearAreas(Plate plate, Box box, NestItem item,
CancellationToken token, Func<Box, NestItem, List<Part>> fill,
List<Part> previouslyPlaced = null) =>
ClearAreas(plate, box, token, fill, item.Quantity, item, previouslyPlaced);
private static List<Part> ClearAreas(Plate plate, Box box, CancellationToken token,
Func<Box, NestItem, List<Part>> fill, int quantity, NestItem item = null,
List<Part> previouslyPlaced = null)
{
var result = new List<Part>();
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<NestItem> { item }, previouslyPlaced);
}
return result;
}
private static List<Box> 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<Part> accepted, List<Part> proposed, Plate plate,
Box area, List<NestItem> demand = null, List<Part> 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;
/// <summary>
/// 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
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<Part> { 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<Part>
{
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<Part>
{
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<ClampZone>()));
Assert.Equal(Describe(legacy), Describe(explicitEmpty));
}
[Fact]
public void IncrementalRecheckAfterMovingAClampMatchesAFullAnalysis()
{
var parts = new List<Part>
{
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<Part>
{
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}"));
}
+13 -2
View File
@@ -36,6 +36,7 @@ internal sealed class OverlapOverlayController : IDisposable
private IReadOnlyList<PlateOverlapPair> pathPairs;
private float pathScale;
private Units capturedUnits;
private Plate observedPlate;
private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>();
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<System.Windows.Forms.Form>()
.Any(form => form.Modal || form is NestProgressForm);
private void ZonesChanged(object sender, EventArgs e) => Invalidate();
private void PartAdded(object sender, ItemAddedEventArgs<Part> e) => Invalidate();
private void PartRemoved(object sender, ItemRemovedEventArgs<Part> e) => Invalidate();
private void PartChanged(object sender, ItemChangedEventArgs<Part> 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);
+4 -1
View File
@@ -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;
+5 -2
View File
@@ -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(