using System; using System.Collections.Generic; using System.Linq; using System.Threading; using Clipper2Lib; using OpenNest.CNC; using OpenNest.CNC.CuttingPlanning; using OpenNest.Geometry; using OpenNest.Math; namespace OpenNest.Diagnostics; /// /// Parts that leave the sheet or come closer to its edges than the plate's edge spacing. /// /// Off the plate: any cutting, lead or scribe motion of the placed program (its native /// curve bounds) past the sheet's bounding box, or, on an outlined sheet, clean material outside /// the outline or over one of its obstacles. A pierce or cut off the sheet cuts nothing, or the /// table. /// /// /// Outside the work area: clean nesting material (leads excluded, as the part-spacing check reads it) /// outside the plate's work area, or, on an outlined sheet, closer than the largest edge spacing to /// the outline or an obstacle: Auto Nest's layout check judges bounds exactly this way, so a layout /// it accepted stays clear. Shortfalls up to pass. Leads /// may run into the edge spacing; only off the sheet do they warn. /// /// Cut-off parts are exempt: they run to the sheet edge by design. /// internal static class PlateEdgeReview { private const double Slack = SpacingOutline.SpacingSlack; /// /// The sheet a plate's parts must stay on, owned and immutable, so the desktop label's worker /// never reads the live plate. and are in the parts' /// signed placement frame; is the outline's clearance, the largest edge spacing. /// internal sealed record SheetFrame(Box Sheet, Box Work, SheetOutline Outline, Vector Origin, double Inset) { /// The plate's frame; throws for an unusable size or spacing. internal static SheetFrame Of(Plate plate) { var size = plate.Size; if (!(size.Length > 0) || !(size.Width > 0) || !double.IsFinite(size.Length) || !double.IsFinite(size.Width)) throw new ArgumentException($"plate size {size} is not a positive, finite size."); var edges = plate.EdgeSpacing; if (!new[] { edges.Left, edges.Bottom, edges.Right, edges.Top }.All(e => double.IsFinite(e) && e >= 0)) throw new ArgumentException("plate edge spacing is not finite and non-negative on every side."); var sheet = plate.BoundingBox(includeParts: false); return new SheetFrame(sheet, plate.WorkArea(), plate.Outline, new Vector(sheet.X, sheet.Y), System.Math.Max(System.Math.Max(edges.Left, edges.Right), System.Math.Max(edges.Top, edges.Bottom))); } } /// What is wrong with one part; follows its quoted name. internal readonly record struct Verdict(bool OffPlate, string Detail, Vector Marker); internal static void Add(Plate plate, int plateNumber, List findings, CancellationToken cancellationToken) { if (plate.Parts.Count == 0) return; SheetFrame frame; try { frame = SheetFrame.Of(plate); } catch (ArgumentException exception) { findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null, "Plate edge check: " + exception.Message)); return; } var profiles = new Dictionary<(Program, long), ShapeProfile>(); for (var index = 0; index < plate.Parts.Count; index++) { cancellationToken.ThrowIfCancellationRequested(); var part = plate.Parts[index]; var partNumber = index + 1; if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff) continue; try { // The placed program first: its leads and scribes may leave a sheet its material stays on. var verdict = PastSheet(part, frame.Sheet, cancellationToken) ?? (PartSpacingReview.Profile(part, profiles) is { } profile ? Check(profile, part.Location, frame) : null); if (verdict is { } found) findings.Add(new(found.OffPlate ? PostVerificationKind.OffPlate : PostVerificationKind.EdgeSpacing, plateNumber, partNumber, null, $"'{part.BaseDrawing.Name}' {found.Detail}") { Location = found.Marker }); } catch (Exception exception) when (IsInvalid(exception)) { findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null, "Plate edge check: " + exception.Message)); } } } /// /// The desktop label's check: clean material only (snapshots hold no leads), run on the /// overlap worker from owned geometry. Findings and issues keep input ids. /// internal static void Add(PlateOverlapSnapshot snapshot, List found, List issues, CancellationToken cancellationToken) { var frame = snapshot.Frame; if (frame == null) return; var profiles = new Dictionary<(OverlapSource, long), ShapeProfile>(); var unreadable = snapshot.Issues.Where(issue => issue.PartBId == null) .Select(issue => issue.PartAId).ToHashSet(); foreach (var part in snapshot.Parts) { cancellationToken.ThrowIfCancellationRequested(); if (unreadable.Contains(part.Id)) continue; try { if (PartSpacingReview.Profile(part, profiles) is { } profile && Check(profile, part.Location, frame) is { } verdict) found.Add(new PlateEdgeFinding(part.Id, part.Name, verdict.OffPlate, $"'{part.Name}' {verdict.Detail}", verdict.Marker)); } catch (Exception exception) when (IsInvalid(exception)) { issues.Add(new PlateOverlapIssue(part.Id, null, exception.Message)); } } } /// Clean material at against the sheet; null when it keeps the edge spacing. internal static Verdict? Check(ShapeProfile profile, Vector location, SheetFrame frame) { var bounds = profile.Perimeter.BoundingBox.Translate(location); if (Describe(Furthest(null, bounds, bounds.Center, frame.Sheet)) is { } past) return past; PathsD region = null; if (frame.Outline != null) { var material = SpacingOutline.Build(profile, location, 0) ?? throw new ArgumentException("Part material has no closed perimeter."); region = new PathsD { ClipperBridge.ToPath(material.Perimeter, positive: true) }; if (!frame.Outline.Holds(region, 0, frame.Origin, Slack)) return new Verdict(true, "lies outside the plate's outline or over one of its obstacles.", bounds.Center); } if (Shortest(bounds, frame.Sheet, frame.Work) is { } close) return new Verdict(false, $"is outside the work area: {close.Gap:F3} from the plate's {close.Edge} " + $"edge, inside its {close.Spacing:F3} edge spacing.", close.Marker); if (region != null && frame.Inset > 0 && !frame.Outline.Holds(region, frame.Inset, frame.Origin, Slack)) return new Verdict(false, $"is outside the work area: inside the {frame.Inset:F3} edge spacing of the " + "plate's outline or one of its obstacles.", bounds.Center); return null; } private readonly record struct Past(string Edge, double Distance, Vector Marker); private readonly record struct Close(string Edge, double Gap, double Spacing, Vector Marker); /// The furthest any non-rapid motion of the placed program runs past the sheet, or null. private static Verdict? PastSheet(Part part, Box sheet, CancellationToken cancellationToken) { Past? worst = null; foreach (var move in ExecutionMotionReader.Read(part.Program, part.Location, null, cancellationToken).Motions) { if (move.Rapid || move.Curve == null) continue; worst = Furthest(worst, move.Curve.ToEntity().BoundingBox, move.Curve.Midpoint, sheet); } return Describe(worst); } private static Verdict? Describe(Past? past) => past is { } p ? new Verdict(true, $"runs {p.Distance:F3} past the plate's {p.Edge} edge.", p.Marker) : null; private static Past? Furthest(Past? worst, Box box, Vector middle, Box sheet) { Consider("left", sheet.Left - box.Left, new Vector(sheet.Left, Clamp(middle.Y, sheet.Bottom, sheet.Top))); Consider("bottom", sheet.Bottom - box.Bottom, new Vector(Clamp(middle.X, sheet.Left, sheet.Right), sheet.Bottom)); Consider("right", box.Right - sheet.Right, new Vector(sheet.Right, Clamp(middle.Y, sheet.Bottom, sheet.Top))); Consider("top", box.Top - sheet.Top, new Vector(Clamp(middle.X, sheet.Left, sheet.Right), sheet.Top)); return worst; void Consider(string edge, double distance, Vector marker) { if (!double.IsFinite(distance)) throw new ArgumentException("Part geometry must be finite."); if (distance > Slack && (worst == null || distance > worst.Value.Distance)) worst = new Past(edge, distance, marker); } } /// The side whose edge spacing the material falls furthest short of, or null. private static Close? Shortest(Box bounds, Box sheet, Box work) { Close? worst = null; var shortfall = 0.0; var center = bounds.Center; Consider("left", work.Left - bounds.Left, bounds.Left - sheet.Left, work.Left - sheet.Left, new Vector((sheet.Left + bounds.Left) / 2, center.Y)); Consider("bottom", work.Bottom - bounds.Bottom, bounds.Bottom - sheet.Bottom, work.Bottom - sheet.Bottom, new Vector(center.X, (sheet.Bottom + bounds.Bottom) / 2)); Consider("right", bounds.Right - work.Right, sheet.Right - bounds.Right, sheet.Right - work.Right, new Vector((sheet.Right + bounds.Right) / 2, center.Y)); Consider("top", bounds.Top - work.Top, sheet.Top - bounds.Top, sheet.Top - work.Top, new Vector(center.X, (sheet.Top + bounds.Top) / 2)); return worst; void Consider(string edge, double by, double gap, double spacing, Vector marker) { if (by > Slack && by > shortfall) { shortfall = by; worst = new Close(edge, System.Math.Max(0, gap), spacing, marker); } } } private static double Clamp(double value, double low, double high) => double.IsFinite(value) ? System.Math.Clamp(value, low, high) : low; private static bool IsInvalid(Exception exception) => exception is ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException; }