mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-02 01:58:48 -04:00
feat(geometry): seed drawing alignment with robust contour ICP
Add DrawingAligner: bounded multi-start rigid ICP that maps a revised drawing's geometry into the old drawing-local frame as an operator-review seed. Material-filtered programs flatten through the shared chord-error machinery, resample via the shared ContourSampler ring scheduler, and fit closest bounded target segments with trimmed weighted least squares (reflection never solved, never applied). Seeds come from outer-centroid translation and MBR angle deltas with the 90-degree family plus identity. The stable outer boundary is fitted first; retained hole evidence only reorders candidates inside the outer tie band, so a moved hole cannot drag the perimeter. Structured AlignmentResult reports convergence, residual quantiles, bidirectional coverage, symmetry ambiguity, reflection uncertainty, and a bounded diagnostic IoU. Refusals (invalid input, insufficient support, unsupported topology, sample budget, cancellation) are reported as reasons, not thrown. Tests verify recovered poses against exact fixture geometry (true segments/arcs/circles, not sample clouds) for known transforms, symmetric rectangle/circle ambiguity, mirror non-selection, moved-hole perimeter preservation, and bounded region arithmetic (identical=1, disjoint=0).
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Tuning for <see cref="DrawingAligner"/>. 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.
|
||||
/// </summary>
|
||||
public sealed class AlignmentOptions
|
||||
{
|
||||
/// <summary>Chord-error tolerance used when flattening arcs for measurement.</summary>
|
||||
public double FlattenTolerance { get; set; } = 0.01;
|
||||
|
||||
/// <summary>
|
||||
/// Target arclength between resampled contour samples. Must resolve the
|
||||
/// intended residual gate: a residual below roughly half this spacing is
|
||||
/// not meaningful.
|
||||
/// </summary>
|
||||
public double SamplingSpacing { get; set; } = 0.5;
|
||||
|
||||
/// <summary>Upper bound on samples per contour ring.</summary>
|
||||
public int MaxSamplesPerRing { get; set; } = 4000;
|
||||
|
||||
/// <summary>Upper bound on total samples across all rings of one drawing.</summary>
|
||||
public int MaxTotalSamples { get; set; } = 20000;
|
||||
|
||||
/// <summary>Trim fraction: worst-distance correspondences excluded from each ICP fit.</summary>
|
||||
public double TrimFraction { get; set; } = 0.10;
|
||||
|
||||
/// <summary>Fit iterations per candidate start.</summary>
|
||||
public int MaxIterations { get; set; } = 40;
|
||||
|
||||
/// <summary>Pose change (radians) treated as converged.</summary>
|
||||
public double RotationEpsilon { get; set; } = 1e-7;
|
||||
|
||||
/// <summary>Translation change in units treated as converged.</summary>
|
||||
public double TranslationEpsilon { get; set; } = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public double OutlierDistance { get; set; } = 10.0;
|
||||
|
||||
/// <summary>Minimum distinct samples required to attempt a fit at all.</summary>
|
||||
public int MinSamples { get; set; } = 8;
|
||||
}
|
||||
|
||||
/// <summary>Why an alignment is not trustworthy. Absence of all flags is not a
|
||||
/// calibrated safe envelope; it only means no review reason fired.</summary>
|
||||
[Flags]
|
||||
public enum AlignmentReasons
|
||||
{
|
||||
None = 0,
|
||||
|
||||
/// <summary>The fit stopped on an iteration/work limit or stagnated without converging.</summary>
|
||||
FailedConvergence = 1,
|
||||
|
||||
/// <summary>The revised geometry has too few usable samples, too little outer
|
||||
/// support, or too little of the target matched to it.</summary>
|
||||
InsufficientSupport = 2,
|
||||
|
||||
/// <summary>Significant changed/unmatched boundary spans in either direction.</summary>
|
||||
SignificantBoundaryChange = 4,
|
||||
|
||||
/// <summary>Two or more genuinely distinct poses fit near-equally (symmetry,
|
||||
/// repeated features). The reported transform is still valid geometry.</summary>
|
||||
UnresolvedAlternatives = 8,
|
||||
|
||||
/// <summary>Input geometry is invalid: nonfinite coordinates, degenerate or
|
||||
/// unclosed rings, unsupported topology, zero usable perimeter.</summary>
|
||||
InvalidGeometry = 16,
|
||||
|
||||
/// <summary>A reflected candidate fits as well as the best rigid one, or the
|
||||
/// input cannot exclude reflection. Reflection is never applied silently.</summary>
|
||||
ReflectionUncertain = 32,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Reflection"/> is
|
||||
/// true, which the automatic aligner never sets), then rotate by
|
||||
/// <see cref="Rotation"/> (radians), then translate by <see cref="Translation"/>.
|
||||
/// Diagnostics are in the drawings' declared units. A diagnostic IoU is
|
||||
/// bounded to [0, 1] and is not a calibrated probability.
|
||||
/// </summary>
|
||||
public sealed class AlignmentResult
|
||||
{
|
||||
public bool Converged { get; init; }
|
||||
public double Rotation { get; init; }
|
||||
public Vector Translation { get; init; }
|
||||
public bool Reflection { get; init; }
|
||||
|
||||
/// <summary>Review reasons; combined across candidate selection. Empty does
|
||||
/// not license skipping the operator overlay — no calibrated envelope
|
||||
/// exists yet.</summary>
|
||||
public AlignmentReasons Reasons { get; init; }
|
||||
|
||||
/// <summary>Trimmed RMS of matched sample-to-segment residuals.</summary>
|
||||
public double ResidualRms { get; init; }
|
||||
public double ResidualP50 { get; init; }
|
||||
public double ResidualP90 { get; init; }
|
||||
|
||||
/// <summary>Fraction of NEW samples matching OLD within the outlier bound, and vice versa.</summary>
|
||||
public double NewToOldCoverage { get; init; }
|
||||
public double OldToNewCoverage { get; init; }
|
||||
|
||||
/// <summary>Number of distinct converged candidate poses clustered as equivalent.</summary>
|
||||
public int EquivalentCandidateCount { get; init; }
|
||||
|
||||
public int Iterations { get; init; }
|
||||
public int NewSampleCount { get; init; }
|
||||
public int OldSampleCount { get; init; }
|
||||
|
||||
/// <summary>Bounded diagnostic intersection-over-union of the material regions,
|
||||
/// or null when regions are unavailable or degenerate. Never a gate input.</summary>
|
||||
public double? DiagnosticIoU { get; init; }
|
||||
|
||||
public string FailureMessage { get; init; }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,511 @@
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
/// <summary>
|
||||
/// Slice-1 tests for <see cref="DrawingAligner"/>: 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.
|
||||
/// </summary>
|
||||
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 ----------
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static List<Entity> 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<Entity> HoleAt(double cx, double cy, double r) => [new Circle(new Vector(cx, cy), r)];
|
||||
|
||||
private static Program BuildProgram(params List<Entity>[] contours) =>
|
||||
ConvertGeometry.ToProgram(contours.SelectMany(c => c).ToList());
|
||||
|
||||
/// <summary>Exact points of a fixture: line endpoints plus dense circle samples.</summary>
|
||||
private static List<Vector> ExactPoints(params IEnumerable<Entity>[] contours)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Rigidly transform fixture entities, preserving line/arc/circle types.</summary>
|
||||
private static List<Entity> TransformEntities(IEnumerable<Entity> entities, Pose pose)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static double MaxWorldError(Pose pose, List<Entity> newEntities, List<Entity> 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<Entity> 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<Entity> 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<Entity>();
|
||||
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<Entity>
|
||||
{
|
||||
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<Entity>
|
||||
{
|
||||
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<Entity>
|
||||
{
|
||||
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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user