using System; using System.Collections.Generic; using System.Linq; using OpenNest.CNC; using OpenNest.Diagnostics; using OpenNest.Geometry; namespace OpenNest { /// One shared cut: the centerline between two parts' facing edges, one kerf apart. public readonly record struct CommonLineSpan(Part First, Part Second, Vector Start, Vector End) { public double Length => Start.DistanceTo(End); } /// A member of a group that no longer shares a span, or could not be read. public readonly record struct CommonLineProblem(Part Part, string Message); /// Spans a group's members share now, and what breaks the group. public sealed record CommonLineCheck(IReadOnlyList Spans, IReadOnlyList Problems) { public bool IsIntact => Problems.Count == 0; } /// /// Derives common-line spans from placed parts' clean perimeters (leads excluded, holes never /// share). Two straight perimeter edges share a span when they face each other (antiparallel, /// outward normals opposed), lie one kerf apart within and overlap /// along the line by at least . Arcs never share. /// public static class CommonLineGeometry { /// How far a shared edge pair's gap may differ from the group's kerf (inches). public const double GapTolerance = 0.0005; /// The shortest overlap that counts as a shared span (inches). public const double MinSharedLength = 0.25; private const double ParallelTolerance = 1e-6; /// A placed perimeter edge with its outward unit normal. internal readonly record struct Edge(Vector Start, Vector End, Vector Direction, Vector Outward, double Length); /// A facing edge pair: along the first edge's outward normal, /// the overlap as distances along the first edge from its start. internal readonly record struct Facing(Edge First, Edge Second, double Gap, double From, double To) { public double Overlap => To - From; } /// The spans and share at . public static IReadOnlyList Spans(Part first, Part second, double kerf) { ArgumentNullException.ThrowIfNull(first); ArgumentNullException.ThrowIfNull(second); var cache = new ProfileCache(); return Spans(first, Edges(first, cache), second, Edges(second, cache), kerf); } /// /// Checks that every member of shares at least one span with another /// member at the group's kerf, and that the members form one connected group. /// public static CommonLineCheck Check(CommonLineGroup group) { ArgumentNullException.ThrowIfNull(group); var members = group.Members; var cache = new ProfileCache(); var edges = new IReadOnlyList[members.Count]; var problems = new List(); for (var i = 0; i < members.Count; i++) { try { edges[i] = Edges(members[i], cache); } catch (Exception exception) when (IsInvalid(exception)) { problems.Add(new(members[i], $"could not be read ({exception.Message}).")); } } var spans = new List(); var links = members.Select(_ => new List()).ToArray(); for (var i = 0; i < members.Count; i++) for (var j = i + 1; j < members.Count; j++) { if (edges[i] == null || edges[j] == null) continue; var shared = Spans(members[i], edges[i], members[j], edges[j], group.Kerf); if (shared.Count == 0) continue; spans.AddRange(shared); links[i].Add(j); links[j].Add(i); } for (var i = 0; i < members.Count; i++) if (edges[i] != null && links[i].Count == 0) problems.Add(new(members[i], $"no longer shares a straight edge one kerf ({group.Kerf:0.####}) apart with another part in its common-line group.")); if (problems.Count == 0 && !Connected(links)) problems.Add(new(members[0], "its common-line group has split into separate pieces.")); return new CommonLineCheck(spans, problems); } /// /// The move that puts one kerf from along /// their best facing edge pair: the longest overlap, then the smallest gap. Only pairs closer /// than (or already overlapping by up to that much) are considered; /// null when there are none. /// public static Vector? JoinOffset(Part anchor, Part moving, double kerf, double maxGap) { ArgumentNullException.ThrowIfNull(anchor); ArgumentNullException.ThrowIfNull(moving); if (!double.IsFinite(kerf) || kerf <= 0) throw new ArgumentOutOfRangeException(nameof(kerf)); var cache = new ProfileCache(); var best = FacingEdges(Edges(anchor, cache), Edges(moving, cache), -maxGap, maxGap) .OrderByDescending(facing => System.Math.Round(facing.Overlap, 6)) .ThenBy(facing => System.Math.Abs(facing.Gap - kerf)) .Cast() .FirstOrDefault(); if (best is not { } chosen) return null; var shift = kerf - chosen.Gap; return new Vector(chosen.First.Outward.X * shift, chosen.First.Outward.Y * shift); } private static IReadOnlyList Spans(Part first, IReadOnlyList a, Part second, IReadOnlyList b, double kerf) { if (!double.IsFinite(kerf) || kerf <= 0) throw new ArgumentOutOfRangeException(nameof(kerf)); var spans = new List(); foreach (var facing in FacingEdges(a, b, kerf - GapTolerance, kerf + GapTolerance)) { var edge = facing.First; var half = facing.Gap / 2; Vector At(double t) => new(edge.Start.X + edge.Direction.X * t + edge.Outward.X * half, edge.Start.Y + edge.Direction.Y * t + edge.Outward.Y * half); spans.Add(new CommonLineSpan(first, second, At(facing.From), At(facing.To))); } return spans; } internal static IEnumerable FacingEdges(IReadOnlyList a, IReadOnlyList b, double minGap, double maxGap) { foreach (var first in a) foreach (var second in b) { // Antiparallel edges with opposed outward normals face each other. if (System.Math.Abs(Cross(first.Direction, second.Direction)) > ParallelTolerance || Dot(first.Outward, second.Outward) > -1 + ParallelTolerance) continue; var gap = Dot(second.Start - first.Start, first.Outward); if (gap < minGap || gap > maxGap) continue; var t0 = Dot(second.Start - first.Start, first.Direction); var t1 = Dot(second.End - first.Start, first.Direction); var from = System.Math.Max(0, System.Math.Min(t0, t1)); var to = System.Math.Min(first.Length, System.Math.Max(t0, t1)); if (to - from >= MinSharedLength) yield return new Facing(first, second, gap, from, to); } } /// The straight edges of a part's placed clean perimeter, with outward normals. internal static IReadOnlyList Edges(Part part, ProfileCache cache) { var profile = PartSpacingReview.Profile(part, cache.Profiles) ?? throw new ArgumentException("Part has no material."); var location = part.Location; var ring = profile.Perimeter.ToPolygon(); var edges = new List(); foreach (var line in profile.Perimeter.Entities.OfType()) { var start = line.StartPoint + location; var end = line.EndPoint + location; var length = start.DistanceTo(end); if (length < MinSharedLength) continue; var direction = new Vector((end.X - start.X) / length, (end.Y - start.Y) / length); var outward = new Vector(direction.Y, -direction.X); // The probe is in part-local coordinates: the ring is not moved to the location. var mid = new Vector((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2); var step = System.Math.Min(0.001, length / 100); if (Inside(ring.Vertices, new Vector(mid.X + outward.X * step, mid.Y + outward.Y * step))) outward = new Vector(-outward.X, -outward.Y); edges.Add(new Edge(start, end, direction, outward, length)); } return edges; } internal sealed class ProfileCache { public readonly Dictionary<(Program, long), ShapeProfile> Profiles = new(); } private static bool Connected(IReadOnlyList> links) { var seen = new bool[links.Count]; var stack = new Stack(); stack.Push(0); seen[0] = true; while (stack.Count > 0) foreach (var next in links[stack.Pop()]) if (!seen[next]) { seen[next] = true; stack.Push(next); } return seen.All(s => s); } private static bool Inside(IReadOnlyList ring, Vector point) { var inside = false; for (int i = 0, j = ring.Count - 1; i < ring.Count; j = i++) { var a = ring[i]; var b = ring[j]; if ((a.Y > point.Y) != (b.Y > point.Y) && point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X) inside = !inside; } return inside; } private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y; private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X; private static bool IsInvalid(Exception exception) => exception is ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException; } }