This commit is contained in:
2026-05-06 10:22:34 +02:00
parent fc73ff47ff
commit 26a7b6b6da
945 changed files with 3606 additions and 4887 deletions

View File

@@ -33,4 +33,29 @@ public sealed class DxfExporterTests
}
}
}
[Fact]
public void ExportRejectsInvalidDrawing()
{
var document = SketchDocument.CreateDefault();
document.AddEntity(new LineEntity(Guid.NewGuid(), Layer.Cut.Name, new Point2(5, 5), new Point2(5, 5)));
var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.dxf");
try
{
var exception = Assert.Throws<InvalidOperationException>(() =>
new NetDxfExporter().Export(document, path));
Assert.Contains("Cannot export invalid drawing", exception.Message);
Assert.False(File.Exists(path));
}
finally
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}

View File

@@ -0,0 +1,141 @@
using TraceCad.Core.Geometry;
using TraceCad.Core.Model;
using Xunit;
namespace TraceCad.Tests;
public sealed class EntityEditingTests
{
[Fact]
public void TrimOrExtendLineEndpointToLineBoundary()
{
var subject = new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(0, 0),
new Point2(8, 0));
var boundary = new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(10, -5),
new Point2(10, 5));
var success = EntityEditing.TryTrimOrExtendEndpoint(
subject,
new Point2(8, 0),
boundary,
new Point2(10, 0),
out var replacement);
Assert.True(success);
var line = Assert.IsType<LineEntity>(replacement);
Assert.Equal(new Point2(0, 0), line.Start);
Assert.Equal(10.0, line.End.X, 9);
Assert.Equal(0.0, line.End.Y, 9);
}
[Fact]
public void TrimOrExtendLineEndpointCanUseLineBoundaryExtension()
{
var subject = new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(0, 0),
new Point2(8, 0));
var boundary = new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(10, 4),
new Point2(10, 8));
var success = EntityEditing.TryTrimOrExtendEndpoint(
subject,
new Point2(8, 0),
boundary,
new Point2(10, 4),
out var replacement);
Assert.True(success);
var line = Assert.IsType<LineEntity>(replacement);
Assert.Equal(10.0, line.End.X, 9);
Assert.Equal(0.0, line.End.Y, 9);
}
[Fact]
public void TrimRejectsLineExtension()
{
var subject = new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(0, 0),
new Point2(8, 0));
var boundary = new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(10, -5),
new Point2(10, 5));
var success = EntityEditing.TryTrimEndpoint(
subject,
new Point2(8, 0),
boundary,
new Point2(10, 0),
out _);
Assert.False(success);
}
[Fact]
public void ExtendRejectsLineTrim()
{
var subject = new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(0, 0),
new Point2(12, 0));
var boundary = new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(10, -5),
new Point2(10, 5));
var success = EntityEditing.TryExtendEndpoint(
subject,
new Point2(12, 0),
boundary,
new Point2(10, 0),
out _);
Assert.False(success);
}
[Fact]
public void TrimOrExtendArcEndpointToLineBoundary()
{
var subject = new ArcEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(0, 0),
10,
0,
90,
false);
var boundary = new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(5, -12),
new Point2(5, 12));
var success = EntityEditing.TryTrimOrExtendEndpoint(
subject,
subject.EndPoint,
boundary,
new Point2(5, 9),
out var replacement);
Assert.True(success);
var arc = Assert.IsType<ArcEntity>(replacement);
Assert.Equal(0.0, arc.StartAngleDeg, 9);
Assert.Equal(60.0, arc.EndAngleDeg, 9);
}
}

View File

@@ -14,6 +14,79 @@ public sealed class GeometryTests
Assert.Equal(5.0, line.Length, 9);
}
[Fact]
public void ParallelConstraintPreservesTargetLengthAndMatchesReferenceDirection()
{
var reference = new LineEntity(Guid.NewGuid(), Layer.Cut.Name, new Point2(0, 0), new Point2(10, 0));
var target = new LineEntity(Guid.NewGuid(), Layer.Cut.Name, new Point2(0, 5), new Point2(0, 9));
var success = ConstraintGeometry.TryMakeParallel(reference, target, out var replacement);
Assert.True(success);
Assert.Equal(target.Length, replacement.Length, 9);
Assert.Equal(0.0, replacement.Start.Y - replacement.End.Y, 9);
}
[Fact]
public void ParallelDistanceMeasuresPerpendicularSpacing()
{
var first = new LineEntity(Guid.NewGuid(), Layer.Cut.Name, new Point2(0, 0), new Point2(10, 0));
var second = new LineEntity(Guid.NewGuid(), Layer.Cut.Name, new Point2(3, 7), new Point2(13, 7));
var success = ConstraintGeometry.TryMeasureParallelDistance(first, second, out var distance);
Assert.True(success);
Assert.Equal(7.0, distance, 9);
}
[Fact]
public void TangentConstraintMovesLineOntoNearestArcEndpointTangent()
{
var arc = new ArcEntity(Guid.NewGuid(), Layer.Cut.Name, new Point2(0, 0), 10, 0, 90, false);
var line = new LineEntity(Guid.NewGuid(), Layer.Cut.Name, new Point2(10.2, 0.1), new Point2(16.2, 2.1));
var success = ConstraintGeometry.TryMakeLineTangentToArc(line, arc, out var replacement);
Assert.True(success);
Assert.Equal(arc.StartPoint.X, replacement.Start.X, 9);
Assert.Equal(arc.StartPoint.Y, replacement.Start.Y, 9);
var direction = (replacement.End - replacement.Start).Normalized();
var tangent = GeometryHelpers.TangentAtAngle(arc.StartAngleDeg, arc.IsClockwise);
Assert.True(Math.Abs(direction.Dot(tangent)) > 0.999);
}
[Fact]
public void SolverAppliesDrivingRadiusDimension()
{
var document = SketchDocument.CreateDefault();
var arcId = Guid.NewGuid();
document.AddEntity(new ArcEntity(arcId, Layer.Cut.Name, new Point2(0, 0), 10, 0, 90, false));
document.AddConstraint(new SketchConstraint(Guid.NewGuid(), ConstraintType.Radius, new[] { arcId }, 25));
SketchConstraintSolver.Solve(document);
var arc = Assert.IsType<ArcEntity>(document.Entities.Single(entity => entity.Id == arcId));
Assert.Equal(25.0, arc.Radius, 9);
}
[Fact]
public void SolverAppliesDrivingParallelDistanceDimension()
{
var document = SketchDocument.CreateDefault();
var firstId = Guid.NewGuid();
var secondId = Guid.NewGuid();
document.AddEntity(new LineEntity(firstId, Layer.Cut.Name, new Point2(0, 0), new Point2(10, 0)));
document.AddEntity(new LineEntity(secondId, Layer.Cut.Name, new Point2(0, 2), new Point2(10, 2)));
document.AddConstraint(new SketchConstraint(Guid.NewGuid(), ConstraintType.Distance, new[] { firstId, secondId }, 7));
SketchConstraintSolver.Solve(document);
var first = Assert.IsType<LineEntity>(document.Entities.Single(entity => entity.Id == firstId));
var second = Assert.IsType<LineEntity>(document.Entities.Single(entity => entity.Id == secondId));
Assert.True(ConstraintGeometry.TryMeasureParallelDistance(first, second, out var distance));
Assert.Equal(7.0, distance, 9);
}
[Fact]
public void CircleFromThreePointsComputesCenterAndRadius()
{
@@ -69,6 +142,34 @@ public sealed class GeometryTests
Assert.False(success);
}
[Fact]
public void ArcFromStartTangentAndEndPointMatchesRequestedTangent()
{
var success = GeometryHelpers.TryCreateArcFromStartTangentAndEndPoint(
new Point2(0, 0),
new Vector2(1, 0),
new Point2(10, 10),
new Point2(8, 2),
out var arc);
Assert.True(success);
var tangent = GeometryHelpers.TangentAtAngle(arc.StartAngleDeg, arc.IsClockwise);
Assert.True(tangent.Dot(new Vector2(1, 0)) > 0.999);
}
[Fact]
public void ArcFromStartTangentFailsWhenEndPointLiesOnTangentLine()
{
var success = GeometryHelpers.TryCreateArcFromStartTangentAndEndPoint(
new Point2(0, 0),
new Vector2(1, 0),
new Point2(10, 0),
null,
out _);
Assert.False(success);
}
[Theory]
[InlineData(360, 0)]
[InlineData(-90, 270)]

View File

@@ -18,6 +18,8 @@ public sealed class SerializationTests
document.AddEntity(new LineEntity(lineId, Layer.Cut.Name, new Point2(10, 20), new Point2(40, 50)));
document.AddEntity(new CircleEntity(circleId, Layer.Cut.Name, new Point2(15, 15), 8));
document.AddEntity(new ArcEntity(arcId, Layer.Cut.Name, new Point2(0, 0), 12, 0, 90, false));
document.AddConstraint(new SketchConstraint(Guid.NewGuid(), ConstraintType.Tangent, new[] { lineId, arcId }));
document.AddConstraint(new SketchConstraint(Guid.NewGuid(), ConstraintType.Radius, new[] { arcId }, 12.0));
var json = SketchDocumentSerializer.Serialize(document);
var reloaded = SketchDocumentSerializer.Deserialize(json);
@@ -27,6 +29,14 @@ public sealed class SerializationTests
Assert.Contains(reloaded.Entities, entity => entity.Id == lineId);
Assert.Contains(reloaded.Entities, entity => entity.Id == circleId);
Assert.Contains(reloaded.Entities, entity => entity.Id == arcId);
Assert.Contains(reloaded.Constraints, constraint =>
constraint.Type == ConstraintType.Tangent &&
constraint.EntityIds.Contains(lineId) &&
constraint.EntityIds.Contains(arcId));
Assert.Contains(reloaded.Constraints, constraint =>
constraint.Type == ConstraintType.Radius &&
constraint.EntityIds.Contains(arcId) &&
constraint.ValueMm == 12.0);
}
[Fact]
@@ -37,7 +47,17 @@ public sealed class SerializationTests
"reference.png",
0.42,
Locked: false,
new ReferenceTransform(12.5, 20.0, 0.25, 0.25, 8.0));
new ReferenceTransform(12.5, 20.0, 0.25, 0.25, 8.0))
{
Calibration = new ReferenceCalibration(
TargetLineLengthMm: 30.0,
MeasuredLineLengthMm: 30.18,
ErrorMm: 0.18,
EstimatedAccuracyMm: 0.18,
ScaleCorrectionFactor: 30.0 / 30.18,
MeetsTargetAccuracy: false,
ControlLineDetected: true)
};
var json = SketchDocumentSerializer.Serialize(document);
var reloaded = SketchDocumentSerializer.Deserialize(json);
@@ -49,5 +69,9 @@ public sealed class SerializationTests
Assert.Equal(12.5, reloaded.Reference.Transform.OriginX, 9);
Assert.Equal(0.25, reloaded.Reference.Transform.ScaleX, 9);
Assert.Equal(8.0, reloaded.Reference.Transform.RotationDeg, 9);
Assert.NotNull(reloaded.Reference.Calibration);
Assert.Equal(30.0, reloaded.Reference.Calibration.TargetLineLengthMm, 9);
Assert.Equal(30.18, reloaded.Reference.Calibration.MeasuredLineLengthMm, 9);
Assert.False(reloaded.Reference.Calibration.MeetsTargetAccuracy);
}
}

View File

@@ -12,7 +12,69 @@ public sealed class SheetCalibratorTests
{
const double pixelsPerMillimetre = 4.0;
var template = ReferenceSheetTemplate.DefaultA4();
using var sheet = new Mat(
using var sheet = RenderSyntheticSheet(template, pixelsPerMillimetre);
var tempDirectory = Directory.CreateTempSubdirectory("easytrace-calibration-test-").FullName;
try
{
var imagePath = Path.Combine(tempDirectory, "sheet.png");
Cv2.ImWrite(imagePath, sheet);
var result = new SheetCalibrator().Calibrate(imagePath, tempDirectory, template);
Assert.True(result.Success, result.Message);
Assert.Equal(8, result.MatchedMarkerCount);
Assert.Equal(0.0625, result.MmPerPixel, 9);
Assert.Equal("perspective", result.CorrectionMode);
Assert.Null(result.Quality);
Assert.True(File.Exists(result.CorrectedImagePath));
}
finally
{
Directory.Delete(tempDirectory, recursive: true);
}
}
[Fact]
public void CalibrateAppliesResidualWarpForSyntheticLensDistortion()
{
const double pixelsPerMillimetre = 8.0;
var template = ReferenceSheetTemplate.DefaultA4();
using var sheet = RenderSyntheticSheet(template, pixelsPerMillimetre);
using var distorted = ApplyRadialDistortion(sheet, -0.025);
var tempDirectory = Directory.CreateTempSubdirectory("easytrace-distortion-test-").FullName;
try
{
var imagePath = Path.Combine(tempDirectory, "distorted-sheet.png");
Cv2.ImWrite(imagePath, distorted);
var result = new SheetCalibrator().Calibrate(
imagePath,
tempDirectory,
template,
SheetCalibrationOptions.Default with
{
PixelsPerMillimetre = 8.0,
MinimumResidualImprovementMm = -100.0
});
Assert.True(result.Success, result.Message);
Assert.True(result.MatchedMarkerCount >= 4);
Assert.True(
result.CorrectionMode.StartsWith("perspective + cross-validated residual warp", StringComparison.Ordinal),
$"{result.Message} mode {result.CorrectionMode}");
Assert.True(File.Exists(result.CorrectedImagePath));
}
finally
{
Directory.Delete(tempDirectory, recursive: true);
}
}
private static Mat RenderSyntheticSheet(ReferenceSheetTemplate template, double pixelsPerMillimetre)
{
var sheet = new Mat(
(int)Math.Round(template.HeightMm * pixelsPerMillimetre),
(int)Math.Round(template.WidthMm * pixelsPerMillimetre),
MatType.CV_8UC3,
@@ -38,23 +100,61 @@ public sealed class SheetCalibratorTests
markerColor.CopyTo(new Mat(sheet, target));
}
var tempDirectory = Directory.CreateTempSubdirectory("easytrace-calibration-test-").FullName;
try
{
var imagePath = Path.Combine(tempDirectory, "sheet.png");
Cv2.ImWrite(imagePath, sheet);
DrawControlGeometry(sheet, template, pixelsPerMillimetre);
return sheet;
}
var result = new SheetCalibrator().Calibrate(imagePath, tempDirectory, template);
private static void DrawControlGeometry(
Mat sheet,
ReferenceSheetTemplate template,
double pixelsPerMillimetre)
{
var thickness = Math.Max(1, (int)Math.Round(0.2 * pixelsPerMillimetre));
Cv2.Line(
sheet,
ToPixel(template.ReferenceLineStartMm, pixelsPerMillimetre),
ToPixel(template.ReferenceLineEndMm, pixelsPerMillimetre),
Scalar.Black,
thickness);
Assert.True(result.Success, result.Message);
Assert.Equal(8, result.MatchedMarkerCount);
Assert.Equal(0.125, result.MmPerPixel, 9);
Assert.Equal("perspective + marker residual warp", result.CorrectionMode);
Assert.True(File.Exists(result.CorrectedImagePath));
}
finally
var center = ToPixel(template.CrosshairMm, pixelsPerMillimetre);
var halfTick = (int)Math.Round(6.0 * pixelsPerMillimetre);
Cv2.Line(
sheet,
new Point(center.X, center.Y - halfTick),
new Point(center.X, center.Y + halfTick),
Scalar.Black,
thickness);
}
private static Point ToPixel(TraceCad.Core.Geometry.Point2 point, double pixelsPerMillimetre) =>
new(
(int)Math.Round(point.X * pixelsPerMillimetre),
(int)Math.Round(point.Y * pixelsPerMillimetre));
private static Mat ApplyRadialDistortion(Mat source, double coefficient)
{
using var mapX = new Mat(source.Height, source.Width, MatType.CV_32FC1);
using var mapY = new Mat(source.Height, source.Width, MatType.CV_32FC1);
var centerX = (source.Width - 1.0) / 2.0;
var centerY = (source.Height - 1.0) / 2.0;
var radius = Math.Min(centerX, centerY);
for (var y = 0; y < source.Height; y++)
{
Directory.Delete(tempDirectory, recursive: true);
for (var x = 0; x < source.Width; x++)
{
var normalizedX = (x - centerX) / radius;
var normalizedY = (y - centerY) / radius;
var r2 = (normalizedX * normalizedX) + (normalizedY * normalizedY);
var scale = 1.0 + (coefficient * r2);
mapX.Set(y, x, (float)(centerX + ((x - centerX) * scale)));
mapY.Set(y, x, (float)(centerY + ((y - centerY) * scale)));
}
}
var distorted = new Mat();
Cv2.Remap(source, distorted, mapX, mapY, InterpolationFlags.Linear, BorderTypes.Constant, Scalar.White);
return distorted;
}
}

View File

@@ -0,0 +1,60 @@
using TraceCad.Core.Commands;
using TraceCad.Core.Geometry;
using TraceCad.Core.Model;
using Xunit;
namespace TraceCad.Tests;
public sealed class TransformTests
{
[Fact]
public void RotateDocumentRotatesEntitiesAndReference()
{
var document = SketchDocument.CreateDefault();
document.AddEntity(new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(1, 0),
new Point2(1, 1)));
document.Reference = new ReferenceImage(
"reference.png",
0.5,
true,
new ReferenceTransform(1, 0, 0.125, 0.125, 0));
var command = new RotateDocumentCommand(90, Point2.Origin);
command.Apply(document);
var line = Assert.IsType<LineEntity>(document.Entities[0]);
Assert.Equal(0.0, line.Start.X, 9);
Assert.Equal(1.0, line.Start.Y, 9);
Assert.Equal(-1.0, line.End.X, 9);
Assert.Equal(1.0, line.End.Y, 9);
Assert.NotNull(document.Reference);
Assert.Equal(0.0, document.Reference.Transform.OriginX, 9);
Assert.Equal(1.0, document.Reference.Transform.OriginY, 9);
Assert.Equal(90.0, document.Reference.Transform.RotationDeg, 9);
}
[Fact]
public void RotateDocumentReverts()
{
var document = SketchDocument.CreateDefault();
var line = new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(1, 0),
new Point2(1, 1));
document.AddEntity(line);
var command = new RotateDocumentCommand(37, new Point2(5, 5));
command.Apply(document);
command.Revert(document);
var reverted = Assert.IsType<LineEntity>(document.Entities[0]);
Assert.Equal(line.Start.X, reverted.Start.X, 9);
Assert.Equal(line.Start.Y, reverted.Start.Y, 9);
Assert.Equal(line.End.X, reverted.End.X, 9);
Assert.Equal(line.End.Y, reverted.End.Y, 9);
}
}

View File

@@ -0,0 +1,58 @@
using TraceCad.Core.Geometry;
using TraceCad.Core.Model;
using TraceCad.Core.Validation;
using Xunit;
namespace TraceCad.Tests;
public sealed class ValidationTests
{
[Fact]
public void ValidatorReportsZeroLengthLineAsError()
{
var document = SketchDocument.CreateDefault();
document.AddEntity(new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(10, 10),
new Point2(10, 10)));
var issues = DrawingValidator.Validate(document);
Assert.Contains(issues, issue =>
issue.Severity == DrawingIssueSeverity.Error &&
issue.Code == "line.zero_length");
}
[Fact]
public void ValidatorReportsTinyLineAsWarning()
{
var document = SketchDocument.CreateDefault();
document.AddEntity(new LineEntity(
Guid.NewGuid(),
Layer.Cut.Name,
new Point2(0, 0),
new Point2(0.01, 0)));
var issues = DrawingValidator.Validate(document);
Assert.Contains(issues, issue =>
issue.Severity == DrawingIssueSeverity.Warning &&
issue.Code == "line.tiny");
}
[Fact]
public void ValidatorIgnoresOpenEndpointsOnNonExportableLayers()
{
var document = SketchDocument.CreateDefault();
document.AddEntity(new LineEntity(
Guid.NewGuid(),
Layer.Construction.Name,
new Point2(0, 0),
new Point2(100, 0)));
var issues = DrawingValidator.Validate(document);
Assert.DoesNotContain(issues, issue => issue.Code == "contour.open_endpoint");
}
}