feat(io): conservative opt-in bend repair with tests and console CLI
Add OpenNest.IO/Bending/BendRepair: opt-in repair of unambiguous paired ETCH/SCRIBE bend ticks, bounded to <=3.175 mm endpoint movement with explicit source units. Cut geometry is never modified. - CadImportOptions.BendRepair configures it; CadImportResult exposes per-bend BendRepairReports; CadImporter/Dxf wire it into import. - Console: --repair-bends-mm <limit> --cad-units inches|mm prints per-bend reports for newly imported DXFs. - New OpenNest.IO.Tests project (net8.0, synthetic DXFs, 30 tests) covering bend detection and repair, added to the solution. - Update README.md and CLAUDE.md for the new pipeline and build/test instructions.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
using ACadSharp;
|
||||
using ACadSharp.IO;
|
||||
using CSMath;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO.Bending;
|
||||
using CadLine = ACadSharp.Entities.Line;
|
||||
using CadLayer = ACadSharp.Tables.Layer;
|
||||
|
||||
namespace OpenNest.IO.Tests;
|
||||
|
||||
public class BendRepairImportTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ETCH", false, false, "Repaired")]
|
||||
[InlineData("SCRIBE", false, false, "Repaired")]
|
||||
[InlineData("ETCH", true, false, "Skipped")]
|
||||
[InlineData("ETCH", false, true, "Skipped")]
|
||||
public void ImportPreservesMarksAndHonorsAmbiguityAndHeader(string layer, bool duplicate, bool conflict, string status)
|
||||
{
|
||||
var doc = Fixture(layer);
|
||||
if (duplicate) doc.Entities.Add(new CadLine(new XYZ(0.05, 5, 0), new XYZ(0.55, 5, 0)) { Layer = new CadLayer(layer) });
|
||||
if (conflict) doc.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Millimeters;
|
||||
WithFile(doc, path =>
|
||||
{
|
||||
var raw = Dxf.Import(path, preserveRepairMarks: true);
|
||||
var result = CadImporter.Import(path, new CadImportOptions { BendRepair = Options() });
|
||||
Assert.Equal(status, Assert.Single(result.BendRepairReports).Status);
|
||||
Assert.Equal(raw.Entities.Count, result.Entities.Count);
|
||||
Assert.Equal(Signatures(raw.Entities.Where(e => e.Layer.Name == "0")), Signatures(result.Entities.Where(e => e.Layer.Name == "0")));
|
||||
Assert.Contains(result.Entities.OfType<Line>(), l => l.StartPoint == new Vector(4, 2) && l.EndPoint == new Vector(4, 3));
|
||||
if (status == "Skipped") Assert.Equal(Signatures(raw.Entities), Signatures(result.Entities));
|
||||
else
|
||||
{
|
||||
Assert.Equal(new Vector(0, 5), result.Bends[0].StartPoint);
|
||||
Assert.Equal(new Vector(10, 5), result.Bends[0].EndPoint);
|
||||
Assert.Equal("Unchanged", Assert.Single(BendRepair.Apply(result.Entities, result.Bends, Options())).Status);
|
||||
}
|
||||
Assert.Empty(CadImporter.Import(path).BendRepairReports);
|
||||
var disabled = CadImporter.Import(path, new CadImportOptions { DetectBends = false, BendRepair = Options() });
|
||||
Assert.Empty(disabled.Bends);
|
||||
Assert.Equal(Signatures(raw.Entities), Signatures(disabled.Entities));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnitlessHeaderRequiresExplicitCallerUnits()
|
||||
{
|
||||
var doc = Fixture("ETCH");
|
||||
doc.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Unitless;
|
||||
WithFile(doc, path =>
|
||||
{
|
||||
var configured = CadImporter.Import(path, new CadImportOptions { BendRepair = Options() });
|
||||
Assert.Equal("Repaired", Assert.Single(configured.BendRepairReports).Status);
|
||||
var unspecified = CadImporter.Import(path, new CadImportOptions { BendRepair = new BendRepairOptions { MaxEndpointMovementMillimeters = 2 } });
|
||||
Assert.Equal("Skipped", Assert.Single(unspecified.BendRepairReports).Status);
|
||||
Assert.Equal(Signatures(Dxf.Import(path, true).Entities), Signatures(unspecified.Entities));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkCircleDoesNotDeduplicateCutCircle()
|
||||
{
|
||||
var doc = Fixture("SCRIBE");
|
||||
doc.Entities.Add(new ACadSharp.Entities.Circle { Center = new XYZ(2, 2, 0), Radius = 0.2, Layer = new CadLayer("SCRIBE") });
|
||||
doc.Entities.Add(new ACadSharp.Entities.Circle { Center = new XYZ(2, 2, 0), Radius = 0.2 });
|
||||
WithFile(doc, path =>
|
||||
{
|
||||
var result = CadImporter.Import(path, new CadImportOptions { BendRepair = Options() });
|
||||
Assert.Equal(2, result.Entities.OfType<Circle>().Count());
|
||||
Assert.Single(result.Entities.OfType<Circle>().Where(e => e.Layer.Name == "0"));
|
||||
Assert.Single(result.Entities.OfType<Circle>().Where(e => e.Layer.Name == "SCRIBE"));
|
||||
});
|
||||
}
|
||||
|
||||
private static BendRepairOptions Options() => new() { DrawingUnits = BendRepairUnits.Inches, MaxEndpointMovementMillimeters = 2 };
|
||||
|
||||
private static CadDocument Fixture(string layer)
|
||||
{
|
||||
var doc = new CadDocument();
|
||||
doc.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Inches;
|
||||
foreach (var line in new[] {
|
||||
new CadLine(new XYZ(0, 0, 0), new XYZ(10, 0, 0)),
|
||||
new CadLine(new XYZ(10, 0, 0), new XYZ(10, 10, 0)),
|
||||
new CadLine(new XYZ(10, 10, 0), new XYZ(0, 10, 0)),
|
||||
new CadLine(new XYZ(0, 10, 0), new XYZ(0, 0, 0)),
|
||||
new CadLine(new XYZ(0.05, 5, 0), new XYZ(0.55, 5, 0)) { Layer = new CadLayer(layer) },
|
||||
new CadLine(new XYZ(9.45, 5, 0), new XYZ(9.95, 5, 0)) { Layer = new CadLayer(layer) },
|
||||
new CadLine(new XYZ(4, 2, 0), new XYZ(4, 3, 0)) { Layer = new CadLayer(layer) },
|
||||
new CadLine(new XYZ(0.05, 5, 0), new XYZ(9.95, 5, 0)) { Layer = new CadLayer("BEND"), LineType = new ACadSharp.Tables.LineType("CENTER") }
|
||||
}) doc.Entities.Add(line);
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static string[] Signatures(IEnumerable<Entity> entities) => entities.OfType<Line>()
|
||||
.Select(l => $"{l.Layer.Name}:{l.StartPoint}:{l.EndPoint}:{l.LineTypeName}").Order().ToArray();
|
||||
|
||||
private static void WithFile(CadDocument doc, Action<string> action)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"opennest-repair-{Guid.NewGuid()}.dxf");
|
||||
try
|
||||
{
|
||||
DxfWriter.Write(path, doc, false);
|
||||
action(path);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO.Bending;
|
||||
|
||||
namespace OpenNest.IO.Tests;
|
||||
|
||||
public class BendRepairTests
|
||||
{
|
||||
private static BendRepairOptions Options(BendRepairUnits units = BendRepairUnits.Inches, double limit = 2) =>
|
||||
new() { DrawingUnits = units, MaxEndpointMovementMillimeters = limit };
|
||||
|
||||
private static (List<Entity> entities, List<Bend> bends) Fixture(double start = 0.05, double end = 9.95)
|
||||
{
|
||||
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)),
|
||||
Mark(start, 5, start + 0.5, 5), Mark(end - 0.5, 5, end, 5),
|
||||
Mark(4, 2, 4, 3)
|
||||
};
|
||||
return (entities, new List<Bend> { new() { StartPoint = new Vector(start, 5), EndPoint = new Vector(end, 5), Direction = BendDirection.Up } });
|
||||
}
|
||||
|
||||
private static Line Mark(double x, double y, double x2, double y2) =>
|
||||
new(new Vector(x, y), new Vector(x2, y2)) { Layer = new Layer("SCRIBE") { IsVisible = true } };
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.05, 9.95)]
|
||||
[InlineData(-0.05, 10.05)]
|
||||
[InlineData(0, 9.95)]
|
||||
public void RepairsAlongAxisPreservingCutAndUnrelatedMarksAndIsIdempotent(double start, double end)
|
||||
{
|
||||
var (entities, bends) = Fixture(start, end);
|
||||
var originals = entities.ToArray();
|
||||
var cutPoints = entities.Take(4).Cast<Line>().Select(l => (l.StartPoint, l.EndPoint)).ToArray();
|
||||
var report = Assert.Single(BendRepair.Apply(entities, bends, Options()));
|
||||
Assert.Equal("Repaired", report.Status);
|
||||
Assert.Equal(new Vector(0, 5), bends[0].StartPoint);
|
||||
Assert.Equal(new Vector(10, 5), bends[0].EndPoint);
|
||||
for (var i = 0; i < 4; i++) Assert.Same(originals[i], entities[i]);
|
||||
Assert.Equal(cutPoints, entities.Take(4).Cast<Line>().Select(l => (l.StartPoint, l.EndPoint)).ToArray());
|
||||
Assert.Same(originals[6], entities[6]);
|
||||
Assert.Equal(new Vector(0, 5), ((Line)entities[4]).StartPoint);
|
||||
Assert.Equal(new Vector(10, 5), ((Line)entities[5]).EndPoint);
|
||||
Assert.Equal(0.5, entities[4].Length, 8);
|
||||
var after = entities.ToArray();
|
||||
Assert.Equal("Unchanged", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status);
|
||||
Assert.Equal(after, entities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneInchTicksAreAcceptedAtPhysicalLengthCap()
|
||||
{
|
||||
var (entities, bends) = Fixture();
|
||||
entities[4] = Mark(0.05, 5, 1.05, 5);
|
||||
entities[5] = Mark(8.95, 5, 9.95, 5);
|
||||
Assert.Equal("Repaired", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status);
|
||||
Assert.Equal("Unchanged", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MillimeterCoordinatesUseSamePhysicalLimit()
|
||||
{
|
||||
var (entities, bends) = Fixture();
|
||||
foreach (var entity in entities) entity.Scale(25.4);
|
||||
bends[0].StartPoint *= 25.4;
|
||||
bends[0].EndPoint *= 25.4;
|
||||
Assert.Equal("Repaired", Assert.Single(BendRepair.Apply(entities, bends, Options(BendRepairUnits.Millimeters))).Status);
|
||||
Assert.Equal(254, bends[0].EndPoint.X, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotatedAxisIsNotRotatedByRepair()
|
||||
{
|
||||
var (entities, bends) = Fixture();
|
||||
foreach (var entity in entities) entity.Rotate(0.7);
|
||||
var axis = bends[0].ToLine();
|
||||
axis.Rotate(0.7);
|
||||
bends[0].StartPoint = axis.StartPoint;
|
||||
bends[0].EndPoint = axis.EndPoint;
|
||||
Assert.Equal("Repaired", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status);
|
||||
Assert.Equal(0.7, bends[0].LineAngle, 8);
|
||||
Assert.Equal(10, bends[0].Length, 8);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("missing")]
|
||||
[InlineData("duplicate")]
|
||||
[InlineData("perpendicular")]
|
||||
[InlineData("offset")]
|
||||
[InlineData("excessive")]
|
||||
[InlineData("open")]
|
||||
[InlineData("hole")]
|
||||
[InlineData("shared")]
|
||||
[InlineData("unknown-layer")]
|
||||
[InlineData("cut-tick")]
|
||||
[InlineData("nonfinite")]
|
||||
public void SafetyFailuresAreAtomic(string failure)
|
||||
{
|
||||
var (entities, bends) = Fixture();
|
||||
switch (failure)
|
||||
{
|
||||
case "missing": entities.RemoveAt(5); break;
|
||||
case "duplicate": entities.Add(entities[4].Clone()); break;
|
||||
case "perpendicular": entities[5] = Mark(9.95, 5, 9.95, 5.5); break;
|
||||
case "offset": entities[5].Offset(0, 0.01); break;
|
||||
case "excessive": bends[0].EndPoint = new Vector(9, 5); entities[5] = Mark(8.5, 5, 9, 5); break;
|
||||
case "open": entities.RemoveAt(0); break;
|
||||
case "hole": entities.Add(new Circle(new Vector(5, 5), 1)); break;
|
||||
case "shared": bends.Add(new Bend { StartPoint = bends[0].StartPoint, EndPoint = bends[0].EndPoint }); break;
|
||||
case "unknown-layer": foreach (var e in entities.Take(4)) e.Layer = new Layer("UNKNOWN"); break;
|
||||
case "cut-tick": entities[5].Layer = Layer.Default; break;
|
||||
case "nonfinite": bends[0].StartPoint = new Vector(double.NaN, 5); break;
|
||||
}
|
||||
var before = entities.ToArray();
|
||||
var start = bends[0].StartPoint;
|
||||
var end = bends[0].EndPoint;
|
||||
var reports = BendRepair.Apply(entities, bends, Options());
|
||||
Assert.All(reports, r => Assert.Equal("Skipped", r.Status));
|
||||
Assert.Equal(before, entities);
|
||||
Assert.Equal(start.X, bends[0].StartPoint.X);
|
||||
Assert.Equal(start.Y, bends[0].StartPoint.Y);
|
||||
Assert.Equal(end, bends[0].EndPoint);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(BendRepairUnits.Unspecified, 2)]
|
||||
[InlineData(BendRepairUnits.Inches, 0)]
|
||||
[InlineData(BendRepairUnits.Inches, -1)]
|
||||
[InlineData(BendRepairUnits.Inches, 3.176)]
|
||||
[InlineData(BendRepairUnits.Inches, double.NaN)]
|
||||
[InlineData(BendRepairUnits.Inches, double.PositiveInfinity)]
|
||||
public void InvalidConfigurationDoesNotMutate(BendRepairUnits units, double limit)
|
||||
{
|
||||
var (entities, bends) = Fixture();
|
||||
var before = entities.ToArray();
|
||||
Assert.Equal("Skipped", Assert.Single(BendRepair.Apply(entities, bends, Options(units, limit))).Status);
|
||||
Assert.Equal(before, entities);
|
||||
Assert.Equal(0.05, bends[0].StartPoint.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultsOff()
|
||||
{
|
||||
Assert.Null(CadImportOptions.Default.BendRepair);
|
||||
var (entities, bends) = Fixture();
|
||||
var before = entities.ToArray();
|
||||
Assert.Empty(BendRepair.Apply(entities, bends, null));
|
||||
Assert.Equal(before, entities);
|
||||
Assert.Equal(0.05, bends[0].StartPoint.X);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
<Using Include="Xunit" />
|
||||
<ProjectReference Include="../OpenNest.IO/OpenNest.IO.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user