diff --git a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs index 0268907f..11a7e351 100644 --- a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs +++ b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs @@ -17,11 +17,13 @@ public sealed class OverlapGeometryStamp private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End, IReadOnlyList 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])) diff --git a/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs b/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs index 8a3b11c3..978d1fe2 100644 --- a/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs +++ b/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs @@ -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. /// public static IReadOnlyList HitTest(IEnumerable pairs, - Func worldToScreen, Vector pointer, int deviceDpi) + Func worldToScreen, Vector pointer, int deviceDpi) => + pairs.Where(pair => Hits(worldToScreen(pair.Marker), pointer, deviceDpi)) + .OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray(); + + /// + public static IReadOnlyList HitTest(IEnumerable pairs, + Func 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; } } diff --git a/OpenNest.Core/Diagnostics/OverlapReportState.cs b/OpenNest.Core/Diagnostics/OverlapReportState.cs index c5f99a14..9a32e93e 100644 --- a/OpenNest.Core/Diagnostics/OverlapReportState.cs +++ b/OpenNest.Core/Diagnostics/OverlapReportState.cs @@ -23,20 +23,24 @@ public sealed class OverlapReportState public IReadOnlyList DisplayPairs { get; private set; } = Array.Empty(); public IReadOnlyList DisplayCutOffClampFindings { get; private set; } = Array.Empty(); + /// Known part-spacing pairs safe to draw, retained like . + public IReadOnlyList DisplaySpacingPairs { get; private set; } = + Array.Empty(); public bool IsRunning => Status == OverlapCheckStatus.Checking; + /// A current, fully checked report with no finding of any kind. + 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"; + } + /// /// 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(); DisplayCutOffClampFindings = Array.Empty(); + DisplaySpacingPairs = Array.Empty(); displayStamp = null; observedDisplayStamp = null; } diff --git a/OpenNest.Core/Diagnostics/PartSpacingReview.cs b/OpenNest.Core/Diagnostics/PartSpacingReview.cs index 6d1cf27c..95fd1a3e 100644 --- a/OpenNest.Core/Diagnostics/PartSpacingReview.cs +++ b/OpenNest.Core/Diagnostics/PartSpacingReview.cs @@ -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. /// internal static class PartSpacingReview { + /// One part to scan: is its position in the overlap check's input. + /// The profile loads inside the scan's error boundary; null means no material. + internal sealed record SpacingInput(int Id, Func Profile, Vector Location); + + /// True when requires any gap at all. + internal static bool Enforces(double spacing) => + double.IsFinite(spacing) && SpacingOutline.InflationFor(spacing) > Tolerance.Epsilon; + internal static void Add(Plate plate, int plateNumber, IReadOnlyList parts, IReadOnlyList partNumbers, PlateOverlapReport overlap, List 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); + } + + /// + /// 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. + /// + internal static void Add(PlateOverlapSnapshot snapshot, IReadOnlyList overlaps, + IReadOnlyList overlapIssues, List pairs, + List 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); + } + + /// + /// Reports each pair by input id, lower id first. receives a + /// part or pair that could not be read; a confirmed gap and its marker. + /// + private static void Scan(IReadOnlyList parts, double spacing, + IEnumerable overlaps, IEnumerable overlapIssues, + Action incomplete, Action 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(); 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; + } + + /// A snapshot source's material at a part's pose, from clones: sources are shared. + 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 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; } diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs index 24974d15..468beb86 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs @@ -18,7 +18,7 @@ public static class PlateOverlapAnalyzer public const double ChordTolerance = 0.001; /// - /// 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); } /// @@ -223,11 +223,16 @@ public static class PlateOverlapAnalyzer } cancellationToken.ThrowIfCancellationRequested(); var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken); + var spacingPairs = new List(); + var spacingIssues = new List(); + 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()); } /// diff --git a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs index 73227037..b112b257 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs @@ -9,13 +9,16 @@ public sealed class PlateOverlapReport { internal PlateOverlapReport(List pairs, List issues, PlateOverlapSnapshot snapshot, List clampFindings = null, - List cutOffClampFindings = null) + List cutOffClampFindings = null, List spacingPairs = null, + List spacingIssues = null) { Pairs = pairs.AsReadOnly(); Issues = issues.AsReadOnly(); Snapshot = snapshot; ClampFindings = (clampFindings ?? new List()).AsReadOnly(); CutOffClampFindings = (cutOffClampFindings ?? new List()).AsReadOnly(); + SpacingPairs = (spacingPairs ?? new List()).AsReadOnly(); + SpacingIssues = (spacingIssues ?? new List()).AsReadOnly(); } /// The owned input this report was computed from; the baseline for incremental rechecks. @@ -33,7 +36,22 @@ public sealed class PlateOverlapReport /// Nominal cut-off/clamp contacts. Lines have no material area or part pair. public IReadOnlyList CutOffClampFindings { get; } - public bool IsComplete => Issues.Count == 0; + /// + /// Pairs that share no material but sit closer than the plate's part spacing. Only a plate + /// capture carries the spacing; see . + /// + public IReadOnlyList SpacingPairs { get; } + + /// + /// Parts or pairs the spacing scan could not read. Kept apart from , + /// which incremental rechecks reuse by overlap pair. + /// + public IReadOnlyList SpacingIssues { get; } + + /// True when the input required a gap, so empty SpacingPairs means something. + 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); } +/// +/// Two parts closer than the plate's part spacing, ordered by zero-based input index. +/// is the middle of the narrowest gap, in world coordinates. +/// +public sealed record PlateSpacingPair(int PartAId, int PartBId, string PartAName, string PartBName, + double Gap, Vector Marker); + /// /// 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 parts, List issues, - List clamps = null, List cutOffClampFindings = null) + List clamps = null, List cutOffClampFindings = null, + double partSpacing = 0) { Parts = parts.AsReadOnly(); Issues = issues.AsReadOnly(); Clamps = (clamps ?? new List()).AsReadOnly(); CutOffClampFindings = (cutOffClampFindings ?? new List()).AsReadOnly(); + PartSpacing = partSpacing; } internal IReadOnlyList Parts { get; } @@ -158,6 +185,9 @@ public sealed class PlateOverlapSnapshot /// Owned clamp keep-out rectangles in plate coordinates, capture order. internal IReadOnlyList Clamps { get; } internal IReadOnlyList CutOffClampFindings { get; } + + /// The plate's part spacing; zero (no spacing check) for a bare group of parts. + internal double PartSpacing { get; } } internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source, diff --git a/OpenNest.Core/Geometry/SpacingOutline.cs b/OpenNest.Core/Geometry/SpacingOutline.cs index a89e812d..34238da3 100644 --- a/OpenNest.Core/Geometry/SpacingOutline.cs +++ b/OpenNest.Core/Geometry/SpacingOutline.cs @@ -85,6 +85,24 @@ namespace OpenNest.Geometry return new SpacingOutline(perimeter, region.Holes); } + /// This outline, built at the origin, moved to : what + /// returns there, without repeating the offset for identical parts. + public SpacingOutline MovedTo(Vector location) + { + var perimeter = (Polygon)Perimeter.Clone(); + ToWorld(perimeter, location); + var holes = new List(Holes.Count); + + foreach (var hole in Holes) + { + var moved = (Polygon)hole.Clone(); + ToWorld(moved, location); + holes.Add(moved); + } + + return new SpacingOutline(perimeter, holes); + } + /// True when the two outlines share material (holes subtracted). public bool Overlaps(SpacingOutline other) { diff --git a/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs b/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs index 33ea5e4e..caf856e1 100644 --- a/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs +++ b/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs @@ -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); diff --git a/OpenNest.Tests/Diagnostics/OverlapSpacingLabelTests.cs b/OpenNest.Tests/Diagnostics/OverlapSpacingLabelTests.cs new file mode 100644 index 00000000..592241ca --- /dev/null +++ b/OpenNest.Tests/Diagnostics/OverlapSpacingLabelTests.cs @@ -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)); +} diff --git a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs index 0d1a26ae..15d5d813 100644 --- a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs +++ b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs @@ -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(() => { diff --git a/OpenNest/Controls/OverlapOverlayController.cs b/OpenNest/Controls/OverlapOverlayController.cs index 8caba0b8..c2fbaa52 100644 --- a/OpenNest/Controls/OverlapOverlayController.cs +++ b/OpenNest/Controls/OverlapOverlayController.cs @@ -38,6 +38,7 @@ internal sealed class OverlapOverlayController : IDisposable private Units capturedUnits; private Plate observedPlate; private IReadOnlyList hoveredPairs = Array.Empty(); + private IReadOnlyList hoveredSpacing = Array.Empty(); private string hoverText; private OverlapHoverPages hoverPages; private Point hoverPoint; @@ -79,6 +80,9 @@ internal sealed class OverlapOverlayController : IDisposable ? "Overlaps: check pending…" : state.Message; + /// The canvas shows a green check instead of the label: a pending recheck is not clear. + internal bool ShowsAllClear => state.IsAllClear && !autoCheck.IsWaiting; + /// /// 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(); + 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(); + hoveredSpacing = Array.Empty(); 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(); + 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)); diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md index 9d64f7b4..a3b9da7b 100644 --- a/docs/geometry/visual-overlap-check.md +++ b/docs/geometry/visual-overlap-check.md @@ -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