diff --git a/OpenNest.Engine.Tests/Jobs/JobPartGeometryTests.cs b/OpenNest.Engine.Tests/Jobs/JobPartGeometryTests.cs new file mode 100644 index 0000000..bab2d48 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/JobPartGeometryTests.cs @@ -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(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(() => 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(() => JobPartGeometry.Read(snapshot)); + Assert.Null(JobPartGeometry.TryRead(snapshot)); + } +} diff --git a/OpenNest.Engine/Jobs/JobPartGeometry.cs b/OpenNest.Engine/Jobs/JobPartGeometry.cs new file mode 100644 index 0000000..b3a1101 --- /dev/null +++ b/OpenNest.Engine/Jobs/JobPartGeometry.cs @@ -0,0 +1,275 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Converters; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Jobs; + +/// +/// Material topology read once from a snapshot using the host validation rules. +/// The analytic shapes are owned by this instance and are mutable; clone them before transforming. +/// +public sealed class JobPartGeometry +{ + private const double Epsilon = 0.0000001; + + private JobPartGeometry(ShapeProfile profile) + { + Profile = profile; + MaterialArea = System.Math.Abs(Perimeter.Area()) + - Cutouts.Sum(shape => System.Math.Abs(shape.Area())); + } + + /// Analytic outer contour, clockwise to match the host CNC convention. + public Shape Perimeter => Profile.Perimeter; + + /// Closed cutouts, counterclockwise to match the host CNC convention. + public IReadOnlyList Cutouts => Profile.Cutouts; + + /// Material profile suitable for Clipper preparation; arcs are preserved. + public ShapeProfile Profile { get; } + + /// Absolute perimeter area less the absolute cutout areas at read time. + public double MaterialArea { get; } + + /// Unrotated material bounds; rapid and scribe/etch moves are excluded. + public Box Bounds => Perimeter.BoundingBox; + + /// Reads usable material, or returns null for an unreadable snapshot. + public static JobPartGeometry? TryRead(PartGeometrySnapshot geometry) + { + try + { + return Read(geometry); + } + catch (Exception exception) when (exception is ArgumentException + or InvalidOperationException or NotSupportedException or ArithmeticException) + { + return null; + } + } + + /// + /// Reads and validates closed material contours. Scribe/etch moves are ignored; + /// open cut marks must stay inside material and do not contribute to its area. + /// + /// Geometry has no usable closed material or invalid marks. + public static JobPartGeometry Read(PartGeometrySnapshot geometry) + { + ArgumentNullException.ThrowIfNull(geometry); + if (geometry.Motions.Count == 0 || geometry.Motions.Any(m => + !double.IsFinite(m.X) || !double.IsFinite(m.Y) + || !double.IsFinite(m.CenterX) || !double.IsFinite(m.CenterY))) + throw new ArgumentException("Geometry must contain finite motions.", nameof(geometry)); + var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(geometry)); + var cutEntities = new List(); + foreach (var entity in entities) + if (SpecialLayers.IsMaterial(entity.Layer)) + cutEntities.Add(entity); + + var contours = ShapeBuilder.GetShapes(cutEntities); + if (contours.Count == 0) + throw new ArgumentException("Geometry must contain a closed contour."); + var closedEntities = new List(); + var marks = new List(); + foreach (var contour in contours) + { + if (contour.IsClosed()) + { + ValidateContour(contour); + closedEntities.AddRange(contour.Entities); + } + else + marks.Add(contour); + } + if (closedEntities.Count == 0) + throw new ArgumentException("Geometry must contain a closed outer contour."); + + // ShapeProfile selects the outer profile, but does not validate containment and + // treats open chains as cutouts. Only validated closed contours may define material. + var profile = new ShapeProfile(closedEntities); + foreach (var cutout in profile.Cutouts) + ValidateInternalChain(cutout, profile.Perimeter, new List()); + foreach (var mark in marks) + ValidateMark(mark, profile.Perimeter, profile.Cutouts); + profile.NormalizeWinding(); + return new JobPartGeometry(profile); + } + + private static void ValidateMark(Shape mark, Shape perimeter, List holes) + { + const double chordTolerance = 0.00001; + var boundaries = new List { perimeter }; + boundaries.AddRange(holes); + var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance)); + foreach (var entity in mark.Entities) + { + if (entity.Length <= Epsilon || entity is not (Line or Arc)) + throw new ArgumentException("Unsupported or degenerate internal mark."); + var parameters = new List { 0, 1 }; + foreach (var boundary in boundaries) + { + entity.Intersects(boundary, out var intersections); + foreach (var point in intersections) + AddParameter(point); + // Include endpoints of coincident edges (parallel intersections may be empty). + foreach (var point in boundary.Entities.CollectPoints()) + if (entity.ClosestPointTo(point).DistanceTo(point) <= Epsilon) + AddParameter(point); + } + parameters.Sort(); + for (var index = 0; index < parameters.Count; index++) + { + Check(PointAt(parameters[index])); + if (index > 0) + Check(PointAt((parameters[index - 1] + parameters[index]) / 2)); + } + + void AddParameter(Vector point) + { + if (!point.IsValid()) + throw new ArgumentException("Indeterminate mark intersection."); + var value = entity is Line line + ? line.StartPoint.DistanceTo(point) / line.Length + : Angle.NormalizeRad( + ((Arc)entity).IsReversed + ? ((Arc)entity).StartAngle - ((Arc)entity).Center.AngleTo(point) + : ((Arc)entity).Center.AngleTo(point) - ((Arc)entity).StartAngle + ) / ((Arc)entity).SweepAngle(); + if (value >= 0 && value <= 1) + parameters.Add(value); + } + Vector PointAt(double value) + { + if (entity is Line line) + return line.StartPoint + (line.EndPoint - line.StartPoint) * value; + var arc = (Arc)entity; + var angle = arc.StartAngle + (arc.IsReversed ? -1 : 1) * arc.SweepAngle() * value; + return arc.Center + + new Vector(System.Math.Cos(angle), System.Math.Sin(angle)) * arc.Radius; + } + void Check(Vector point) + { + for (var index = 0; index < boundaries.Count; index++) + { + // Exact analytic boundary contact is allowed; near-boundary uncertainty is not. + var onBoundary = false; + foreach (var edge in boundaries[index].Entities) + if (edge.ClosestPointTo(point).DistanceTo(point) <= Epsilon) + onBoundary = true; + if (onBoundary) + continue; + foreach (var edge in polygons[index].ToLines()) + if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance) + throw new ArgumentException( + "Internal mark is too close to a material boundary." + ); + var inside = StrictlyInside(polygons[index], point); + if (index == 0 ? !inside : inside) + throw new ArgumentException( + "Open geometry leaves the closed material region." + ); + } + } + } + } + + private static void ValidateInternalChain(Shape chain, Shape perimeter, List holes) + { + // A connected analytic entity cannot leave material without crossing its boundary. + // Reject contact too: conservative, rather than guessing at tangent/collinear cuts. + // The witness point is farther than the polygonization error from every boundary. + const double chordTolerance = 0.00001; + var boundaries = new List { perimeter }; + boundaries.AddRange(holes); + var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance)); + foreach (var entity in chain.Entities) + { + if (entity.Length <= Epsilon) + throw new ArgumentException("Geometry contains a zero-length internal edge."); + var point = entity switch + { + Line line => line.StartPoint, + Arc arc => arc.StartPoint(), + Circle circle => circle.Center.Offset(circle.Radius, 0), + _ => throw new ArgumentException("Unsupported internal geometry."), + }; + if (!StrictlyInside(polygons[0], point)) + throw new ArgumentException( + "Open or disconnected geometry lies outside the closed perimeter." + ); + for (var index = 0; index < boundaries.Count; index++) + { + if (index > 0 && polygons[index].ContainsPoint(point)) + throw new ArgumentException("Internal geometry lies in a cutout."); + foreach (var edge in polygons[index].ToLines()) + if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance) + throw new ArgumentException( + "Internal geometry is too close to a material boundary." + ); + if (entity.Intersects(boundaries[index])) + throw new ArgumentException( + "Internal geometry crosses or touches a material boundary." + ); + } + } + } + + private static void ValidateContour(Shape contour) + { + if (!contour.IsClosed()) + throw new ArgumentException("Geometry must contain closed contours with usable edges."); + foreach (var entity in contour.Entities) + if (entity.Length <= Epsilon) + throw new ArgumentException("Geometry contains a zero-length edge."); + if (contour.Area() <= Epsilon) + throw new ArgumentException("Geometry must contain non-degenerate contours."); + } + + /// + /// Winding-number point-in-polygon. Returns false for points on an edge or vertex. + /// + private static bool StrictlyInside(Polygon polygon, Vector point) + { + var n = polygon.IsClosed() ? polygon.Vertices.Count - 1 : polygon.Vertices.Count; + if (n < 3) + return false; + var winding = 0; + for (var i = 0; i < n; i++) + { + var p1 = polygon.Vertices[i]; + var p2 = polygon.Vertices[(i + 1) % n]; + if (OnSegment(p1, p2, point)) + return false; + if (p1.Y <= point.Y) + { + if (p2.Y > point.Y && IsLeft(p1, p2, point) > 0) + winding++; + } + else if (p2.Y <= point.Y && IsLeft(p1, p2, point) < 0) + { + winding--; + } + } + return winding != 0; + } + + private static bool OnSegment(Vector a, Vector b, Vector p) + { + var cross = (b.X - a.X) * (p.Y - a.Y) - (b.Y - a.Y) * (p.X - a.X); + if (!cross.IsEqualTo(0.0)) + return false; + return System.Math.Min(a.X, b.X) - Epsilon <= p.X + && p.X <= System.Math.Max(a.X, b.X) + Epsilon + && System.Math.Min(a.Y, b.Y) - Epsilon <= p.Y + && p.Y <= System.Math.Max(a.Y, b.Y) + Epsilon; + } + + private static double IsLeft(Vector p1, Vector p2, Vector p) => + (p2.X - p1.X) * (p.Y - p1.Y) - (p2.Y - p1.Y) * (p.X - p1.X); + +} diff --git a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs index 2d92cd4..4d8fd78 100644 --- a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs +++ b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs @@ -1,11 +1,8 @@ using System; using System.Collections.Generic; -using OpenNest.Converters; -using OpenNest.Geometry; -using OpenNest.Math; - -using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Placement; +using OpenNest.Geometry; + namespace OpenNest.Engine.Jobs; /// Validates a trial against immutable job geometry before the runner commits accounting. @@ -80,176 +77,10 @@ internal static class NestJobPlacementValidator } } - internal static void ValidateGeometry(PartGeometrySnapshot geometry) - { - _ = CreateShape(geometry); - } - private static ShapeTopology CreateShape(PartGeometrySnapshot geometry) { - var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(geometry)); - var cutEntities = new List(); - foreach (var entity in entities) - if (SpecialLayers.IsMaterial(entity.Layer)) - cutEntities.Add(entity); - - var contours = ShapeBuilder.GetShapes(cutEntities); - if (contours.Count == 0) - throw new ArgumentException("Geometry must contain a closed contour."); - var closedEntities = new List(); - var marks = new List(); - foreach (var contour in contours) - { - if (contour.IsClosed()) - { - ValidateContour(contour); - closedEntities.AddRange(contour.Entities); - } - else - marks.Add(contour); - } - if (closedEntities.Count == 0) - throw new ArgumentException("Geometry must contain a closed outer contour."); - - // ShapeProfile selects the outer profile, but does not validate containment and - // treats open chains as cutouts. Only validated closed contours may define material. - var profile = new ShapeProfile(closedEntities); - foreach (var cutout in profile.Cutouts) - ValidateInternalChain(cutout, profile.Perimeter, new List()); - foreach (var mark in marks) - ValidateMark(mark, profile.Perimeter, profile.Cutouts); - profile.NormalizeWinding(); - return new ShapeTopology(profile.Perimeter, profile.Cutouts); - } - - private static void ValidateMark(Shape mark, Shape perimeter, List holes) - { - const double chordTolerance = 0.00001; - var boundaries = new List { perimeter }; - boundaries.AddRange(holes); - var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance)); - foreach (var entity in mark.Entities) - { - if (entity.Length <= Epsilon || entity is not (Line or Arc)) - throw new ArgumentException("Unsupported or degenerate internal mark."); - var parameters = new List { 0, 1 }; - foreach (var boundary in boundaries) - { - entity.Intersects(boundary, out var intersections); - foreach (var point in intersections) - AddParameter(point); - // Include endpoints of coincident edges (parallel intersections may be empty). - foreach (var point in boundary.Entities.CollectPoints()) - if (entity.ClosestPointTo(point).DistanceTo(point) <= Epsilon) - AddParameter(point); - } - parameters.Sort(); - for (var index = 0; index < parameters.Count; index++) - { - Check(PointAt(parameters[index])); - if (index > 0) - Check(PointAt((parameters[index - 1] + parameters[index]) / 2)); - } - - void AddParameter(Vector point) - { - if (!point.IsValid()) - throw new ArgumentException("Indeterminate mark intersection."); - var value = entity is Line line - ? line.StartPoint.DistanceTo(point) / line.Length - : Angle.NormalizeRad( - ((Arc)entity).IsReversed - ? ((Arc)entity).StartAngle - ((Arc)entity).Center.AngleTo(point) - : ((Arc)entity).Center.AngleTo(point) - ((Arc)entity).StartAngle - ) / ((Arc)entity).SweepAngle(); - if (value >= 0 && value <= 1) - parameters.Add(value); - } - Vector PointAt(double value) - { - if (entity is Line line) - return line.StartPoint + (line.EndPoint - line.StartPoint) * value; - var arc = (Arc)entity; - var angle = arc.StartAngle + (arc.IsReversed ? -1 : 1) * arc.SweepAngle() * value; - return arc.Center - + new Vector(System.Math.Cos(angle), System.Math.Sin(angle)) * arc.Radius; - } - void Check(Vector point) - { - for (var index = 0; index < boundaries.Count; index++) - { - // Exact analytic boundary contact is allowed; near-boundary uncertainty is not. - var onBoundary = false; - foreach (var edge in boundaries[index].Entities) - if (edge.ClosestPointTo(point).DistanceTo(point) <= Epsilon) - onBoundary = true; - if (onBoundary) - continue; - foreach (var edge in polygons[index].ToLines()) - if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance) - throw new ArgumentException( - "Internal mark is too close to a material boundary." - ); - var inside = StrictlyInside(polygons[index], point); - if (index == 0 ? !inside : inside) - throw new ArgumentException( - "Open geometry leaves the closed material region." - ); - } - } - } - } - - private static void ValidateInternalChain(Shape chain, Shape perimeter, List holes) - { - // A connected analytic entity cannot leave material without crossing its boundary. - // Reject contact too: conservative, rather than guessing at tangent/collinear cuts. - // The witness point is farther than the polygonization error from every boundary. - const double chordTolerance = 0.00001; - var boundaries = new List { perimeter }; - boundaries.AddRange(holes); - var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance)); - foreach (var entity in chain.Entities) - { - if (entity.Length <= Epsilon) - throw new ArgumentException("Geometry contains a zero-length internal edge."); - var point = entity switch - { - Line line => line.StartPoint, - Arc arc => arc.StartPoint(), - Circle circle => circle.Center.Offset(circle.Radius, 0), - _ => throw new ArgumentException("Unsupported internal geometry."), - }; - if (!StrictlyInside(polygons[0], point)) - throw new ArgumentException( - "Open or disconnected geometry lies outside the closed perimeter." - ); - for (var index = 0; index < boundaries.Count; index++) - { - if (index > 0 && polygons[index].ContainsPoint(point)) - throw new ArgumentException("Internal geometry lies in a cutout."); - foreach (var edge in polygons[index].ToLines()) - if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance) - throw new ArgumentException( - "Internal geometry is too close to a material boundary." - ); - if (entity.Intersects(boundaries[index])) - throw new ArgumentException( - "Internal geometry crosses or touches a material boundary." - ); - } - } - } - - private static void ValidateContour(Shape contour) - { - if (!contour.IsClosed()) - throw new ArgumentException("Geometry must contain closed contours with usable edges."); - foreach (var entity in contour.Entities) - if (entity.Length <= Epsilon) - throw new ArgumentException("Geometry contains a zero-length edge."); - if (contour.Area() <= Epsilon) - throw new ArgumentException("Geometry must contain non-degenerate contours."); + var shape = JobPartGeometry.Read(geometry); + return new ShapeTopology(shape.Perimeter, shape.Profile.Cutouts); } private static ShapeTopology Transform(ShapeTopology source, NestJobPlacement placement) @@ -308,48 +139,6 @@ internal static class NestJobPlacementValidator ); } - /// - /// Winding-number point-in-polygon. Returns false for points on an edge or vertex. - /// - private static bool StrictlyInside(Polygon polygon, Vector point) - { - var n = polygon.IsClosed() ? polygon.Vertices.Count - 1 : polygon.Vertices.Count; - if (n < 3) - return false; - var winding = 0; - for (var i = 0; i < n; i++) - { - var p1 = polygon.Vertices[i]; - var p2 = polygon.Vertices[(i + 1) % n]; - if (OnSegment(p1, p2, point)) - return false; - if (p1.Y <= point.Y) - { - if (p2.Y > point.Y && IsLeft(p1, p2, point) > 0) - winding++; - } - else if (p2.Y <= point.Y && IsLeft(p1, p2, point) < 0) - { - winding--; - } - } - return winding != 0; - } - - private static bool OnSegment(Vector a, Vector b, Vector p) - { - var cross = (b.X - a.X) * (p.Y - a.Y) - (b.Y - a.Y) * (p.X - a.X); - if (!cross.IsEqualTo(0.0)) - return false; - return System.Math.Min(a.X, b.X) - Epsilon <= p.X - && p.X <= System.Math.Max(a.X, b.X) + Epsilon - && System.Math.Min(a.Y, b.Y) - Epsilon <= p.Y - && p.Y <= System.Math.Max(a.Y, b.Y) + Epsilon; - } - - private static double IsLeft(Vector p1, Vector p2, Vector p) => - (p2.X - p1.X) * (p.Y - p1.Y) - (p2.Y - p1.Y) * (p.X - p1.X); - private static double BoundsDistance(Box left, Box right) { var x = System.Math.Max(0, System.Math.Max(left.Left - right.Right, right.Left - left.Right)); diff --git a/OpenNest.Engine/Jobs/NestJobValidator.cs b/OpenNest.Engine/Jobs/NestJobValidator.cs index 694d518..98c8c79 100644 --- a/OpenNest.Engine/Jobs/NestJobValidator.cs +++ b/OpenNest.Engine/Jobs/NestJobValidator.cs @@ -48,7 +48,7 @@ public static class NestJobValidator ); try { - NestJobPlacementValidator.ValidateGeometry(part.Geometry); + _ = JobPartGeometry.Read(part.Geometry); } catch (ArgumentException exception) {