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:
aj
2026-09-20 15:22:46 -04:00
parent 1a05391d94
commit 589d341455
14 changed files with 589 additions and 22 deletions
+107
View File
@@ -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); }
}
}
+154
View File
@@ -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>