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}");
+ }
+}