mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-03 12:21:39 -05:00
feat(ui): validate part spacing in the plate view overlay
The overlap check now runs the pre-post part spacing scan on its snapshot, so the canvas label and posting agree. Too-close pairs are counted in the label and marked with an orange ring showing the gap, with hover details. A fully checked plate with no findings draws a green check instead of text; the status bar keeps the wording. Changing the plate's part spacing now stales the report, and repeated parts share one offset outline so automatic rechecks stay fast. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c8883bbd1e
commit
c0cd7c6aff
12 files changed
+643
-64
No files matched your search
@@ -17,11 +17,13 @@ public sealed class OverlapGeometryStamp
|
||||
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End,
|
||||
IReadOnlyList<CutOffGap> Gaps, long Overcut)[] cutoffs;
|
||||
private readonly (long Width, long Length, int Quadrant) sheet;
|
||||
private readonly long partSpacing;
|
||||
|
||||
private OverlapGeometryStamp(Plate plate)
|
||||
{
|
||||
this.plate = plate;
|
||||
sheet = SheetState(plate);
|
||||
partSpacing = Bits(plate.PartSpacing);
|
||||
entries = new Entry[plate.Parts.Count];
|
||||
for (var i = 0; i < entries.Length; i++)
|
||||
entries[i] = new Entry(plate.Parts[i]);
|
||||
@@ -77,9 +79,13 @@ public sealed class OverlapGeometryStamp
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool SameSpacing(Plate current) =>
|
||||
ReferenceEquals(plate, current) && partSpacing == Bits(current.PartSpacing);
|
||||
|
||||
public bool Matches(Plate current)
|
||||
{
|
||||
if (current.Parts.Count != entries.Length || !SameZones(current) || !SameCutOffs(current))
|
||||
if (current.Parts.Count != entries.Length || partSpacing != Bits(current.PartSpacing)
|
||||
|| !SameZones(current) || !SameCutOffs(current))
|
||||
return false;
|
||||
for (var i = 0; i < entries.Length; i++)
|
||||
if (!entries[i].Matches(current.Parts[i]))
|
||||
|
||||
@@ -26,6 +26,17 @@ public static class OverlapPairPresentation
|
||||
+ $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}";
|
||||
}
|
||||
|
||||
public static string Label(PlateSpacingPair pair, IFormatProvider provider = null) =>
|
||||
$"{pair.PartAId + 1}/{pair.PartBId + 1} gap {Number(pair.Gap, provider)}";
|
||||
|
||||
public static string Details(PlateSpacingPair pair, Units capturedUnits, IFormatProvider provider = null)
|
||||
{
|
||||
var units = UnitsHelper.GetShortString(capturedUnits);
|
||||
return $"Part spacing {pair.PartAId + 1}/{pair.PartBId + 1}: {pair.PartAName} / {pair.PartBName}\n"
|
||||
+ $"Gap ≈ {Number(pair.Gap, provider)} {units}\n"
|
||||
+ $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}";
|
||||
}
|
||||
|
||||
// Significant figures adapt to scale: a positive sliver must never read as zero.
|
||||
public static string Number(double value, IFormatProvider provider = null) =>
|
||||
value.ToString("G6", provider ?? CultureInfo.CurrentCulture);
|
||||
@@ -39,15 +50,21 @@ public static class OverlapPairPresentation
|
||||
/// Coincident or nearby markers return every matching pair in stable ID order.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PlateOverlapPair> HitTest(IEnumerable<PlateOverlapPair> pairs,
|
||||
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi)
|
||||
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi) =>
|
||||
pairs.Where(pair => Hits(worldToScreen(pair.Marker), pointer, deviceDpi))
|
||||
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
|
||||
|
||||
/// <inheritdoc cref="HitTest(IEnumerable{PlateOverlapPair}, Func{Vector, Vector}, Vector, int)"/>
|
||||
public static IReadOnlyList<PlateSpacingPair> HitTest(IEnumerable<PlateSpacingPair> pairs,
|
||||
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi) =>
|
||||
pairs.Where(pair => Hits(worldToScreen(pair.Marker), pointer, deviceDpi))
|
||||
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
|
||||
|
||||
private static bool Hits(Vector center, Vector pointer, int deviceDpi)
|
||||
{
|
||||
var radius = HitRadius(deviceDpi);
|
||||
return pairs.Where(pair =>
|
||||
{
|
||||
var center = worldToScreen(pair.Marker);
|
||||
var dx = pointer.X - center.X;
|
||||
var dy = pointer.Y - center.Y;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
}).OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
|
||||
var dx = pointer.X - center.X;
|
||||
var dy = pointer.Y - center.Y;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
}
|
||||
}
|
||||
@@ -23,20 +23,24 @@ public sealed class OverlapReportState
|
||||
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
|
||||
public IReadOnlyList<CutOffClampFinding> DisplayCutOffClampFindings { get; private set; } =
|
||||
Array.Empty<CutOffClampFinding>();
|
||||
/// <summary>Known part-spacing pairs safe to draw, retained like <see cref="DisplayPairs"/>.</summary>
|
||||
public IReadOnlyList<PlateSpacingPair> DisplaySpacingPairs { get; private set; } =
|
||||
Array.Empty<PlateSpacingPair>();
|
||||
public bool IsRunning => Status == OverlapCheckStatus.Checking;
|
||||
/// <summary>A current, fully checked report with no finding of any kind.</summary>
|
||||
public bool IsAllClear => Status == OverlapCheckStatus.Current && Report.Pairs.Count == 0
|
||||
&& Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
|
||||
&& Report.SpacingPairs.Count == 0;
|
||||
|
||||
public string Message => Status switch
|
||||
{
|
||||
OverlapCheckStatus.Checking => "Checking overlaps…",
|
||||
OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
|
||||
? (Report.Pairs.Count == 0 ? "No material overlaps detected"
|
||||
: $"Overlaps: {Report.Pairs.Count} pairs")
|
||||
: $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, "
|
||||
+ $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts",
|
||||
OverlapCheckStatus.Current => CurrentMessage(),
|
||||
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
|
||||
+ (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ")
|
||||
+ (Report.CutOffClampFindings.Count == 0 ? ""
|
||||
: $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ")
|
||||
+ (Report.SpacingPairs.Count == 0 ? "" : $"{Report.SpacingPairs.Count} pairs under part spacing; ")
|
||||
+ $"{uncheckedPartCount} parts could not be checked",
|
||||
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
|
||||
OverlapCheckStatus.Canceled => "Overlap check canceled",
|
||||
@@ -44,6 +48,22 @@ public sealed class OverlapReportState
|
||||
_ => "Overlaps: not checked"
|
||||
};
|
||||
|
||||
// Only a fully checked report that required a gap may clear spacing as well as overlaps.
|
||||
private string CurrentMessage()
|
||||
{
|
||||
var spacing = Report.SpacingPairs.Count == 0 ? ""
|
||||
: $"; part spacing: {Report.SpacingPairs.Count} pairs too close";
|
||||
if (Report.ClampFindings.Count != 0 || Report.CutOffClampFindings.Count != 0)
|
||||
return $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, "
|
||||
+ $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts{spacing}";
|
||||
if (Report.Pairs.Count != 0)
|
||||
return $"Overlaps: {Report.Pairs.Count} pairs{spacing}";
|
||||
if (spacing.Length != 0)
|
||||
return "No material overlaps" + spacing;
|
||||
return Report.SpacingChecked ? "No material overlaps or part spacing violations detected"
|
||||
: "No material overlaps detected";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a request. A manual check from Off shows Areas; an automatic recheck keeps
|
||||
/// the user's display choice, including Off.
|
||||
@@ -66,9 +86,10 @@ public sealed class OverlapReportState
|
||||
DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs
|
||||
: report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly();
|
||||
DisplayCutOffClampFindings = report.CutOffClampFindings;
|
||||
DisplaySpacingPairs = report.SpacingPairs;
|
||||
displayStamp = stamp;
|
||||
observedDisplayStamp = stamp;
|
||||
uncheckedPartCount = CountUncheckedParts(report.Issues);
|
||||
uncheckedPartCount = CountUncheckedParts(report.Issues.Concat(report.SpacingIssues));
|
||||
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
|
||||
return true;
|
||||
}
|
||||
@@ -123,7 +144,12 @@ public sealed class OverlapReportState
|
||||
&& (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList();
|
||||
if (retained.Count != DisplayPairs.Count)
|
||||
DisplayPairs = retained.AsReadOnly();
|
||||
if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0)
|
||||
var sameSpacing = displayStamp.SameSpacing(plate);
|
||||
var retainedSpacing = DisplaySpacingPairs.Where(pair => sameSpacing
|
||||
&& unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList();
|
||||
if (retainedSpacing.Count != DisplaySpacingPairs.Count)
|
||||
DisplaySpacingPairs = retainedSpacing.AsReadOnly();
|
||||
if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0 && DisplaySpacingPairs.Count == 0)
|
||||
ClearDisplayPairs();
|
||||
else
|
||||
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
|
||||
@@ -133,6 +159,7 @@ public sealed class OverlapReportState
|
||||
{
|
||||
DisplayPairs = Array.Empty<PlateOverlapPair>();
|
||||
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
||||
DisplaySpacingPairs = Array.Empty<PlateSpacingPair>();
|
||||
displayStamp = null;
|
||||
observedDisplayStamp = null;
|
||||
}
|
||||
|
||||
@@ -21,10 +21,19 @@ namespace OpenNest.Diagnostics;
|
||||
/// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays
|
||||
/// valid.
|
||||
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
|
||||
/// that check. The sheet edge and clamps are not spacing-checked here.
|
||||
/// that check. The sheet edge and clamps are not spacing-checked here. The pre-post review and the
|
||||
/// desktop overlap label share this scan, so they cannot disagree.
|
||||
/// </summary>
|
||||
internal static class PartSpacingReview
|
||||
{
|
||||
/// <summary>One part to scan: <see cref="Id"/> is its position in the overlap check's input.
|
||||
/// The profile loads inside the scan's error boundary; null means no material.</summary>
|
||||
internal sealed record SpacingInput(int Id, Func<ShapeProfile> Profile, Vector Location);
|
||||
|
||||
/// <summary>True when <paramref name="spacing"/> requires any gap at all.</summary>
|
||||
internal static bool Enforces(double spacing) =>
|
||||
double.IsFinite(spacing) && SpacingOutline.InflationFor(spacing) > Tolerance.Epsilon;
|
||||
|
||||
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
|
||||
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -36,14 +45,58 @@ internal static class PartSpacingReview
|
||||
$"Part spacing check: plate part spacing {spacing} is not a finite, non-negative value."));
|
||||
return;
|
||||
}
|
||||
var profiles = new Dictionary<(Program, long), ShapeProfile>();
|
||||
var inputs = parts.Select((part, id) => new SpacingInput(id, () => Profile(part, profiles), part.Location))
|
||||
.ToList();
|
||||
Scan(inputs, spacing, overlap.Pairs, overlap.Issues,
|
||||
(a, b, message) => findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumbers[a],
|
||||
b is { } other ? partNumbers[other] : null, "Part spacing check: " + message)),
|
||||
(a, b, gap, marker) => findings.Add(new(PostVerificationKind.PartSpacing, plateNumber, partNumbers[a],
|
||||
partNumbers[b],
|
||||
$"'{Name(parts[a])}' and '{Name(parts[b])}' are {gap:F3} apart, closer than the "
|
||||
+ $"plate's part spacing ({spacing:F3}).")
|
||||
{ Location = marker }),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The desktop label's check: the same scan over a snapshot's owned material, so it runs on
|
||||
/// the overlap worker and never reads a live part. Pairs and issues keep input ids.
|
||||
/// </summary>
|
||||
internal static void Add(PlateOverlapSnapshot snapshot, IReadOnlyList<PlateOverlapPair> overlaps,
|
||||
IReadOnlyList<PlateOverlapIssue> overlapIssues, List<PlateSpacingPair> pairs,
|
||||
List<PlateOverlapIssue> issues, CancellationToken cancellationToken)
|
||||
{
|
||||
var spacing = snapshot.PartSpacing;
|
||||
if (!Enforces(spacing))
|
||||
return;
|
||||
var names = snapshot.Parts.ToDictionary(part => part.Id, part => part.Name);
|
||||
var profiles = new Dictionary<(OverlapSource, long), ShapeProfile>();
|
||||
var inputs = snapshot.Parts
|
||||
.Select(part => new SpacingInput(part.Id, () => Profile(part, profiles), part.Location)).ToList();
|
||||
Scan(inputs, spacing, overlaps, overlapIssues,
|
||||
(a, b, message) => issues.Add(new PlateOverlapIssue(a, b, message)),
|
||||
(a, b, gap, marker) => pairs.Add(new PlateSpacingPair(a, b, names[a], names[b], gap, marker)),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports each pair by input id, lower id first. <paramref name="incomplete"/> receives a
|
||||
/// part or pair that could not be read; <paramref name="tooClose"/> a confirmed gap and its marker.
|
||||
/// </summary>
|
||||
private static void Scan(IReadOnlyList<SpacingInput> parts, double spacing,
|
||||
IEnumerable<PlateOverlapPair> overlaps, IEnumerable<PlateOverlapIssue> overlapIssues,
|
||||
Action<int, int?, string> incomplete, Action<int, int, double, Vector> tooClose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inflateBy = SpacingOutline.InflationFor(spacing);
|
||||
if (inflateBy <= Tolerance.Epsilon || parts.Count < 2)
|
||||
return;
|
||||
|
||||
var unreadable = overlap.Issues.Where(issue => issue.PartBId == null)
|
||||
var unreadable = overlapIssues.Where(issue => issue.PartBId == null)
|
||||
.Select(issue => issue.PartAId).ToHashSet();
|
||||
var overlapping = overlap.Pairs.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
|
||||
var profiles = new Dictionary<(Program, long), ShapeProfile>();
|
||||
var overlapping = overlaps.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
|
||||
var outlines = new Dictionary<ShapeProfile, (SpacingOutline Raw, SpacingOutline Inflated)>();
|
||||
var shapes = new ShapeProfile[parts.Count];
|
||||
var fine = new NativeContours[parts.Count];
|
||||
var raw = new SpacingOutline[parts.Count];
|
||||
@@ -51,24 +104,30 @@ internal static class PartSpacingReview
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (unreadable.Contains(i))
|
||||
if (unreadable.Contains(parts[i].Id))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
var profile = Profile(parts[i], profiles);
|
||||
var profile = parts[i].Profile();
|
||||
if (profile == null)
|
||||
continue;
|
||||
shapes[i] = profile;
|
||||
raw[i] = SpacingOutline.Build(profile, parts[i].Location, 0);
|
||||
inflated[i] = SpacingOutline.Build(profile, parts[i].Location, inflateBy);
|
||||
if (raw[i] == null || inflated[i] == null)
|
||||
// Repeated parts share a profile, so each shape is offset once.
|
||||
if (!outlines.TryGetValue(profile, out var outline))
|
||||
{
|
||||
outline = (SpacingOutline.Build(profile, Vector.Zero, 0),
|
||||
SpacingOutline.Build(profile, Vector.Zero, inflateBy));
|
||||
outlines[profile] = outline;
|
||||
}
|
||||
if (outline.Raw == null || outline.Inflated == null)
|
||||
throw new ArgumentException("Part material has no closed perimeter.");
|
||||
raw[i] = outline.Raw.MovedTo(parts[i].Location);
|
||||
inflated[i] = outline.Inflated.MovedTo(parts[i].Location);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
raw[i] = inflated[i] = null;
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumbers[i], null,
|
||||
"Part spacing check: " + exception.Message));
|
||||
incomplete(parts[i].Id, null, exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +147,8 @@ internal static class PartSpacingReview
|
||||
// Sorted by Left, so nothing further along can reach part i either.
|
||||
if (other.Left > reach.Right + Tolerance.Epsilon)
|
||||
break;
|
||||
if (!BoxesTouch(reach, other) || overlapping.Contains(Key(i, j))
|
||||
var (first, second) = Key(parts[i].Id, parts[j].Id);
|
||||
if (!BoxesTouch(reach, other) || overlapping.Contains((first, second))
|
||||
|| !inflated[i].Overlaps(raw[j]))
|
||||
continue;
|
||||
double gap;
|
||||
@@ -101,20 +161,11 @@ internal static class PartSpacingReview
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber,
|
||||
System.Math.Min(partNumbers[i], partNumbers[j]),
|
||||
System.Math.Max(partNumbers[i], partNumbers[j]),
|
||||
"Part spacing check: " + exception.Message));
|
||||
incomplete(first, second, exception.Message);
|
||||
continue;
|
||||
}
|
||||
if (!(gap < spacing - SpacingOutline.SpacingSlack))
|
||||
continue;
|
||||
var (first, second) = partNumbers[i] < partNumbers[j] ? (i, j) : (j, i);
|
||||
findings.Add(new(PostVerificationKind.PartSpacing, plateNumber, partNumbers[first],
|
||||
partNumbers[second],
|
||||
$"'{Name(parts[first])}' and '{Name(parts[second])}' are {gap:F3} apart, closer than the "
|
||||
+ $"plate's part spacing ({spacing:F3}).")
|
||||
{ Location = marker });
|
||||
if (gap < spacing - SpacingOutline.SpacingSlack)
|
||||
tooClose(first, second, gap, marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,16 +182,34 @@ internal static class PartSpacingReview
|
||||
if (profiles.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
// Conversion creates fresh geometry, so rotating it never touches the live program.
|
||||
var entities = ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
|
||||
.Where(entity => entity.Layer != SpecialLayers.Leadin && entity.Layer != SpecialLayers.Leadout)
|
||||
.ForNesting();
|
||||
var profile = Rotated(ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
|
||||
.Where(entity => entity.Layer != SpecialLayers.Leadin && entity.Layer != SpecialLayers.Leadout),
|
||||
rotation);
|
||||
profiles[key] = profile;
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <summary>A snapshot source's material at a part's pose, from clones: sources are shared.</summary>
|
||||
private static ShapeProfile Profile(CapturedOverlapPart part,
|
||||
Dictionary<(OverlapSource, long), ShapeProfile> profiles)
|
||||
{
|
||||
var key = (part.Source, BitConverter.DoubleToInt64Bits(part.Rotation));
|
||||
if (profiles.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
var profile = Rotated(part.Source.Entities.Select(entity => entity.Clone()), part.Rotation);
|
||||
profiles[key] = profile;
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static ShapeProfile Rotated(IEnumerable<Entity> owned, double rotation)
|
||||
{
|
||||
var entities = owned.ForNesting();
|
||||
if (rotation != 0)
|
||||
foreach (var entity in entities)
|
||||
entity.Rotate(rotation);
|
||||
var profile = entities.Count == 0 ? null : new ShapeProfile(entities);
|
||||
if (profile != null && profile.Perimeter == null)
|
||||
throw new ArgumentException("Part material has no closed perimeter.");
|
||||
profiles[key] = profile;
|
||||
return profile;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ public static class PlateOverlapAnalyzer
|
||||
public const double ChordTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Capture a whole plate including active clamps and nominal cut-off definitions.
|
||||
/// Capture a whole plate including active clamps, nominal cut-off definitions and part spacing.
|
||||
/// Use this entry for the desktop overlap check so manual cut-off contacts
|
||||
/// cannot be mistaken for a clear plate merely because cut-offs are not material.
|
||||
/// Clamp zones are mapped from the sheet's lower-left frame into the parts' signed
|
||||
@@ -33,7 +33,7 @@ public static class PlateOverlapAnalyzer
|
||||
var cutoffs = ClampCutDiagnostics.Find(plate);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(),
|
||||
material.Clamps.ToList(), cutoffs.ToList());
|
||||
material.Clamps.ToList(), cutoffs.ToList(), plate.PartSpacing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -223,11 +223,16 @@ public static class PlateOverlapAnalyzer
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken);
|
||||
var spacingPairs = new List<PlateSpacingPair>();
|
||||
var spacingIssues = new List<PlateOverlapIssue>();
|
||||
PartSpacingReview.Add(snapshot, pairs, issues, spacingPairs, spacingIssues, 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, clampFindings
|
||||
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(),
|
||||
snapshot.CutOffClampFindings.ToList());
|
||||
snapshot.CutOffClampFindings.ToList(), spacingPairs.OrderBy(pair => pair.PartAId)
|
||||
.ThenBy(pair => pair.PartBId).ToList(), spacingIssues.OrderBy(issue => issue.PartAId)
|
||||
.ThenBy(issue => issue.PartBId).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,13 +9,16 @@ public sealed class PlateOverlapReport
|
||||
{
|
||||
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
|
||||
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null,
|
||||
List<CutOffClampFinding> cutOffClampFindings = null)
|
||||
List<CutOffClampFinding> cutOffClampFindings = null, List<PlateSpacingPair> spacingPairs = null,
|
||||
List<PlateOverlapIssue> spacingIssues = null)
|
||||
{
|
||||
Pairs = pairs.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
Snapshot = snapshot;
|
||||
ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly();
|
||||
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
||||
SpacingPairs = (spacingPairs ?? new List<PlateSpacingPair>()).AsReadOnly();
|
||||
SpacingIssues = (spacingIssues ?? new List<PlateOverlapIssue>()).AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
|
||||
@@ -33,7 +36,22 @@ public sealed class PlateOverlapReport
|
||||
/// <summary>Nominal cut-off/clamp contacts. Lines have no material area or part pair.</summary>
|
||||
public IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
||||
|
||||
public bool IsComplete => Issues.Count == 0;
|
||||
/// <summary>
|
||||
/// Pairs that share no material but sit closer than the plate's part spacing. Only a plate
|
||||
/// capture carries the spacing; see <see cref="SpacingChecked"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PlateSpacingPair> SpacingPairs { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parts or pairs the spacing scan could not read. Kept apart from <see cref="Issues"/>,
|
||||
/// which incremental rechecks reuse by overlap pair.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PlateOverlapIssue> SpacingIssues { get; }
|
||||
|
||||
/// <summary>True when the input required a gap, so empty SpacingPairs means something.</summary>
|
||||
public bool SpacingChecked => PartSpacingReview.Enforces(Snapshot.PartSpacing);
|
||||
|
||||
public bool IsComplete => Issues.Count == 0 && SpacingIssues.Count == 0;
|
||||
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
|
||||
}
|
||||
|
||||
@@ -103,6 +121,13 @@ public sealed class PlateOverlapPair
|
||||
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two parts closer than the plate's part spacing, ordered by zero-based input index.
|
||||
/// <see cref="Marker"/> is the middle of the narrowest gap, in world coordinates.
|
||||
/// </summary>
|
||||
public sealed record PlateSpacingPair(int PartAId, int PartBId, string PartAName, string PartBName,
|
||||
double Gap, Vector Marker);
|
||||
|
||||
/// <summary>
|
||||
/// One connected island of shared material in world coordinates, after hole subtraction:
|
||||
/// a counterclockwise outline and its clockwise holes.
|
||||
@@ -144,12 +169,14 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message
|
||||
public sealed class PlateOverlapSnapshot
|
||||
{
|
||||
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues,
|
||||
List<CapturedClamp> clamps = null, List<CutOffClampFinding> cutOffClampFindings = null)
|
||||
List<CapturedClamp> clamps = null, List<CutOffClampFinding> cutOffClampFindings = null,
|
||||
double partSpacing = 0)
|
||||
{
|
||||
Parts = parts.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
|
||||
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
||||
PartSpacing = partSpacing;
|
||||
}
|
||||
|
||||
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
||||
@@ -158,6 +185,9 @@ public sealed class PlateOverlapSnapshot
|
||||
/// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary>
|
||||
internal IReadOnlyList<CapturedClamp> Clamps { get; }
|
||||
internal IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
||||
|
||||
/// <summary>The plate's part spacing; zero (no spacing check) for a bare group of parts.</summary>
|
||||
internal double PartSpacing { get; }
|
||||
}
|
||||
|
||||
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
|
||||
|
||||
@@ -85,6 +85,24 @@ namespace OpenNest.Geometry
|
||||
return new SpacingOutline(perimeter, region.Holes);
|
||||
}
|
||||
|
||||
/// <summary>This outline, built at the origin, moved to <paramref name="location"/>: what
|
||||
/// <see cref="Build"/> returns there, without repeating the offset for identical parts.</summary>
|
||||
public SpacingOutline MovedTo(Vector location)
|
||||
{
|
||||
var perimeter = (Polygon)Perimeter.Clone();
|
||||
ToWorld(perimeter, location);
|
||||
var holes = new List<Polygon>(Holes.Count);
|
||||
|
||||
foreach (var hole in Holes)
|
||||
{
|
||||
var moved = (Polygon)hole.Clone();
|
||||
ToWorld(moved, location);
|
||||
holes.Add(moved);
|
||||
}
|
||||
|
||||
return new SpacingOutline(perimeter, holes);
|
||||
}
|
||||
|
||||
/// <summary>True when the two outlines share material (holes subtracted).</summary>
|
||||
public bool Overlaps(SpacingOutline other)
|
||||
{
|
||||
|
||||
@@ -122,7 +122,6 @@ public class OverlapReportStateTests
|
||||
state.DisplayMode = OverlapDisplayMode.Off;
|
||||
plate.Parts[0].BaseDrawing.Name = "renamed";
|
||||
plate.Quantity = 2;
|
||||
plate.PartSpacing = 123;
|
||||
Assert.True(state.EnsureFresh(plate));
|
||||
Assert.Same(report, state.Report);
|
||||
Assert.Equal(request, state.Generation);
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Globalization;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
using static OpenNest.Tests.TestHelpers;
|
||||
|
||||
namespace OpenNest.Tests.Diagnostics;
|
||||
|
||||
public class OverlapSpacingLabelTests
|
||||
{
|
||||
private const double Spacing = 0.25;
|
||||
|
||||
[Fact]
|
||||
public void GapUnderSpacing_IsCountedWithoutAnOverlap()
|
||||
{
|
||||
var plate = PlateWith(Spacing, Square(0, "a"), Square(4.1, "b"));
|
||||
|
||||
var report = Analyze(plate);
|
||||
|
||||
Assert.Empty(report.Pairs);
|
||||
var pair = Assert.Single(report.SpacingPairs);
|
||||
Assert.Equal((0, 1, "a", "b"), (pair.PartAId, pair.PartBId, pair.PartAName, pair.PartBName));
|
||||
Assert.Equal(0.1, pair.Gap, 6);
|
||||
Assert.Equal(4.05, pair.Marker.X, 3);
|
||||
Assert.Equal("No material overlaps; part spacing: 1 pairs too close", Label(plate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearPlate_SaysSpacingWasChecked()
|
||||
{
|
||||
var plate = PlateWith(Spacing, Square(0), Square(4.5));
|
||||
|
||||
Assert.Equal("No material overlaps or part spacing violations detected", Label(plate));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(double.NaN)]
|
||||
public void SpacingNotEnforced_ClaimsOnlyOverlaps(double spacing)
|
||||
{
|
||||
var plate = PlateWith(spacing, Square(0), Square(4));
|
||||
|
||||
Assert.False(Analyze(plate).SpacingChecked);
|
||||
Assert.Equal("No material overlaps detected", Label(plate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BareGroupOfParts_HasNoSpacingToCheck()
|
||||
{
|
||||
var plate = PlateWith(Spacing, Square(0), Square(4.1));
|
||||
|
||||
var report = PlateOverlapAnalyzer.Analyze(plate.Parts.ToArray());
|
||||
|
||||
Assert.False(report.SpacingChecked);
|
||||
Assert.Empty(report.SpacingPairs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlappingPair_IsNotAlsoASpacingPair()
|
||||
{
|
||||
var plate = PlateWith(Spacing, Square(0), Square(3), Square(7.1));
|
||||
|
||||
var report = Analyze(plate);
|
||||
|
||||
Assert.Single(report.Pairs);
|
||||
Assert.Equal((1, 2), (report.SpacingPairs[0].PartAId, report.SpacingPairs[0].PartBId));
|
||||
Assert.Equal("Overlaps: 1 pairs; part spacing: 1 pairs too close", Label(plate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutOffs_AreSkippedAndKeepInputIds()
|
||||
{
|
||||
var line = new Program();
|
||||
line.MoveTo(0, 4.05);
|
||||
line.LineTo(4, 4.05);
|
||||
var plate = PlateWith(Spacing, new Part(new Drawing("cutoff", line) { IsCutOff = true }),
|
||||
Square(0), Square(4.1));
|
||||
|
||||
var pair = Assert.Single(Analyze(plate).SpacingPairs);
|
||||
|
||||
Assert.Equal((1, 2), (pair.PartAId, pair.PartBId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnreadablePart_LeavesTheLabelIncomplete()
|
||||
{
|
||||
var plate = PlateWith(Spacing, Square(0), Square(4.1), new Part(new Drawing("invalid", new Program())));
|
||||
|
||||
var report = Analyze(plate);
|
||||
|
||||
Assert.False(report.IsComplete);
|
||||
Assert.Single(report.SpacingPairs);
|
||||
Assert.Contains("1 pairs under part spacing; 1 parts could not be checked", Label(plate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesThePrePostReview()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = nest.CreatePlate();
|
||||
plate.PartSpacing = Spacing;
|
||||
foreach (var part in new[] { Square(0, "a"), Square(4.1, "b"), Square(8.3, "c"), Square(13, "d") })
|
||||
plate.Parts.Add(part);
|
||||
|
||||
var label = Analyze(plate).SpacingPairs.Select(pair => (pair.PartAId + 1, pair.PartBId + 1));
|
||||
var review = PostVerificationAnalyzer.Analyze(nest).Findings
|
||||
.Where(finding => finding.Kind == PostVerificationKind.PartSpacing)
|
||||
.Select(finding => (finding.PartNumber!.Value, finding.OtherPartNumber!.Value));
|
||||
|
||||
Assert.Equal(review, label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MovedPart_IsRecheckedAgainstThePreviousReport()
|
||||
{
|
||||
var plate = PlateWith(Spacing, Square(0), Square(4.1));
|
||||
var cache = new OverlapMaterialCache();
|
||||
var first = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate, cache));
|
||||
Assert.Single(first.SpacingPairs);
|
||||
|
||||
plate.Parts[1].Offset(1, 0);
|
||||
var second = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate, cache), first);
|
||||
|
||||
Assert.Empty(second.SpacingPairs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangingPartSpacing_MakesTheReportStale()
|
||||
{
|
||||
var plate = PlateWith(Spacing, Square(0), Square(4.5));
|
||||
var state = new OverlapReportState();
|
||||
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
|
||||
|
||||
plate.PartSpacing = 1;
|
||||
|
||||
Assert.False(state.EnsureFresh(plate));
|
||||
Assert.Equal(OverlapCheckStatus.Stale, state.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Markers_KeepOnlyPairsOfUnchangedPartsUntilTheRecheck()
|
||||
{
|
||||
var plate = PlateWith(Spacing, Square(0), Square(4.1), Square(20), Square(24.1));
|
||||
var state = new OverlapReportState();
|
||||
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
|
||||
Assert.Equal(2, state.DisplaySpacingPairs.Count);
|
||||
|
||||
plate.Parts[3].Offset(1, 0);
|
||||
Assert.False(state.EnsureFresh(plate));
|
||||
|
||||
var kept = Assert.Single(state.DisplaySpacingPairs);
|
||||
Assert.Equal((0, 1), (kept.PartAId, kept.PartBId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Markers_ClearWhenPartSpacingChanges()
|
||||
{
|
||||
var plate = PlateWith(Spacing, Square(0), Square(4.1));
|
||||
var state = new OverlapReportState();
|
||||
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
|
||||
|
||||
plate.PartSpacing = 0.05;
|
||||
state.EnsureFresh(plate);
|
||||
|
||||
Assert.Empty(state.DisplaySpacingPairs);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(4.5, 0.0, Spacing, true)] // clear
|
||||
[InlineData(4.0, 0.0, 0.0, true)] // contact with nothing enforced
|
||||
[InlineData(4.1, 0.0, Spacing, false)] // too close
|
||||
[InlineData(3.0, 0.0, Spacing, false)] // overlap
|
||||
public void AllClear_RequiresNoFindings(double x, double y, double spacing, bool clear)
|
||||
{
|
||||
var second = Square(x);
|
||||
second.Offset(0, y);
|
||||
var plate = PlateWith(spacing, Square(0), second);
|
||||
var state = new OverlapReportState();
|
||||
Assert.False(state.IsAllClear);
|
||||
|
||||
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
|
||||
|
||||
Assert.Equal(clear, state.IsAllClear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllClear_IsNeverShownForAnIncompleteOrStaleCheck()
|
||||
{
|
||||
var plate = PlateWith(Spacing, Square(0), new Part(new Drawing("invalid", new Program())));
|
||||
var state = new OverlapReportState();
|
||||
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
|
||||
Assert.False(state.IsAllClear);
|
||||
|
||||
plate = PlateWith(Spacing, Square(0), Square(4.5));
|
||||
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
|
||||
Assert.True(state.IsAllClear);
|
||||
plate.Parts[1].Offset(-0.4, 0);
|
||||
state.EnsureFresh(plate);
|
||||
Assert.False(state.IsAllClear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presentation_ShowsPositionsNamesAndGap()
|
||||
{
|
||||
var pair = new PlateSpacingPair(1, 2, "a", "b", 0.1, new Vector(4.05, 2));
|
||||
|
||||
Assert.Equal("2/3 gap 0.1", OverlapPairPresentation.Label(pair, CultureInfo.InvariantCulture));
|
||||
Assert.Equal("Part spacing 2/3: a / b\nGap ≈ 0.1 in\nMarker: (4.05, 2) in",
|
||||
OverlapPairPresentation.Details(pair, Units.Inches, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private static string Label(Plate plate)
|
||||
{
|
||||
var state = new OverlapReportState();
|
||||
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
|
||||
return state.Message;
|
||||
}
|
||||
|
||||
private static PlateOverlapReport Analyze(Plate plate) =>
|
||||
PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate));
|
||||
|
||||
private static Plate PlateWith(double spacing, params Part[] parts)
|
||||
{
|
||||
var plate = new Plate { PartSpacing = spacing };
|
||||
foreach (var part in parts)
|
||||
plate.Parts.Add(part);
|
||||
return plate;
|
||||
}
|
||||
|
||||
private static Part Square(double x, string name = "square") =>
|
||||
new(new Drawing(name, MakeSquareProgram(0, 0, 4)), new Vector(x, 0));
|
||||
}
|
||||
@@ -292,6 +292,88 @@ public class PlateOverlapOverlayTests
|
||||
Assert.Contains("incomplete: 1 overlapping pairs; 1 parts", run.View.Status);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void LabelReportsPartSpacingAndRechecksWhenItChanges() => RunSta(() =>
|
||||
{
|
||||
using var run = new OverlayRun();
|
||||
run.View.Plate.PartSpacing = 0.25;
|
||||
run.View.Plate.Parts.Add(Rectangle(5.1));
|
||||
run.Finish();
|
||||
Assert.Equal("Overlaps: 1 pairs; part spacing: 1 pairs too close", run.View.OverlapOverlay.Message);
|
||||
Assert.Equal(run.View.OverlapOverlay.Message, run.View.Status);
|
||||
|
||||
run.View.Plate.PartSpacing = 0.05;
|
||||
run.Paint();
|
||||
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
|
||||
run.Finish();
|
||||
Assert.Equal("Overlaps: 1 pairs", run.View.OverlapOverlay.Message);
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData(OverlapDisplayMode.Areas, true)]
|
||||
[InlineData(OverlapDisplayMode.Centroids, true)]
|
||||
[InlineData(OverlapDisplayMode.Both, true)]
|
||||
[InlineData(OverlapDisplayMode.Off, false)]
|
||||
public void SpacingRingDrawsAndHoversInEveryVisibleMode(OverlapDisplayMode mode, bool visible) => RunSta(() =>
|
||||
{
|
||||
using var run = new OverlayRun();
|
||||
run.View.Plate.Parts.Clear();
|
||||
run.View.Plate.PartSpacing = 0.25;
|
||||
run.View.Plate.Parts.Add(Rectangle());
|
||||
run.View.Plate.Parts.Add(Rectangle(4.1));
|
||||
run.Finish();
|
||||
run.View.OverlapDisplay = mode;
|
||||
run.View.ZoomToPoint(new Vector(), 20);
|
||||
run.View.SetOrigin(60, 220);
|
||||
using var image = new Bitmap(300, 300);
|
||||
using var graphics = Graphics.FromImage(image);
|
||||
graphics.Clear(Color.White);
|
||||
graphics.TranslateTransform(60, 220);
|
||||
run.View.OverlapOverlay.Draw(graphics);
|
||||
|
||||
var pair = Assert.Single(run.View.OverlapReport.SpacingPairs);
|
||||
var center = run.View.PointWorldToControl(pair.Marker);
|
||||
var radius = (int)OverlapPairPresentation.MarkerHalfSize(run.View.DeviceDpi);
|
||||
Assert.Equal(visible, image.GetPixel(center.X - radius, center.Y).ToArgb() != Color.White.ToArgb());
|
||||
run.View.MoveTo(center);
|
||||
if (visible)
|
||||
Assert.Contains("Part spacing 1/2", run.View.OverlapOverlay.HoverText);
|
||||
else
|
||||
Assert.Null(run.View.OverlapOverlay.HoverText);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void ClearPlateDrawsAGreenCheckInsteadOfTheLabelButStatusKeepsTheWords() => RunSta(() =>
|
||||
{
|
||||
using var run = new OverlayRun();
|
||||
run.View.Plate.Parts.RemoveAt(1);
|
||||
run.Finish();
|
||||
Assert.True(run.View.OverlapOverlay.ShowsAllClear);
|
||||
Assert.Equal("No material overlaps detected", run.View.Status);
|
||||
Assert.True(CheckIsDrawn(run));
|
||||
|
||||
run.View.Plate.Parts.Add(Rectangle(1));
|
||||
run.Finish();
|
||||
Assert.False(run.View.OverlapOverlay.ShowsAllClear);
|
||||
Assert.False(CheckIsDrawn(run));
|
||||
});
|
||||
|
||||
private static bool CheckIsDrawn(OverlayRun run)
|
||||
{
|
||||
using var image = new Bitmap(64, 64);
|
||||
using var graphics = Graphics.FromImage(image);
|
||||
graphics.Clear(Color.White);
|
||||
run.View.OverlapOverlay.Draw(graphics);
|
||||
for (var x = 0; x < 64; x++)
|
||||
for (var y = 0; y < 64; y++)
|
||||
{
|
||||
var pixel = image.GetPixel(x, y);
|
||||
if (pixel.G > pixel.R + 60 && pixel.G > pixel.B + 40)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotKeepsOriginalLabelsWhenLiveNamesChange() => RunSta(() =>
|
||||
{
|
||||
|
||||
@@ -38,6 +38,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
private Units capturedUnits;
|
||||
private Plate observedPlate;
|
||||
private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>();
|
||||
private IReadOnlyList<PlateSpacingPair> hoveredSpacing = Array.Empty<PlateSpacingPair>();
|
||||
private string hoverText;
|
||||
private OverlapHoverPages hoverPages;
|
||||
private Point hoverPoint;
|
||||
@@ -79,6 +80,9 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
? "Overlaps: check pending…"
|
||||
: state.Message;
|
||||
|
||||
/// <summary>The canvas shows a green check instead of the label: a pending recheck is not clear.</summary>
|
||||
internal bool ShowsAllClear => state.IsAllClear && !autoCheck.IsWaiting;
|
||||
|
||||
/// <summary>
|
||||
/// Recheck automatically after layout edits settle. Units are read when each request
|
||||
/// starts, like the manual command. Pass null to return to manual-only checks.
|
||||
@@ -381,7 +385,10 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
DrawCentroids(graphics);
|
||||
}
|
||||
if (state.DisplayMode != OverlapDisplayMode.Off)
|
||||
{
|
||||
DrawCutOffClampContacts(graphics);
|
||||
DrawSpacingMarkers(graphics);
|
||||
}
|
||||
DrawStateLabel(graphics);
|
||||
}
|
||||
|
||||
@@ -422,7 +429,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
public void UpdateHover(Point point)
|
||||
{
|
||||
ValidateHoverTransform();
|
||||
if (!EnsureFresh() || !ShowsCentroids || state.Report == null)
|
||||
if (!EnsureFresh() || state.DisplayMode == OverlapDisplayMode.Off || state.Report == null)
|
||||
{
|
||||
ClearHover();
|
||||
return;
|
||||
@@ -430,22 +437,30 @@ 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.DisplayPairs, world =>
|
||||
var pointer = new Vector(graphPoint.X, graphPoint.Y);
|
||||
Vector ToGraph(Vector world)
|
||||
{
|
||||
var graph = view.PointWorldToGraph(world);
|
||||
return new Vector(graph.X, graph.Y);
|
||||
}, new Vector(graphPoint.X, graphPoint.Y), view.DeviceDpi);
|
||||
var changed = !hits.SequenceEqual(hoveredPairs);
|
||||
}
|
||||
// Overlap markers exist only in these modes; spacing markers in every visible one.
|
||||
var hits = ShowsCentroids
|
||||
? OverlapPairPresentation.HitTest(state.DisplayPairs, ToGraph, pointer, view.DeviceDpi)
|
||||
: Array.Empty<PlateOverlapPair>();
|
||||
var spacingHits = OverlapPairPresentation.HitTest(state.DisplaySpacingPairs, ToGraph, pointer, view.DeviceDpi);
|
||||
var changed = !hits.SequenceEqual(hoveredPairs) || !spacingHits.SequenceEqual(hoveredSpacing);
|
||||
var moved = point != hoverPoint;
|
||||
hoveredPairs = hits;
|
||||
hoveredSpacing = spacingHits;
|
||||
hoverPoint = point;
|
||||
hoverTransform = ViewTransform;
|
||||
if (changed)
|
||||
{
|
||||
hoverPages = null;
|
||||
HoverBounds = RectangleF.Empty;
|
||||
hoverText = hits.Count == 0 ? null : string.Join("\n\n",
|
||||
hits.Select(pair => OverlapPairPresentation.Details(pair, capturedUnits)));
|
||||
hoverText = hits.Count + spacingHits.Count == 0 ? null : string.Join("\n\n",
|
||||
hits.Select(pair => OverlapPairPresentation.Details(pair, capturedUnits))
|
||||
.Concat(spacingHits.Select(pair => OverlapPairPresentation.Details(pair, capturedUnits))));
|
||||
}
|
||||
if (changed || (moved && hoverText != null))
|
||||
view.Invalidate();
|
||||
@@ -464,6 +479,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
hoverPages = null;
|
||||
HoverBounds = RectangleF.Empty;
|
||||
hoveredPairs = Array.Empty<PlateOverlapPair>();
|
||||
hoveredSpacing = Array.Empty<PlateSpacingPair>();
|
||||
if (visible && !disposed && !view.IsDisposed && !view.Disposing)
|
||||
view.Invalidate();
|
||||
}
|
||||
@@ -527,7 +543,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
using var format = (StringFormat)StringFormat.GenericTypographic.Clone();
|
||||
format.FormatFlags |= StringFormatFlags.NoWrap | StringFormatFlags.MeasureTrailingSpaces;
|
||||
float Measure(string line) => graphics.MeasureString(line, view.Font, PointF.Empty, format).Width;
|
||||
hoverPages ??= OverlapHoverPages.Create(text, hoveredPairs.Count, maxRows, maxWidth, line => Measure(line));
|
||||
hoverPages ??= OverlapHoverPages.Create(text, hoveredPairs.Count + hoveredSpacing.Count, maxRows, maxWidth, line => Measure(line));
|
||||
if (hoverPages.NeedsLargerViewport)
|
||||
{
|
||||
// A viewport smaller than a content line plus its paging hint cannot
|
||||
@@ -637,12 +653,70 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// A gap has no shared area to shade, so every visible mode marks it: an orange ring at the
|
||||
// narrowest point, apart from the red overlap crosshairs. Labels on one point stack.
|
||||
private void DrawSpacingMarkers(Graphics graphics)
|
||||
{
|
||||
var dpiScale = view.DeviceDpi / 96f;
|
||||
var radius = (float)OverlapPairPresentation.MarkerHalfSize(view.DeviceDpi);
|
||||
var color = Color.FromArgb(200, 90, 0);
|
||||
using var halo = new Pen(Color.White, 4 * dpiScale);
|
||||
using var ring = new Pen(color, 2 * dpiScale);
|
||||
using var text = new SolidBrush(color);
|
||||
var labelRows = new Dictionary<PointF, int>();
|
||||
foreach (var pair in state.DisplaySpacingPairs)
|
||||
{
|
||||
var center = view.PointWorldToGraph(pair.Marker);
|
||||
if (!float.IsFinite(center.X) || !float.IsFinite(center.Y))
|
||||
continue;
|
||||
foreach (var pen in new[] { halo, ring })
|
||||
graphics.DrawEllipse(pen, center.X - radius, center.Y - radius, radius * 2, radius * 2);
|
||||
var label = OverlapPairPresentation.Label(pair);
|
||||
var size = graphics.MeasureString(label, view.Font);
|
||||
labelRows.TryGetValue(center, out var row);
|
||||
labelRows[center] = row + 1;
|
||||
var x = center.X + radius + 3 * dpiScale;
|
||||
var y = center.Y - radius + row * size.Height;
|
||||
graphics.FillRectangle(Brushes.White, x, y, size.Width, size.Height);
|
||||
graphics.DrawString(label, view.Font, text, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
// Same corner as the label; Message keeps the words for the status bar.
|
||||
private void DrawAllClear(Graphics graphics)
|
||||
{
|
||||
var scale = view.DeviceDpi / 96f;
|
||||
var box = 24 * scale;
|
||||
using var background = new SolidBrush(Color.FromArgb(235, Color.White));
|
||||
graphics.FillRectangle(background, 6, 6, box, box);
|
||||
using var pen = new Pen(Color.FromArgb(0, 140, 60), 3 * scale)
|
||||
{
|
||||
StartCap = LineCap.Round,
|
||||
EndCap = LineCap.Round,
|
||||
LineJoin = LineJoin.Round,
|
||||
};
|
||||
var smoothing = graphics.SmoothingMode;
|
||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
graphics.DrawLines(pen, new[]
|
||||
{
|
||||
new PointF(6 + 5 * scale, 6 + 13 * scale),
|
||||
new PointF(6 + 10 * scale, 6 + 18 * scale),
|
||||
new PointF(6 + 19 * scale, 6 + 7 * scale),
|
||||
});
|
||||
graphics.SmoothingMode = smoothing;
|
||||
}
|
||||
|
||||
private void DrawStateLabel(Graphics graphics)
|
||||
{
|
||||
var saved = graphics.Save();
|
||||
try
|
||||
{
|
||||
graphics.ResetTransform();
|
||||
if (ShowsAllClear)
|
||||
{
|
||||
DrawAllClear(graphics);
|
||||
return;
|
||||
}
|
||||
var text = Message + (state.DisplayMode == OverlapDisplayMode.Off ? " (display off)" : "");
|
||||
var size = graphics.MeasureString(text, view.Font);
|
||||
using var background = new SolidBrush(Color.FromArgb(235, Color.White));
|
||||
|
||||
@@ -29,12 +29,27 @@ Small positive areas use significant-figure formatting rather than rounding to
|
||||
zero. Selection and dragging remain ordinary plate operations, not overlay
|
||||
interactions.
|
||||
|
||||
A persistent label distinguishes unchecked, checking, current, incomplete, stale,
|
||||
A current, fully checked report with no finding of any kind draws a green check in
|
||||
the corner instead of words; the status bar still uses the wording below. Every
|
||||
other state keeps a persistent label, which distinguishes unchecked, checking,
|
||||
current with findings, incomplete, stale,
|
||||
canceled, and failed checks. Only a completed, current, fully checked report can
|
||||
say **No material overlaps detected**. An incomplete check retains known overlaps
|
||||
and states how many distinct parts could not be checked. Pair counts are not
|
||||
fragment counts. This diagnostic checks shared material, not minimum spacing,
|
||||
plate edges, or cutting-path crossings.
|
||||
fragment counts. This diagnostic checks shared material, not plate edges or
|
||||
cutting-path crossings.
|
||||
|
||||
The label also reports minimum spacing. When the plate's part spacing is above zero,
|
||||
pairs that share no material but sit closer than it are counted, as in
|
||||
**Overlaps: 1 pairs; part spacing: 3 pairs too close**, and a clear result reads
|
||||
**No material overlaps or part spacing violations detected**. These are the same
|
||||
pairs the [pre-post check](../post-verification.md) warns about, found by the same
|
||||
`PartSpacingReview` scan on the request's snapshot. Each pair gets an orange ring at
|
||||
the narrowest point of its gap, labeled like `1/3 gap 0.1`, in every display mode
|
||||
except Off: a gap has no shared area to shade, so Areas shows it too. Hover near a
|
||||
ring for captured names, the gap and marker coordinates. Rings follow the same
|
||||
retention as overlap highlights: only pairs involving changed parts disappear before
|
||||
the recheck. Changing the plate's part spacing clears them and reruns the check.
|
||||
|
||||
In a nest window the active plate is checked automatically. Any layout edit
|
||||
(add, remove, reorder, move, rotate, fill) invalidates the full report and shows
|
||||
@@ -210,8 +225,11 @@ at zero. Curved-outline centroids inherit the polygonization approximation.
|
||||
|
||||
Full containment and coincident parts are detected without relying on crossing
|
||||
points. Edge/corner contact with no positive shared material is not overlap.
|
||||
There are no spacing offsets, plate-edge checks, cut-path crossing checks, automatic
|
||||
repairs, export blocks, or machining-validity guarantees.
|
||||
Overlap regions use no spacing offsets; `report.SpacingPairs` is a separate result
|
||||
that only a `CapturePlate` snapshot produces (`report.SpacingChecked`), and its
|
||||
unreadable inputs are in `report.SpacingIssues`. There are no plate-edge checks,
|
||||
cut-path crossing checks, automatic repairs, export blocks, or machining-validity
|
||||
guarantees.
|
||||
|
||||
Arc/circle flattening uses a chord tolerance of `0.001` model units (also exposed
|
||||
as `report.ChordTolerance`). Curved overlaps and topology are therefore polygonal
|
||||
@@ -228,7 +246,7 @@ resolution.
|
||||
|
||||
`OverlapReportState` and `OverlapGeometryStamp` in Core hold the testable request
|
||||
policy. The stamp compares ordered part identities, exact pose scalars, drawing
|
||||
and program references, cutoff status, and plate identity. It is not a geometry
|
||||
and program references, cutoff status, part spacing, and plate identity. It is not a geometry
|
||||
hash: any new editor that mutates a clean program in place must call
|
||||
`PlateView.InvalidateOverlapCheck()` before loading/mutation. Current live clean
|
||||
program editing goes through `EditNestForm.EditDrawingsInConverter_Click`;
|
||||
@@ -290,6 +308,7 @@ and triple coverage does not darken. World-to-graph conversion excludes pan,
|
||||
because PlateView already applies origin translation. Paths are rebuilt for
|
||||
displayed-pair/scale changes, not ordinary repaints or panning. The state label
|
||||
saves/restores graphics state.
|
||||
Spacing rings draw with the cut-off contact marks and have no `GraphicsPath`.
|
||||
Marker hit tests use only cached report coordinates and DPI-scaled screen
|
||||
radii. Hover clears on edits, mode/request/view changes, leave, and teardown.
|
||||
Diagnostic details draw above action adorners and take precedence over the normal
|
||||
|
||||
Reference in new issue
Block a user