diff --git a/OpenNest.Core/Geometry/AlignmentResult.cs b/OpenNest.Core/Geometry/AlignmentResult.cs new file mode 100644 index 0000000..bd82270 --- /dev/null +++ b/OpenNest.Core/Geometry/AlignmentResult.cs @@ -0,0 +1,125 @@ +using System; + +namespace OpenNest.Geometry +{ + /// + /// Tuning for . Every default is a design choice + /// under measurement, not a calibrated safe envelope: until calibration + /// establishes one, every alignment pair still requires operator confirmation. + /// Values are expressed in the declared units of the drawings being aligned. + /// + public sealed class AlignmentOptions + { + /// Chord-error tolerance used when flattening arcs for measurement. + public double FlattenTolerance { get; set; } = 0.01; + + /// + /// Target arclength between resampled contour samples. Must resolve the + /// intended residual gate: a residual below roughly half this spacing is + /// not meaningful. + /// + public double SamplingSpacing { get; set; } = 0.5; + + /// Upper bound on samples per contour ring. + public int MaxSamplesPerRing { get; set; } = 4000; + + /// Upper bound on total samples across all rings of one drawing. + public int MaxTotalSamples { get; set; } = 20000; + + /// Trim fraction: worst-distance correspondences excluded from each ICP fit. + public double TrimFraction { get; set; } = 0.10; + + /// Fit iterations per candidate start. + public int MaxIterations { get; set; } = 40; + + /// Pose change (radians) treated as converged. + public double RotationEpsilon { get; set; } = 1e-7; + + /// Translation change in units treated as converged. + public double TranslationEpsilon { get; set; } = 1e-7; + + /// + /// Correspondences farther than this count as unmatched (changed geometry), + /// not as fit error. Defaults to a generous envelope; the review gate + /// reports coverage instead of silently clamping it. + /// + public double OutlierDistance { get; set; } = 10.0; + + /// Minimum distinct samples required to attempt a fit at all. + public int MinSamples { get; set; } = 8; + } + + /// Why an alignment is not trustworthy. Absence of all flags is not a + /// calibrated safe envelope; it only means no review reason fired. + [Flags] + public enum AlignmentReasons + { + None = 0, + + /// The fit stopped on an iteration/work limit or stagnated without converging. + FailedConvergence = 1, + + /// The revised geometry has too few usable samples, too little outer + /// support, or too little of the target matched to it. + InsufficientSupport = 2, + + /// Significant changed/unmatched boundary spans in either direction. + SignificantBoundaryChange = 4, + + /// Two or more genuinely distinct poses fit near-equally (symmetry, + /// repeated features). The reported transform is still valid geometry. + UnresolvedAlternatives = 8, + + /// Input geometry is invalid: nonfinite coordinates, degenerate or + /// unclosed rings, unsupported topology, zero usable perimeter. + InvalidGeometry = 16, + + /// A reflected candidate fits as well as the best rigid one, or the + /// input cannot exclude reflection. Reflection is never applied silently. + ReflectionUncertain = 32, + } + + /// + /// Structured outcome of aligning one revised drawing to one old drawing. + /// The transform maps NEW points into the OLD drawing-local frame: + /// reflect about the declared local axis (only when is + /// true, which the automatic aligner never sets), then rotate by + /// (radians), then translate by . + /// Diagnostics are in the drawings' declared units. A diagnostic IoU is + /// bounded to [0, 1] and is not a calibrated probability. + /// + public sealed class AlignmentResult + { + public bool Converged { get; init; } + public double Rotation { get; init; } + public Vector Translation { get; init; } + public bool Reflection { get; init; } + + /// Review reasons; combined across candidate selection. Empty does + /// not license skipping the operator overlay — no calibrated envelope + /// exists yet. + public AlignmentReasons Reasons { get; init; } + + /// Trimmed RMS of matched sample-to-segment residuals. + public double ResidualRms { get; init; } + public double ResidualP50 { get; init; } + public double ResidualP90 { get; init; } + + /// Fraction of NEW samples matching OLD within the outlier bound, and vice versa. + public double NewToOldCoverage { get; init; } + public double OldToNewCoverage { get; init; } + + /// Number of distinct converged candidate poses clustered as equivalent. + public int EquivalentCandidateCount { get; init; } + + public int Iterations { get; init; } + public int NewSampleCount { get; init; } + public int OldSampleCount { get; init; } + + /// Bounded diagnostic intersection-over-union of the material regions, + /// or null when regions are unavailable or degenerate. Never a gate input. + public double? DiagnosticIoU { get; init; } + + public string FailureMessage { get; init; } + } +} diff --git a/OpenNest.Core/Geometry/DrawingAligner.cs b/OpenNest.Core/Geometry/DrawingAligner.cs new file mode 100644 index 0000000..e7023bf --- /dev/null +++ b/OpenNest.Core/Geometry/DrawingAligner.cs @@ -0,0 +1,1166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Clipper2Lib; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Math; + + +namespace OpenNest.Geometry +{ + /// + /// Seed-aligns a revised drawing's geometry to an old drawing's drawing-local + /// frame with a bounded, robust, multi-start rigid ICP in 2D. Intended as an + /// initial alignment for operator review, never as a proven-unique registration: + /// a small perimeter edit, a symmetric outline, or a dominant repeated feature + /// can defeat any local optimizer, and every reason this class reports routes + /// the pair to the manual overlay. The result never applies reflection. + /// + /// Convention: the returned transform maps NEW points into the OLD local frame + /// — reflect about the X axis (only if the caller explicitly selects a mirror; + /// this aligner never does), then rotate by + /// radians, then translate. No scale, no shear, ever. + /// + /// + /// Pipeline: material-filter the CNC programs through + /// (rapids and scribe moves are + /// dropped by layer; nominal cut contours are kept), chain them into contours, + /// classify outer versus hole roles, flatten with the shared chord-error + /// machinery, resample each ring at near-uniform arclength with + /// , seed translation from outer geometry + /// and rotation from minimum-bounding-rectangle angle deltas plus the + /// 90-degree family and identity, then run a trimmed robust ICP per start that + /// matches samples to closest bounded target SEGMENTS and solves a proper + /// 2D rotation+translation least-squares fit (reflection excluded inside the + /// solve). The outer boundary is fitted first; hole evidence is folded in with + /// reduced weight so many small holes cannot dominate and a moved hole is + /// trimmed instead of dragging an unchanged perimeter. + /// + /// + public static class DrawingAligner + { + /// + /// Aligns onto . + /// Both programs are treated as read-only. Throws + /// only when cancelled before any + /// measurable work; work limits reached mid-run are reported as reasons on + /// the result instead. + /// + public static AlignmentResult Align( + Program oldProgram, + Program newProgram, + AlignmentOptions options = null, + CancellationToken cancellationToken = default + ) + { + options ??= new AlignmentOptions(); + + var prepared = Prepare(oldProgram, "old", options, out var oldReason, out var oldError); + if (prepared == null) + return Failure(oldReason, oldError); + + var newSide = Prepare(newProgram, "new", options, out var newReason, out var newError); + if (newSide == null) + return Failure(newReason, newError); + + var minSamples = System.Math.Max(3, options.MinSamples); + if ( + newSide.TotalSamples < minSamples + || prepared.TotalSamples < minSamples + ) + return Failure( + AlignmentReasons.InsufficientSupport, + "Too few contour samples to align; increase sampling budget or align manually." + ); + + // Seeds: translation from outer centroid, rotation from MBR angle deltas + // with the 90-degree family, plus the identity/original-frame candidate. + // These are local-search starts, not an exhaustive proof of the best pose. + var seeds = BuildSeeds(prepared, newSide); + + // Stage 1: fit the stable outer boundary only, so many small holes or a + // moved hole cannot dominate or drag the perimeter. + var candidates = SearchStarts(prepared, newSide, seeds, options, false, cancellationToken); + var reflectedCandidates = SearchStarts(prepared, newSide, seeds, options, true, cancellationToken); + + if (candidates.Count == 0) + return Failure( + AlignmentReasons.FailedConvergence, + "No candidate pose converged; align manually." + ); + + var result = BuildResult(prepared, newSide, candidates, reflectedCandidates, options); + return result; + } + + /// + /// Bounded diagnostic intersection-over-union of two drawings' material + /// regions under the pose (NEW -> OLD convention), for review display only. + /// Null when regions are unavailable or degenerate. Not a calibrated + /// probability and never a gate input. + /// + public static double? DiagnosticIoU( + Program oldProgram, + Program newProgram, + double rotation, + Vector translation, + bool reflect = false, + AlignmentOptions options = null + ) + { + options ??= new AlignmentOptions(); + var oldSide = Prepare(oldProgram, "old", options, out _, out _); + var newSide = Prepare(newProgram, "new", options, out _, out _); + if (oldSide == null || newSide == null) + return null; + + var candidate = new Candidate + { + Rotation = rotation, + Translation = translation, + }; + + if (!reflect) + return RegionIoU(oldSide, newSide, candidate, options); + + // Reflect the NEW side about its X axis once for the diagnostic. + var reflected = new Side + { + Outer = Mirror(newSide.Outer), + Holes = newSide.Holes.Select(Mirror).ToList(), + TotalSamples = newSide.TotalSamples, + }; + return RegionIoU(oldSide, reflected, candidate, options); + + static Ring Mirror(Ring ring) + { + var pts = ring.Points + .Select(p => new Vector(p.X, -p.Y)) + .ToArray(); + var segments = new Line[pts.Length]; + var kept = 0; + for (var i = 0; i < pts.Length; i++) + { + var a = pts[i]; + var b = pts[(i + 1) % pts.Length]; + if (a.DistanceTo(b) <= Tolerance.Epsilon) + continue; + segments[kept++] = new Line(a, b); + } + return new Ring + { + Points = pts, + Samples = ring.Samples + .Select(s => new ContourSample( + new Vector(s.Position.X, -s.Position.Y), + new Vector(s.Direction.X, -s.Direction.Y), + -s.Tangent, + s.At + )) + .ToArray(), + Segments = segments.Take(kept).ToArray(), + Perimeter = ring.Perimeter, + }; + } + } + + // ---------- contour preparation ---------- + + private sealed class Ring + { + public Vector[] Points; // closed implicitly; no duplicate closing vertex + public ContourSample[] Samples; + public Line[] Segments; // target segments, same length as Points (closes implicitly) + public double Perimeter; + } + + private sealed class Side + { + public Ring Outer; + public List Holes = new(); + public int TotalSamples; + + /// + /// Distance at which a correspondence counts as an unmatched (changed) + /// boundary span instead of fit error, for COVERAGE reporting only: + /// relative to the part size, so a moved hole on a small part cannot + /// hide inside a generous absolute fit-trim envelope. + /// + public double SpanDistance = double.PositiveInfinity; + } + + private static Side Prepare( + Program pgm, + string which, + AlignmentOptions options, + out AlignmentReasons reason, + out string error + ) + { + reason = AlignmentReasons.InvalidGeometry; + error = null; + var side = new Side(); + + if (pgm == null) + { + error = $"The {which} program is missing."; + return null; + } + + var entities = ConvertProgram + .ToGeometry(pgm) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ToList(); + + if (entities.Count == 0) + { + error = $"The {which} drawing has no material geometry."; + return null; + } + + List shapes; + try + { + shapes = ShapeBuilder.GetShapes(entities, Tolerance.ChainTolerance); + } + catch (Exception ex) + { + error = $"The {which} drawing geometry cannot be chained into contours: {ex.Message}"; + return null; + } + + if (shapes.Count == 0) + { + error = $"The {which} drawing has no contours."; + return null; + } + + var contours = ContourInfo.Classify(shapes); + + var outerInfo = contours.FirstOrDefault(c => c.Type == ContourClassification.Perimeter); + if (outerInfo == null) + { + error = $"The {which} drawing has no closed outer contour."; + return null; + } + // Flatten closes its output; an open outer contour must not be silently + // closed into a measurable ring. + if (!outerInfo.Shape.IsClosed()) + { + error = $"The outer contour of the {which} drawing is not closed."; + return null; + } + + // Validate closure and finiteness before flattening; Flatten closes its + // output and is not an input validator. + foreach (var info in contours.Where(c => c.Type != ContourClassification.Open)) + { + if (!info.Shape.IsClosed()) + { + error = $"A closed contour in the {which} drawing is not closed."; + return null; + } + foreach (var entity in info.Shape.Entities) + { + foreach (var pt in EntityPoints(entity)) + { + if (double.IsNaN(pt.X) || double.IsNaN(pt.Y) || double.IsInfinity(pt.X) || double.IsInfinity(pt.Y)) + { + error = $"The {which} drawing contains nonfinite coordinates."; + return null; + } + } + } + } + + // Reject unsupported topology: every non-outer closed contour must lie + // inside the outer contour, otherwise this is disconnected material, not + // a part with holes. + var outerFlat = ClipperBridge.Flatten(outerInfo.Shape, options.FlattenTolerance, false); + var outerRing = ToRing(outerFlat.Vertices, options, out var outerSpacingError); + if (outerRing == null) + { + error = outerSpacingError != null + ? outerSpacingError + : $"The outer contour of the {which} drawing has no usable perimeter."; + return null; + } + side.Outer = outerRing; + side.TotalSamples += outerRing.Samples.Length; + + foreach (var info in contours) + { + if (info == outerInfo || info.Type != ContourClassification.Hole) + continue; + + var flat = ClipperBridge.Flatten(info.Shape, options.FlattenTolerance, false); + var ring = ToRing(flat.Vertices, options, out var holeError); + if (ring == null) + { + error = holeError ?? $"A hole in the {which} drawing has no usable perimeter."; + return null; + } + + var probe = ring.Points[0]; + if (!outerFlat.ContainsPoint(probe) && !OuterContainsVertex(outerFlat, probe, options)) + { + error = $"A contour of the {which} drawing lies outside the outer contour; disconnected material is not supported."; + return null; + } + + side.Holes.Add(ring); + side.TotalSamples += ring.Samples.Length; + } + + if (side.TotalSamples > options.MaxTotalSamples) + { + error = $"The {which} drawing needs more samples ({side.TotalSamples}) than the configured total ({options.MaxTotalSamples}); raise the sampling budget or align manually."; + reason = AlignmentReasons.InsufficientSupport; + return null; + } + + if (side.TotalSamples < System.Math.Max(3, options.MinSamples)) + { + error = $"The {which} drawing has too few samples to align."; + reason = AlignmentReasons.InsufficientSupport; + return null; + } + + var obb = outerFlat.BoundingBox; + var diagonal = System.Math.Sqrt( + (obb.Right - obb.Left) * (obb.Right - obb.Left) + + (obb.Top - obb.Bottom) * (obb.Top - obb.Bottom) + ); + side.SpanDistance = System.Math.Max(8 * options.FlattenTolerance, 0.10 * diagonal); + + return side; + } + + /// + /// Fallback containment when a hole touches the outer contour within + /// rounding: accept a vertex inside the bounding box expanded by the + /// flatten tolerance plus one part in a thousand of the part size. + /// + private static bool OuterContainsVertex(Polygon outer, Vector probe, AlignmentOptions options) + { + var bb = outer.BoundingBox; + var margin = options.FlattenTolerance + 0.001 * System.Math.Max(bb.Right - bb.Left, bb.Top - bb.Bottom); + return probe.X >= bb.Left - margin && probe.X <= bb.Right + margin && probe.Y >= bb.Bottom - margin && probe.Y <= bb.Top + margin; + } + + private static Ring ToRing(List flattened, AlignmentOptions options, out string error) + { + error = null; + var pts = DistinctClosed(flattened); + if (pts.Count < 3) + return null; + + double perimeter = 0; + for (var i = 0; i < pts.Count; i++) + perimeter += pts[i].DistanceTo(pts[(i + 1) % pts.Count]); + if (!(perimeter > Tolerance.Epsilon)) + return null; + + // Bound the work: raise the spacing until the ring fits the sample cap. + var spacing = System.Math.Max( + options.SamplingSpacing, + perimeter / System.Math.Max(8, options.MaxSamplesPerRing) + ); + + var samples = new List(); + try + { + ContourSampler.RingMoves(pts, spacing, samples); + } + catch (ArgumentException) + { + error = "A contour ring is invalid."; + return null; + } + if (samples.Count < 3) + return null; + + var segments = new Line[pts.Count]; + var kept = 0; + for (var i = 0; i < pts.Count; i++) + { + var a = pts[i]; + var b = pts[(i + 1) % pts.Count]; + if (a.DistanceTo(b) <= Tolerance.Epsilon) + continue; // zero-length segments are not constraints + segments[kept++] = new Line(a, b); + } + + return new Ring + { + Points = pts.ToArray(), + Samples = samples.ToArray(), + Segments = segments.Take(kept).ToArray(), + Perimeter = perimeter, + }; + } + + private static List DistinctClosed(List vertices) + { + var result = new List(vertices.Count); + foreach (var v in vertices) + { + if (result.Count > 0) + { + var last = result[^1]; + if (System.Math.Abs(last.X - v.X) <= Tolerance.Epsilon && System.Math.Abs(last.Y - v.Y) <= Tolerance.Epsilon) + continue; + } + result.Add(v); + } + if (result.Count > 1) + { + var first = result[0]; + var last = result[^1]; + if (System.Math.Abs(first.X - last.X) <= Tolerance.Epsilon && System.Math.Abs(first.Y - last.Y) <= Tolerance.Epsilon) + result.RemoveAt(result.Count - 1); + } + return result; + } + + private static IEnumerable EntityPoints(Entity entity) + { + switch (entity) + { + case Line line: + yield return line.StartPoint; + yield return line.EndPoint; + break; + case Arc arc: + yield return arc.Center; + yield return arc.StartPoint(); + yield return arc.EndPoint(); + break; + case Circle circle: + yield return circle.Center; + break; + case Shape shape: + foreach (var e in shape.Entities) + foreach (var p in EntityPoints(e)) + yield return p; + break; + } + } + + // ---------- multi-start search ---------- + + private sealed class Candidate + { + public double Rotation; + public Vector Translation; + + /// Trimmed RMS of the outer-boundary fit (stage 1). + public double OuterRms; + + /// Trimmed RMS including hole evidence; NaN in stage 1. + public double CombinedRms = double.NaN; + public int Iterations; + public bool Converged; + } + + private static List<(double Rotation, Vector Translation)> BuildSeeds(Side old, Side @new) + { + var seeds = new List<(double, Vector)>(); + + void Add(double rotation) + { + foreach (var s in seeds) + if (System.Math.Abs(NormalizeAngle(s.Item1 - rotation)) < 1e-9) + return; + seeds.Add((rotation, new Vector())); + } + + Add(0.0); // identity / original frame + + try + { + var oldMbr = RotatingCalipers.MinimumBoundingRectangle(old.Outer.Points); + var newMbr = RotatingCalipers.MinimumBoundingRectangle(@new.Outer.Points); + + // A near-square MBR has unstable angle selection; the 90-degree + // family plus identity covers the practical seeds either way. + var delta = oldMbr.Angle - newMbr.Angle; + for (var k = 0; k < 4; k++) + Add(delta + k * System.Math.PI / 2); + + // Swap W/H orientation: MBR angle may report the long edge either way. + for (var k = 0; k < 4; k++) + Add(delta + System.Math.PI / 2 + k * System.Math.PI / 2); + } + catch (Exception) + { + // MBR needs a real polygon; fall back to identity-only seeds. + } + + // Translation seed: align outer-sample centroids for every rotation. + var oldC = Centroid(old.Outer.Samples); + var newC = Centroid(@new.Outer.Samples); + for (var i = 0; i < seeds.Count; i++) + { + var rotated = Rotate(newC, seeds[i].Item1); + seeds[i] = (seeds[i].Item1, oldC - rotated); + } + return seeds; + } + + private static List SearchStarts( + Side old, + Side @new, + List<(double Rotation, Vector Translation)> seeds, + AlignmentOptions options, + bool reflectNew, + CancellationToken cancellationToken + ) + { + var candidates = new List(); + Func mirror = reflectNew + ? static (double x, double y) => (x, -y) + : static (double x, double y) => (x, y); + + foreach (var seed in seeds) + { + // work-limit guard doubles as the cancellation checkpoint + if (cancellationToken.IsCancellationRequested) + break; + + var candidate = FitIcp( + old, + @new, + seed.Rotation, + seed.Translation, + options, + mirror, + false, + cancellationToken + ); + if (candidate != null) + candidates.Add(candidate); + } + candidates.Sort((a, b) => a.OuterRms.CompareTo(b.OuterRms)); + return candidates; + } + + private static Candidate FitIcp( + Side old, + Side @new, + double rotation, + Vector translation, + AlignmentOptions options, + Func preMirror, + bool includeHoles, + CancellationToken cancellationToken + ) + { + var n = @new.Outer.Samples.Length; + var totalNew = includeHoles ? @new.TotalSamples : n; + var positions = new Vector[totalNew]; + + void LoadPositions() + { + var idx = 0; + for (var i = 0; i < @new.Outer.Samples.Length; i++) + { + var p = @new.Outer.Samples[i].Position; + var m = preMirror(p.X, p.Y); + positions[idx++] = Rotate(new Vector(m.X, m.Y), rotation) + translation; + } + if (!includeHoles) + return; + for (var h = 0; h < @new.Holes.Count; h++) + for (var i = 0; i < @new.Holes[h].Samples.Length; i++) + { + var p = @new.Holes[h].Samples[i].Position; + var m = preMirror(p.X, p.Y); + positions[idx++] = Rotate(new Vector(m.X, m.Y), rotation) + translation; + } + } + + var holeWeight = 0.25; + var oldOuterSegments = old.Outer.Segments; + var oldHoleSegments = AllHoleSegments(old); + var iterations = 0; + var converged = false; + var previousRms = double.PositiveInfinity; + + var residuals = new double[totalNew]; + var targets = new Vector[totalNew]; + var weights = new double[totalNew]; + + for (var iter = 0; iter < options.MaxIterations; iter++) + { + iterations = iter + 1; + if (cancellationToken.IsCancellationRequested) + break; + + LoadPositions(); + + // Correspondences: closest bounded target segment per sample. + // Outer-to-outer and hole-to-hole candidates stay distinct: a hole + // sample never projects onto the outer perimeter when hole targets + // exist, so a moved hole is trimmed instead of dragging the ring. + var matched = 0; + var outerMatched = 0; + var outerCount = n; + for (var i = 0; i < totalNew; i++) + { + var isOuter = i < n; + var segments = isOuter + ? oldOuterSegments + : oldHoleSegments.Length > 0 + ? oldHoleSegments + : oldOuterSegments; + var pt = positions[i]; + if (!TryClosestSegment(pt, segments, out var closest)) + { + residuals[i] = double.PositiveInfinity; + continue; + } + + targets[i] = closest; + weights[i] = isOuter ? 1.0 : holeWeight; + residuals[i] = pt.DistanceTo(closest); + matched++; + if (isOuter && residuals[i] <= options.OutlierDistance) + outerMatched++; + } + + if (matched < System.Math.Max(4, options.MinSamples) || outerMatched < System.Math.Max(3, outerCount / 4)) + return null; // insufficient support for this basin + + // Trim: drop the worst correspondences, never below half the data. + var keep = System.Math.Max(matched / 2, (int)System.Math.Ceiling(matched * (1.0 - options.TrimFraction))); + var order = Enumerable + .Range(0, totalNew) + .Where(i => !double.IsInfinity(residuals[i])) + .OrderBy(i => residuals[i]) + .Take(keep) + .ToArray(); + if (order.Length < 4) + return null; + + // Weighted rigid least squares to the projected target points. + if (!SolveRotationTranslation(order, positions, targets, weights, out var dTheta, out var dT)) + return null; // nonfinite update + + rotation += dTheta; + translation = Rotate(translation, dTheta) + dT; + + var rms = System.Math.Sqrt(order.Average(i => residuals[i] * residuals[i])); + + var dAngle = System.Math.Abs(dTheta); + var dTrans = dT.DistanceTo(new Vector()); + if ( + (dAngle < options.RotationEpsilon && dTrans < options.TranslationEpsilon) + || System.Math.Abs(previousRms - rms) < 1e-12 + ) + { + converged = true; + previousRms = rms; + break; + } + previousRms = rms; + } + + // full-data diagnostics at the converged pose: the ranking metric is + // the trimmed outer-boundary residual; hole evidence is reported + // separately and only used to break near-ties. + LoadPositions(); + var outerTrimSum = 0.0; + var outerTrimCount = 0; + var holeTrimSum = 0.0; + var holeTrimCount = 0; + for (var i = 0; i < totalNew; i++) + { + var isOuter = i < n; + var pt = positions[i]; + var segs = isOuter ? oldOuterSegments : oldHoleSegments.Length > 0 ? oldHoleSegments : oldOuterSegments; + if (!TryClosestSegment(pt, segs, out var closest)) + continue; + var dist = pt.DistanceTo(closest); + if (dist > options.OutlierDistance) + continue; // changed/unmatched span, not fit error + if (isOuter) + { + outerTrimSum += dist * dist; + outerTrimCount++; + } + else + { + holeTrimSum += dist * dist; + holeTrimCount++; + } + } + if (outerTrimCount < 3) + return null; + + var combinedCount = outerTrimCount + holeTrimCount; + var combinedSum = outerTrimSum + holeTrimSum; + return new Candidate + { + Rotation = NormalizeAngle(rotation), + Translation = translation, + OuterRms = System.Math.Sqrt(outerTrimSum / outerTrimCount), + CombinedRms = combinedCount > 0 ? System.Math.Sqrt(combinedSum / combinedCount) : double.NaN, + Iterations = iterations, + Converged = converged, + }; + } + + private static Line[] AllHoleSegments(Side old) + { + var list = new List(); + foreach (var h in old.Holes) + list.AddRange(h.Segments); + return list.ToArray(); + } + + private static Line[] AllOldSegments(Side old) + { + var list = new List(old.Outer.Segments); + foreach (var h in old.Holes) + list.AddRange(h.Segments); + return list.ToArray(); + } + + private static bool TryClosestSegment(Vector pt, Line[] segments, out Vector closest) + { + closest = Vector.Invalid; + var bestDistance = double.PositiveInfinity; + foreach (var segment in segments) + { + var candidate = segment.ClosestPointTo(pt); + var d = pt.DistanceTo(candidate); + if (d < bestDistance) + { + bestDistance = d; + closest = candidate; + } + } + return !double.IsInfinity(bestDistance); + } + + private static bool SolveRotationTranslation( + int[] order, + Vector[] source, + Vector[] target, + double[] weights, + out double dTheta, + out Vector dTranslation + ) + { + dTheta = 0; + dTranslation = Vector.Invalid; + + var wSum = 0.0; + var qs = new Vector(); + var ts = new Vector(); + foreach (var i in order) + { + var w = weights[i]; + wSum += w; + qs += source[i] * w; + ts += target[i] * w; + } + if (wSum <= 0) + return false; + var cq = qs * (1.0 / wSum); + var ct = ts * (1.0 / wSum); + + var covXY = 0.0; + var covXX = 0.0; + foreach (var i in order) + { + var w = weights[i]; + var dq = source[i] - cq; + var dt = target[i] - ct; + covXY += w * (dq.X * dt.Y - dq.Y * dt.X); + covXX += w * (dq.X * dt.X + dq.Y * dt.Y); + } + if (!(covXX > 0) || double.IsNaN(covXY) || double.IsNaN(covXX)) + return false; + + dTheta = System.Math.Atan2(covXY, covXX); + if (double.IsNaN(dTheta) || double.IsInfinity(dTheta)) + return false; + + var r = Rotate(cq, dTheta); + dTranslation = ct - r; + return !double.IsNaN(dTranslation.X) && !double.IsNaN(dTranslation.Y); + } + + // ---------- result assembly ---------- + + private static AlignmentResult BuildResult( + Side old, + Side @new, + List candidates, + List reflectedCandidates, + AlignmentOptions options + ) + { + // Stage 2: fold in retained hole evidence only where it can actually + // distinguish competing perimeter fits. Candidates whose outer fit is + // within the tie band of the best are re-scored with hole evidence at + // reduced weight; a moved hole raises those candidates' combined RMS + // without dragging the (unchanged) perimeter pose itself. + var hasHoles = @new.Holes.Count > 0 && old.Holes.Count > 0; + var band = System.Math.Max(10 * options.FlattenTolerance, 0.05 * options.SamplingSpacing); + var bestOuter = candidates.Min(c => c.OuterRms); + var tied = candidates.Where(c => c.OuterRms <= bestOuter + band).ToList(); + + if (hasHoles) + { + foreach (var c in tied) + { + var refined = FitIcp( + old, + @new, + c.Rotation, + c.Translation, + options, + static (double x, double y) => (x, y), + true, + CancellationToken.None + ); + if (refined != null) + { + c.CombinedRms = refined.CombinedRms; + c.Iterations += refined.Iterations; + } + } + } + + // Winner: lowest combined RMS among the outer-tied set (holes can only + // reorder candidates inside the tie band), preferring the smaller outer + // residual when hole evidence is absent or identical. + var best = tied + .OrderBy(c => hasHoles && !double.IsNaN(c.CombinedRms) ? c.CombinedRms : c.OuterRms) + .ThenBy(c => c.OuterRms) + .First(); + + var reasons = AlignmentReasons.None; + if (!best.Converged) + reasons |= AlignmentReasons.FailedConvergence; + + // Pose ambiguity: distinct converged poses that fit near-equally on the + // data used to pick them (outer + holes when present) but move the + // geometry differently. A symmetric rectangle cannot yield a uniquely + // recoverable 180-degree orientation without other evidence. + var ambiguityCandidates = new List + { + best, + }; + foreach (var other in tied) + if (!ReferenceEquals(other, best)) + ambiguityCandidates.Add(other); + + // plus the winner's symmetry family: a 180-degree reversal about the + // outer centroid re-fit from the flipped start + var outerCenter = Centroid(old.Outer.Samples); + foreach (var flip in new[] { System.Math.PI, System.Math.PI / 2, -System.Math.PI / 2 }) + { + var anchor = Rotate(-outerCenter, flip) + outerCenter; + var probe = new Candidate + { + Rotation = NormalizeAngle(best.Rotation + flip), + Translation = Rotate(best.Translation, flip) + anchor, + }; + var refined = FitIcp( + old, + @new, + probe.Rotation, + probe.Translation, + options, + static (double x, double y) => (x, y), + hasHoles, + CancellationToken.None + ); + if (refined != null) + ambiguityCandidates.Add(refined); + } + + var scored = ambiguityCandidates + .Select(c => ( + Pose: c, + Metric: hasHoles && !double.IsNaN(c.CombinedRms) ? c.CombinedRms : c.OuterRms + )) + .ToList(); + var bestMetric = scored.Min(s => s.Metric); + var equivalents = 0; + var distinctAmbiguous = false; + foreach (var (pose, metric) in scored) + { + if (metric > bestMetric + band) + continue; + equivalents++; + if (AppliesDifferently(pose, best, @new, options)) + distinctAmbiguous = true; + } + if (distinctAmbiguous && equivalents > 1) + reasons |= AlignmentReasons.UnresolvedAlternatives; + + // Bidirectional coverage and residual gate. These are review reasons, + // not a calibrated envelope: no threshold here licenses skipping the + // operator overlay. + var (newToOld, oldToNew) = Coverage(old, @new, best, options); + var supportGate = 0.90; // design choice pending calibration + var residualGate = System.Math.Max(4 * options.FlattenTolerance, 0.5 * options.SamplingSpacing); + if (best.OuterRms > residualGate || System.Math.Min(newToOld, oldToNew) < supportGate) + reasons |= AlignmentReasons.SignificantBoundaryChange; + if (oldToNew < 0.5 || newToOld < 0.5) + reasons |= AlignmentReasons.InsufficientSupport; + + // Reflection diagnostics: reported, never applied (user decision D3). + var reflectedBand = System.Math.Max(10 * options.FlattenTolerance, 0.1 * options.SamplingSpacing); + var reflectedBest = reflectedCandidates.Count > 0 ? reflectedCandidates.Min(c => c.OuterRms) : double.PositiveInfinity; + if (reflectedBest <= best.OuterRms + reflectedBand) + reasons |= AlignmentReasons.ReflectionUncertain; + + double? iou = null; + try + { + iou = RegionIoU(old, @new, best, options); + } + catch (Exception) + { + iou = null; // diagnostic only; never gates anything + } + + var quantiles = ResidualQuantiles(old, @new, best, options); + + return new AlignmentResult + { + Converged = best.Converged, + Rotation = best.Rotation, + Translation = best.Translation, + Reflection = false, + Reasons = reasons, + ResidualRms = best.OuterRms, + ResidualP50 = quantiles.P50, + ResidualP90 = quantiles.P90, + NewToOldCoverage = newToOld, + OldToNewCoverage = oldToNew, + EquivalentCandidateCount = equivalents, + Iterations = best.Iterations, + NewSampleCount = @new.TotalSamples, + OldSampleCount = old.TotalSamples, + DiagnosticIoU = iou, + FailureMessage = (reasons & (AlignmentReasons.FailedConvergence | AlignmentReasons.InsufficientSupport)) != 0 + ? "Automatic alignment is not trustworthy for this pair; use the manual overlay." + : null, + }; + } + + private static bool AppliesDifferently( + Candidate a, + Candidate best, + Side @new, + AlignmentOptions options + ) + { + // Apply both poses to the new outer samples; equivalent poses leave the + // geometry where the other pose put it (max displacement below half the + // sampling spacing). + var limit = options.SamplingSpacing * 0.5; + foreach (var s in @new.Outer.Samples) + { + var pa = Rotate(s.Position, a.Rotation) + a.Translation; + var pb = Rotate(s.Position, best.Rotation) + best.Translation; + if (pa.DistanceTo(pb) > limit) + return true; + } + return false; + } + + private static (double NewToOld, double OldToNew) Coverage( + Side old, + Side @new, + Candidate pose, + AlignmentOptions options + ) + { + var oldSegments = AllOldSegments(old); + var spanBound = System.Math.Min(old.SpanDistance, @new.SpanDistance); + + var newMatched = 0; + var newTotal = 0; + foreach (var s in AllNewSamples(@new)) + { + var p = Rotate(s.Position, pose.Rotation) + pose.Translation; + newTotal++; + if (TryClosestSegment(p, oldSegments, out var c) && p.DistanceTo(c) <= spanBound) + newMatched++; + } + + // the reverse direction must compare OLD samples against the REVISED + // geometry posed into the OLD frame, not its local frame + var posedNewSegments = new Line[AllNewSegments(@new).Length]; + var posed = AllNewSegments(@new); + for (var i = 0; i < posed.Length; i++) + posedNewSegments[i] = new Line( + Rotate(posed[i].StartPoint, pose.Rotation) + pose.Translation, + Rotate(posed[i].EndPoint, pose.Rotation) + pose.Translation + ); + + var oldMatched = 0; + var oldSamples = AllOldSamples(old); + foreach (var s in oldSamples) + { + if (TryClosestSegment(s.Position, posedNewSegments, out var c) && s.Position.DistanceTo(c) <= spanBound) + oldMatched++; + } + + return ( + newTotal > 0 ? (double)newMatched / newTotal : 0, + oldSamples.Length > 0 ? (double)oldMatched / oldSamples.Length : 0 + ); + } + + private static (double P50, double P90) ResidualQuantiles( + Side old, + Side @new, + Candidate pose, + AlignmentOptions options + ) + { + var oldSegments = AllOldSegments(old); + var distances = new List(); + foreach (var s in AllNewSamples(@new)) + { + var p = Rotate(s.Position, pose.Rotation) + pose.Translation; + if (TryClosestSegment(p, oldSegments, out var c)) + distances.Add(p.DistanceTo(c)); + } + if (distances.Count == 0) + return (double.NaN, double.NaN); + distances.Sort(); + return (Quantile(distances, 0.5), Quantile(distances, 0.9)); + } + + private static double Quantile(List sorted, double q) + { + var pos = (sorted.Count - 1) * q; + var lo = (int)System.Math.Floor(pos); + var hi = (int)System.Math.Ceiling(pos); + return lo == hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo); + } + + private static double? RegionIoU(Side old, Side @new, Candidate pose, AlignmentOptions options) + { + var oldRegion = ToMaterialPaths(old.Outer, old.Holes, Vector.Zero, 0); + var newRegion = ToMaterialPaths(@new.Outer, @new.Holes, pose.Translation, pose.Rotation); + if (oldRegion == null || newRegion == null) + return null; + + var inter = Clipper.Intersect(oldRegion, newRegion, FillRule.NonZero, ClipperBridge.Precision); + var union = Clipper.Union(oldRegion, newRegion, FillRule.NonZero, ClipperBridge.Precision); + var interArea = System.Math.Abs(Clipper.Area(inter)); + var unionArea = Clipper.Area(union); + if (!(unionArea > 0)) + return null; + var iou = interArea / unionArea; + // Bounded diagnostic: values outside [0,1] beyond rounding mean the + // region arithmetic is wrong for this input; report nothing rather + // than clamping an invalid measurement. + if (iou < -1e-6 || iou > 1 + 1e-6) + return null; + return System.Math.Clamp(iou, 0, 1); + } + + private static PathsD ToMaterialPaths(Ring outer, List holes, Vector translation, double rotation) + { + PathsD paths = new(1 + holes.Count); + var outerPath = ToPath(outer.Points, translation, rotation, true); + if (outerPath == null) + return null; + paths.Add(outerPath); + foreach (var hole in holes) + { + var p = ToPath(hole.Points, translation, rotation, false); + if (p == null) + return null; + paths.Add(p); + } + return paths; + } + + private static PathD ToPath(Vector[] points, Vector translation, double rotation, bool positive) + { + var path = new PathD(points.Length); + foreach (var p in points) + { + var q = Rotate(p, rotation) + translation; + if (double.IsNaN(q.X) || double.IsNaN(q.Y)) + return null; + path.Add(new PointD(q.X, q.Y)); + } + if (path.Count < 3) + return null; + if (Clipper.IsPositive(path) != positive) + path.Reverse(); + return path; + } + + // ---------- small helpers ---------- + + private static IEnumerable AllNewSamples(Side side) + { + foreach (var s in side.Outer.Samples) + yield return s; + foreach (var h in side.Holes) + foreach (var s in h.Samples) + yield return s; + } + + private static ContourSample[] AllOldSamples(Side side) => AllNewSamples(side).ToArray(); + + private static Line[] AllNewSegments(Side side) + { + var list = new List(side.Outer.Segments); + foreach (var h in side.Holes) + list.AddRange(h.Segments); + return list.ToArray(); + } + + private static Vector Centroid(ContourSample[] samples) + { + var sum = new Vector(); + foreach (var s in samples) + sum += s.Position; + return samples.Length > 0 ? sum * (1.0 / samples.Length) : new Vector(); + } + + private static Vector Rotate(Vector v, double angle) + { + var cos = System.Math.Cos(angle); + var sin = System.Math.Sin(angle); + return new Vector(v.X * cos - v.Y * sin, v.X * sin + v.Y * cos); + } + + private static double NormalizeAngle(double angle) + { + var twoPi = 2 * System.Math.PI; + var a = angle % twoPi; + if (a > System.Math.PI) + a -= twoPi; + if (a < -System.Math.PI) + a += twoPi; + return a; + } + + private static AlignmentResult Failure(AlignmentReasons reason, string message) => + new() + { + Converged = false, + Reasons = reason, + FailureMessage = message ?? "Alignment refused; use the manual overlay.", + }; + } +} diff --git a/OpenNest.Tests/Geometry/DrawingAlignerTests.cs b/OpenNest.Tests/Geometry/DrawingAlignerTests.cs new file mode 100644 index 0000000..4c4cd3f --- /dev/null +++ b/OpenNest.Tests/Geometry/DrawingAlignerTests.cs @@ -0,0 +1,511 @@ +using System.Threading; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Geometry; + +/// +/// Slice-1 tests for : one rigid asymmetric line/arc +/// part with a hole must align against an independently known transform, and +/// symmetric, mirrored, degenerate or heavily revised geometry must refuse an +/// unearned unique/high-confidence result. Transforms are verified by applying +/// the returned pose to exact source geometry and measuring world-space error, +/// never by trusting the aligner's own residual scores. +/// +public class DrawingAlignerTests +{ + private static readonly AlignmentOptions Options = new() + { + FlattenTolerance = 0.01, + SamplingSpacing = 0.5, + OutlierDistance = 5.0, + }; + + // ---------- fixtures, each declared in its own drawing-local frame ---------- + + /// + /// Asymmetric outer contour: a 10x8 slab with one arc corner and one diagonal + /// corner. The diagonal breaks both mirror and 180-degree symmetry, so a + /// correct pose is recoverable and a wrong one must be distinguishable. + /// + private static List AsymmetricOuter() => + [ + new Line(new Vector(0, 0), new Vector(10, 0)), + new Line(new Vector(10, 0), new Vector(10, 6)), + new Arc(new Vector(8, 6), 2, 0, System.Math.PI / 2), + new Line(new Vector(8, 8), new Vector(4, 8)), + new Line(new Vector(4, 8), new Vector(0, 4)), + new Line(new Vector(0, 4), new Vector(0, 0)), + ]; + + private static List HoleAt(double cx, double cy, double r) => [new Circle(new Vector(cx, cy), r)]; + + private static Program BuildProgram(params List[] contours) => + ConvertGeometry.ToProgram(contours.SelectMany(c => c).ToList()); + + /// Exact points of a fixture: line endpoints plus dense circle samples. + private static List ExactPoints(params IEnumerable[] contours) + { + var points = new List(); + foreach (var contour in contours) + foreach (var entity in contour) + switch (entity) + { + case Line line: + points.Add(line.StartPoint); + points.Add(line.EndPoint); + break; + case Circle circle: + for (var k = 0; k < 48; k++) + { + var a = 2 * System.Math.PI * k / 48; + points.Add( + new Vector( + circle.Center.X + circle.Radius * System.Math.Cos(a), + circle.Center.Y + circle.Radius * System.Math.Sin(a) + ) + ); + } + break; + case Arc arc: + // the arc center is NOT on the contour; sample the sweep + for (var k = 0; k <= 16; k++) + { + var a = arc.StartAngle + (arc.EndAngle - arc.StartAngle) * k / 16.0; + points.Add( + new Vector( + arc.Center.X + arc.Radius * System.Math.Cos(a), + arc.Center.Y + arc.Radius * System.Math.Sin(a) + ) + ); + } + break; + } + return points; + } + + private readonly record struct Pose(double Rotation, Vector Translation); + + private static Vector ApplyPoint(Pose pose, Vector p) + { + var cos = System.Math.Cos(pose.Rotation); + var sin = System.Math.Sin(pose.Rotation); + return new Vector( + pose.Translation.X + p.X * cos - p.Y * sin, + pose.Translation.Y + p.X * sin + p.Y * cos + ); + } + + /// Rigidly transform fixture entities, preserving line/arc/circle types. + private static List TransformEntities(IEnumerable entities, Pose pose) + { + var result = new List(); + foreach (var entity in entities) + { + switch (entity) + { + case Line line: + result.Add(new Line(ApplyPoint(pose, line.StartPoint), ApplyPoint(pose, line.EndPoint))); + break; + case Circle circle: + result.Add(new Circle(ApplyPoint(pose, circle.Center), circle.Radius)); + break; + case Arc arc: + result.Add( + new Arc( + ApplyPoint(pose, arc.Center), + arc.Radius, + arc.StartAngle + pose.Rotation, + arc.EndAngle + pose.Rotation, + arc.IsReversed + ) + ); + break; + } + } + return result; + } + + /// + /// Independent oracle: every point of one fixture must lie on the OTHER + /// fixture's TRUE geometry (segments, circles, arcs — not a sampled point + /// cloud, whose spacing would quantize the measurement) under the recovered + /// pose, and vice versa. + /// + private static double MaxWorldError(Pose pose, List newEntities, List oldEntities) + { + var worst = 0.0; + foreach (var p in ExactPoints(newEntities)) + worst = System.Math.Max(worst, DistanceToGeometry(ApplyPoint(pose, p), oldEntities)); + foreach (var p in ExactPoints(oldEntities)) + worst = System.Math.Max(worst, DistanceToGeometry(p, TransformEntities(newEntities, pose))); + return worst; + } + + private static double DistanceToGeometry(Vector p, List entities) + { + var best = double.PositiveInfinity; + foreach (var entity in entities) + { + double d = entity switch + { + Line line => DistanceToSegment(p, line.StartPoint, line.EndPoint), + Circle circle => System.Math.Abs(p.DistanceTo(circle.Center) - circle.Radius), + Arc arc => System.Math.Abs(p.DistanceTo(arc.Center) - arc.Radius), + _ => double.PositiveInfinity, + }; + best = System.Math.Min(best, d); + } + return best; + } + + private static double DistanceToSegment(Vector p, Vector a, Vector b) + { + var dx = b.X - a.X; + var dy = b.Y - a.Y; + var lenSq = dx * dx + dy * dy; + if (lenSq <= 0) + return p.DistanceTo(a); + var t = System.Math.Clamp(((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq, 0.0, 1.0); + return p.DistanceTo(new Vector(a.X + t * dx, a.Y + t * dy)); + } + + private static void AssertNoFlag(AlignmentResult result, AlignmentReasons flag) => + Assert.True( + (result.Reasons & flag) == 0, + $"unexpected review reason {flag}; full set: {result.Reasons}" + ); + + private static void AssertRefused(AlignmentResult result, AlignmentReasons expected) + { + Assert.False(result.Converged); + Assert.NotNull(result.FailureMessage); + Assert.True( + (result.Reasons & expected) != 0, + $"expected review reason {expected}; got {result.Reasons}" + ); + } + + // ---------- asymmetric success ---------- + + [Theory] + [InlineData(0.0, 3.25, -1.75)] + [InlineData(0.5, -2.0, 4.5)] + [InlineData(-1.25, 0.0, 0.0)] + [InlineData(2.75, 6.0, -6.0)] + public void Align_KnownRigidTransformOfAsymmetricHoledPart_RecoversWorldSpaceGeometry( + double rotation, + double tx, + double ty + ) + { + var outer = AsymmetricOuter(); + var hole = HoleAt(3, 3, 1); + var oldPgm = BuildProgram(outer, hole); + + // the revised drawing is the SAME geometry through an independently known + // rigid transform, rebuilt as exact entities + var pose = new Pose(rotation, new Vector(tx, ty)); + var revisedEntities = TransformEntities(outer.Concat(hole), pose); + var revisedPgm = ConvertGeometry.ToProgram(revisedEntities); + + var result = DrawingAligner.Align(oldPgm, revisedPgm, Options); + + Assert.True(result.Converged, $"did not converge: {result.Reasons} {result.FailureMessage}"); + Assert.True(result.FailureMessage == null, result.FailureMessage); + AssertNoFlag(result, AlignmentReasons.InsufficientSupport); + AssertNoFlag(result, AlignmentReasons.InvalidGeometry); + AssertNoFlag(result, AlignmentReasons.ReflectionUncertain); + AssertNoFlag(result, AlignmentReasons.UnresolvedAlternatives); + + // verification against independent known geometry, not the aligner's + // score: the recovered NEW->OLD pose must map the exact REVISED vertices + // onto the exact OLD vertices and back + var error = MaxWorldError( + new Pose(result.Rotation, result.Translation), + revisedEntities, + outer.Concat(hole).ToList() + ); + Assert.True(error < 0.05, $"world-space reconstruction error {error}"); + + Assert.True(result.NewToOldCoverage > 0.95, $"coverage {result.NewToOldCoverage}"); + Assert.True(result.OldToNewCoverage > 0.95, $"coverage {result.OldToNewCoverage}"); + Assert.True(result.ResidualRms < 0.05, $"residual {result.ResidualRms}"); + Assert.False(result.Reflection); + + // the diagnostic IoU is bounded and meaningful for a holed part + Assert.NotNull(result.DiagnosticIoU); + Assert.InRange(result.DiagnosticIoU.Value, 0.0, 1.0); + Assert.True(result.DiagnosticIoU > 0.9, $"diagnostic IoU {result.DiagnosticIoU}"); + } + + // ---------- symmetric outlines cannot claim a unique pose ---------- + + [Fact] + public void Align_SymmetricRectangle_FlagsUnresolvedAlternatives() + { + static List Rect(double w, double h) => + [ + new Line(new Vector(-w / 2, -h / 2), new Vector(w / 2, -h / 2)), + new Line(new Vector(w / 2, -h / 2), new Vector(w / 2, h / 2)), + new Line(new Vector(w / 2, h / 2), new Vector(-w / 2, h / 2)), + new Line(new Vector(-w / 2, h / 2), new Vector(-w / 2, -h / 2)), + ]; + + var oldPgm = BuildProgram(Rect(10, 6)); + var revisedPgm = ConvertGeometry.ToProgram( + TransformEntities(Rect(10, 6), new Pose(System.Math.PI, new Vector(1.0, 0.5))) + ); + + var result = DrawingAligner.Align(oldPgm, revisedPgm, Options); + + Assert.True( + (result.Reasons & AlignmentReasons.UnresolvedAlternatives) != 0, + $"a rectangle's 180-degree reversal is not recoverable; reasons={result.Reasons}" + ); + } + + [Fact] + public void Align_Circle_FlagsUnresolvedAlternatives() + { + var oldPgm = BuildProgram(HoleAt(0, 0, 12)); // a circle used as the outer contour + var revisedPgm = BuildProgram(HoleAt(2.5, -1.5, 12)); + + var result = DrawingAligner.Align(oldPgm, revisedPgm, Options); + + Assert.True( + (result.Reasons & AlignmentReasons.UnresolvedAlternatives) != 0, + $"a circle has no unique angle; reasons={result.Reasons}" + ); + } + + // ---------- mirror is never auto-selected (user decision D3) ---------- + + [Fact] + public void Align_MirroredRevision_NeverSelectsReflectionAndDoesNotLookSafe() + { + var outer = AsymmetricOuter(); + var hole = HoleAt(3, 3, 1); + var oldPgm = BuildProgram(outer, hole); + + // mirror the revised drawing about its X axis + var mirrored = new List(); + foreach (var entity in TransformEntities(outer.Concat(hole), new Pose(0, new Vector(4, 0)))) + { + switch (entity) + { + case Line line: + mirrored.Add( + new Line( + new Vector(line.StartPoint.X, -line.StartPoint.Y), + new Vector(line.EndPoint.X, -line.EndPoint.Y) + ) + ); + break; + case Circle circle: + mirrored.Add(new Circle(new Vector(circle.Center.X, -circle.Center.Y), circle.Radius)); + break; + case Arc arc: + mirrored.Add( + new Arc( + new Vector(arc.Center.X, -arc.Center.Y), + arc.Radius, + -arc.EndAngle, + -arc.StartAngle, + !arc.IsReversed + ) + ); + break; + } + } + var revisedPgm = ConvertGeometry.ToProgram(mirrored); + + var result = DrawingAligner.Align(oldPgm, revisedPgm, Options); + + Assert.False(result.Reflection); + Assert.True( + (result.Reasons & AlignmentReasons.ReflectionUncertain) != 0 + || result.FailureMessage != null + || (result.Reasons & AlignmentReasons.SignificantBoundaryChange) != 0, + $"a mirrored revision must not present as a safe rigid seed; reasons={result.Reasons}" + ); + } + + // ---------- changed geometry is reported, not hidden ---------- + + [Fact] + public void Align_RemovedOuterEdge_ReportsBoundaryChangeOrRefuses() + { + var oldPgm = BuildProgram(AsymmetricOuter(), HoleAt(3, 3, 1)); + + // the same slab with the diagonal corner squared off: a major outline edit + var revisedOuter = new List + { + new Line(new Vector(0, 0), new Vector(10, 0)), + new Line(new Vector(10, 0), new Vector(10, 6)), + new Arc(new Vector(8, 6), 2, 0, System.Math.PI / 2), + new Line(new Vector(8, 8), new Vector(0, 8)), + new Line(new Vector(0, 8), new Vector(0, 0)), + }; + var revisedPgm = BuildProgram(revisedOuter, HoleAt(3, 3, 1)); + + var result = DrawingAligner.Align(oldPgm, revisedPgm, Options); + + Assert.True( + result.FailureMessage != null + || (result.Reasons & (AlignmentReasons.SignificantBoundaryChange | AlignmentReasons.InsufficientSupport)) != 0, + $"a squared-off corner must be reported; reasons={result.Reasons}" + ); + } + + [Fact] + public void Align_MovedHole_KeepsPerimeterPoseButReportsBoundaryChange() + { + var oldPgm = BuildProgram(AsymmetricOuter(), HoleAt(3, 3, 1)); + var revisedPgm = BuildProgram(AsymmetricOuter(), HoleAt(6.5, 5.5, 1)); + + var result = DrawingAligner.Align(oldPgm, revisedPgm, Options); + + // the unchanged perimeter must still land on identity — a moved hole must + // not drag it — while the changed hole is reported as an unmatched span + Assert.True(System.Math.Abs(result.Rotation) < 1e-6, $"rotation drifted: {result.Rotation}"); + Assert.True(result.Translation.DistanceTo(new Vector()) < 0.05, $"translation drifted: {result.Translation}"); + Assert.True( + (result.Reasons & (AlignmentReasons.SignificantBoundaryChange | AlignmentReasons.InsufficientSupport)) != 0, + $"a moved hole is a changed span; reasons={result.Reasons}" + ); + } + + // ---------- invalid input refuses instead of throwing ---------- + + [Fact] + public void Align_InvalidInput_ReportsInvalidGeometryWithoutThrowing() + { + var good = BuildProgram(AsymmetricOuter(), HoleAt(3, 3, 1)); + + AssertRefused(DrawingAligner.Align(new Program(), good, Options), AlignmentReasons.InvalidGeometry); + AssertRefused(DrawingAligner.Align(null, good, Options), AlignmentReasons.InvalidGeometry); + + var nan = new Program(); + nan.Codes.Add(new LinearMove(0, 0)); + nan.Codes.Add(new LinearMove(double.NaN, 5)); + nan.Codes.Add(new LinearMove(5, 5)); + nan.Codes.Add(new LinearMove(0, 0)); + AssertRefused(DrawingAligner.Align(nan, good, Options), AlignmentReasons.InvalidGeometry); + } + + [Fact] + public void Align_TooFewSamples_ReportsInsufficientSupport() + { + var oldPgm = BuildProgram(AsymmetricOuter(), HoleAt(3, 3, 1)); + var tiny = new Program(); + tiny.Codes.Add(new LinearMove(0, 0)); + tiny.Codes.Add(new LinearMove(1, 0)); + tiny.Codes.Add(new LinearMove(0, 1)); + tiny.Codes.Add(new LinearMove(0, 0)); + + AssertRefused(DrawingAligner.Align(oldPgm, tiny, Options), AlignmentReasons.InsufficientSupport); + } + + [Fact] + public void Align_SampleBudgetExceeded_ReportsInsufficientSupport() + { + var oldPgm = BuildProgram(AsymmetricOuter(), HoleAt(3, 3, 1)); + var tight = new AlignmentOptions { MaxTotalSamples = 10 }; + + AssertRefused(DrawingAligner.Align(oldPgm, oldPgm, tight), AlignmentReasons.InsufficientSupport); + } + + [Fact] + public void Align_DisconnectedMaterial_ReportsInvalidGeometry() + { + // two separate closed contours, neither containing the other + var entities = new List + { + new Line(new Vector(0, 0), new Vector(10, 0)), + new Line(new Vector(10, 0), new Vector(10, 10)), + new Line(new Vector(10, 10), new Vector(0, 10)), + new Line(new Vector(0, 10), new Vector(0, 0)), + new Line(new Vector(50, 50), new Vector(55, 50)), + new Line(new Vector(55, 50), new Vector(55, 55)), + new Line(new Vector(55, 55), new Vector(50, 55)), + new Line(new Vector(50, 55), new Vector(50, 50)), + }; + var pgm = ConvertGeometry.ToProgram(entities); + var good = BuildProgram(AsymmetricOuter(), HoleAt(3, 3, 1)); + + AssertRefused(DrawingAligner.Align(good, pgm, Options), AlignmentReasons.InvalidGeometry); + } + + // ---------- cancellation fails closed ---------- + + [Fact] + public void Align_CancelledUpFront_FailsClosed() + { + var oldPgm = BuildProgram(AsymmetricOuter(), HoleAt(3, 3, 1)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var result = DrawingAligner.Align(oldPgm, oldPgm, Options, cts.Token); + + Assert.True( + result.FailureMessage != null || !result.Converged, + $"a cancelled run must not report a confident success; reasons={result.Reasons}" + ); + } + + // ---------- diagnostic region arithmetic (Slice-0 step 4) ---------- + + [Fact] + public void DiagnosticIoU_IdenticalHoledPart_IsOne() + { + var pgm = BuildProgram(AsymmetricOuter(), HoleAt(3, 3, 1)); + + var iou = DrawingAligner.DiagnosticIoU(pgm, pgm, 0, new Vector(), false, Options); + + Assert.NotNull(iou); + Assert.InRange(iou.Value, 0.0, 1.0); + Assert.True(iou > 0.999, $"identical parts must score 1, got {iou}"); + } + + [Fact] + public void DiagnosticIoU_DisjointParts_IsZero() + { + var a = BuildProgram(AsymmetricOuter(), HoleAt(3, 3, 1)); + var b = ConvertGeometry.ToProgram( + new List + { + new Line(new Vector(200, 200), new Vector(210, 200)), + new Line(new Vector(210, 200), new Vector(210, 208)), + new Line(new Vector(210, 208), new Vector(200, 208)), + new Line(new Vector(200, 208), new Vector(200, 200)), + } + ); + + var iou = DrawingAligner.DiagnosticIoU(a, b, 0, new Vector(), false, Options); + + Assert.NotNull(iou); + Assert.InRange(iou.Value, 0.0, 1.0); + Assert.True(iou < 0.001, $"disjoint parts must score 0, got {iou}"); + } + + [Fact] + public void DiagnosticIoU_TranslatedPart_MatchesIndependentAreaComputation() + { + var a = BuildProgram(AsymmetricOuter(), HoleAt(3, 3, 1)); + var b = ConvertGeometry.ToProgram( + TransformEntities(AsymmetricOuter().Concat(HoleAt(3, 3, 1)), new Pose(0, new Vector(5, 0))) + ); + + // aligning first, then scoring at the returned pose, must beat scoring at identity + var result = DrawingAligner.Align(a, b, Options); + var aligned = DrawingAligner.DiagnosticIoU(a, b, result.Rotation, result.Translation, false, Options); + var unaligned = DrawingAligner.DiagnosticIoU(a, b, 0, new Vector(), false, Options); + + Assert.NotNull(aligned); + Assert.NotNull(unaligned); + Assert.True(aligned > unaligned, $"aligned {aligned} should beat identity {unaligned}"); + Assert.True(aligned > 0.99, $"aligned IoU {aligned}"); + } +}