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:
ajandClaude Opus 5.5 committed 2026-11-01 23:42:25 -05:00
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;
}
+97 -28
View File
@@ -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,
+18
View File
@@ -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(() =>
{
+81 -7
View File
@@ -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));
+25 -6
View File
@@ -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