feat(engine): expose JobPartGeometry for reading snapshot material

Every plugin engine rebuilt part material from a snapshot by hand and
filtered only rapids, so all three kept counting scribe/etch marks as
material after 1b5e1b1 fixed it in the host. JobPartGeometry is the
validator's own reader made public: SpecialLayers.IsMaterial, validated
closed contours, material area, and TryRead returning null for unreadable
parts. The job validators now use it, so engines and validation agree.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-25 08:12:07 -04:00
co-authored by Codex Claude Opus 5.5
parent 6ab45e6de7
commit ae2b0beb45
4 changed files with 414 additions and 216 deletions
@@ -0,0 +1,134 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class JobPartGeometryTests
{
[Fact]
public void RapidsAndEtchAreExcludedFromBoundsAndArea()
{
var program = TestDrawingFactory.Rectangle(10, 10);
program.MoveTo(9.5, 5);
program.Codes.Add(new LinearMove(14, 5) { Layer = LayerType.Scribe });
program.MoveTo(100, 100);
var geometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(program));
Assert.Equal(100, geometry.MaterialArea);
Assert.Equal(0, geometry.Bounds.Left);
Assert.Equal(0, geometry.Bounds.Bottom);
Assert.Equal(10, geometry.Bounds.Right);
Assert.Equal(10, geometry.Bounds.Top);
Assert.Empty(geometry.Cutouts);
Assert.All(geometry.Perimeter.Entities, e => Assert.True(SpecialLayers.IsMaterial(e.Layer)));
}
[Fact]
public void EtchTickCrossingIntoNotchIsIgnored()
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(10, 0);
program.LineTo(10, 4);
program.LineTo(6, 4);
program.LineTo(6, 6);
program.LineTo(10, 6);
program.LineTo(10, 10);
program.LineTo(0, 10);
program.LineTo(0, 0);
program.MoveTo(5, 5);
program.Codes.Add(new LinearMove(8, 5) { Layer = LayerType.Scribe });
var geometry = JobPartGeometry.TryRead(PartGeometrySnapshot.FromProgram(program));
Assert.NotNull(geometry);
Assert.Equal(92, geometry.MaterialArea);
Assert.Equal(8, geometry.Perimeter.Entities.Count);
Assert.Empty(geometry.Cutouts);
}
[Fact]
public void MapperRoundTripPreservesHoleAreaAndHostWinding()
{
var program = TestDrawingFactory.Rectangle(10, 10);
program.MoveTo(3, 3);
program.LineTo(7, 3);
program.LineTo(7, 7);
program.LineTo(3, 7);
program.LineTo(3, 3);
var part = DrawingJobMapper.FromDrawing("frame", new Drawing("frame", program), 1);
var motions = part.Geometry.Motions.ToArray();
var geometry = JobPartGeometry.Read(part.Geometry);
Assert.Equal(84, geometry.MaterialArea);
Assert.Equal(16, Assert.Single(geometry.Cutouts).Area());
Assert.Same(geometry.Perimeter, geometry.Profile.Perimeter);
Assert.Equal(RotationType.CW, geometry.Perimeter.ToPolygon().RotationDirection());
Assert.Equal(RotationType.CCW, geometry.Cutouts[0].ToPolygon().RotationDirection());
Assert.Equal(motions, part.Geometry.Motions);
}
[Fact]
public void AnalyticCircleIsPreserved()
{
var program = new Program();
program.MoveTo(5, 0);
program.Codes.Add(new ArcMove(5, 0, 0, 0, RotationType.CW));
var geometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(program));
Assert.IsType<Circle>(Assert.Single(geometry.Perimeter.Entities));
Assert.Equal(25 * System.Math.PI, geometry.MaterialArea, 10);
}
[Fact]
public void OpenCutLeavingMaterialIsUnusableButInternalMarkDoesNotChangeArea()
{
var program = TestDrawingFactory.Rectangle(10, 10);
program.MoveTo(5, 5);
program.LineTo(7, 5);
Assert.Equal(100, JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(program)).MaterialArea);
program.LineTo(11, 5);
var snapshot = PartGeometrySnapshot.FromProgram(program);
var error = Assert.Throws<ArgumentException>(() => JobPartGeometry.Read(snapshot));
Assert.Contains("Open geometry leaves the closed material region", error.Message);
Assert.Null(JobPartGeometry.TryRead(snapshot));
}
[Theory]
[InlineData("empty")]
[InlineData("open")]
[InlineData("zero-length")]
[InlineData("non-finite")]
[InlineData("rapid-only")]
[InlineData("scribe-only")]
public void UnusableSnapshotsReturnNullWithoutThrowing(string kind)
{
var program = new Program();
if (kind == "open")
{
program.MoveTo(0, 0);
program.LineTo(1, 1);
}
if (kind == "zero-length")
{
program = TestDrawingFactory.Rectangle(10, 10);
program.LineTo(0, 0);
}
if (kind == "non-finite")
program.LineTo(double.NaN, 0);
if (kind == "rapid-only")
program.MoveTo(100, 100);
if (kind == "scribe-only")
program.Codes.Add(new LinearMove(1, 1) { Layer = LayerType.Scribe });
var snapshot = PartGeometrySnapshot.FromProgram(program);
Assert.Throws<ArgumentException>(() => JobPartGeometry.Read(snapshot));
Assert.Null(JobPartGeometry.TryRead(snapshot));
}
}