mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-22 14:11:37 -04:00
feat(engine): respect clamp zones in interactive placement and diagnostics
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user