From 1b5e1b14a61c079f15afdee06793b3bea15b5d4f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 25 Sep 2026 06:44:29 -0400 Subject: [PATCH] fix: leave etch/scribe marks out of nesting geometry Every nesting-geometry consumer filtered only rapids, so scribe/etch moves counted as part material. An etch tick that ends a hair outside the outline (PEP bend ticks start on the notch edge) made the part "open geometry leaving the material region": the job validator threw and every built-in engine plus Gpt6Astra crashed on real PEP jobs (PT75, drawing 4980 A01 PT77). Marks are only on the surface, so they should never affect placement, collision, area, or validation. - SpecialLayers.IsMaterial excludes Rapid and Scribe; used by drawing area, canonical angle, part collision, PartGeometry, plate perimeter, best-fit/pair evaluation, rotation analysis, GPU evaluators, and both validators. Timing, display, splitting and posts still see marks. - ConvertGeometry also maps the saved SCRIBE layer name to Scribe, so programs rebuilt from stored entities keep their marks. - NestReader repairs older files (e.g. PepNestExport output) whose programs saved etch as cut moves while source entities kept SCRIBE. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 +- OpenNest.Benchmark/NestValidator.cs | 2 +- OpenNest.Core/CanonicalAngle.cs | 2 +- OpenNest.Core/Converters/ConvertGeometry.cs | 7 +- OpenNest.Core/Converters/ScribeLayerRepair.cs | 94 +++++++++++++++++++ OpenNest.Core/Drawing.cs | 2 +- OpenNest.Core/Part.cs | 4 +- OpenNest.Core/PartGeometry.cs | 14 +-- OpenNest.Core/Plate.cs | 2 +- OpenNest.Core/SpecialLayers.cs | 7 ++ .../Jobs/NestJobValidationTests.cs | 35 +++++++ OpenNest.Engine/BestFit/BestFitFinder.cs | 2 +- OpenNest.Engine/BestFit/BestFitResult.cs | 2 +- OpenNest.Engine/BestFit/PairEvaluator.cs | 6 +- OpenNest.Engine/BestFit/PolygonHelper.cs | 2 +- OpenNest.Engine/Fill/RotationAnalysis.cs | 4 +- .../Jobs/NestJobPlacementValidator.cs | 2 +- OpenNest.Engine/ML/FeatureExtractor.cs | 2 +- OpenNest.Engine/PartClassifier.cs | 2 +- OpenNest.Gpu/GpuPairEvaluator.cs | 2 +- OpenNest.Gpu/PartBitmap.cs | 4 +- OpenNest.IO/NestReader.cs | 12 +++ .../Converters/ConvertGeometryLayerTests.cs | 12 +++ .../Converters/ScribeLayerRepairTests.cs | 79 ++++++++++++++++ 24 files changed, 273 insertions(+), 30 deletions(-) create mode 100644 OpenNest.Core/Converters/ScribeLayerRepair.cs create mode 100644 OpenNest.Tests/Converters/ScribeLayerRepairTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index aa637fe..fc4750c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,9 +137,10 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe - `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations. - Nesting uses async progress/cancellation: `IProgress` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`. - **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed with endpoints kept on the arc, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `NestValidator` uses `OffsetForValidation` instead: the same flattening with fine joins and no padding, so a layout exactly at the spacing passes. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. Clipper is allowed only for cached CPU preparation, never in per-pair hot loops. +- **Marks are not material**: scribe/etch moves are marked on the surface, never cut through, so they are left out of nesting. `SpecialLayers.IsMaterial(layer)` (excludes `Rapid` and `Scribe`) is the filter for every consumer that builds part material from a program: drawing area, canonical angle, part collision, `PartGeometry`, plate perimeters, best-fit/pair evaluation, rotation analysis, the GPU evaluators, and both validators (`NestJobPlacementValidator`, benchmark `NestValidator`). Cutting time, on-screen display, splitting, and post-processors still see marks. Older `.nest` files (e.g. `tools/PepNestExport` output) saved etch as cut moves while their source entities kept the `SCRIBE` layer; `NestReader` runs `ScribeLayerRepair` on load to move matching program moves back to `Scribe`. - `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes. - `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. - **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load). - **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity. - **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). MCP, API, and Training projects use `ImportDrawing` for headless conversion. The console uses `Import` followed by `BuildDrawing` so it can report bend-repair outcomes. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata. -- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth). +- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` and the saved `SCRIBE` layer (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth). diff --git a/OpenNest.Benchmark/NestValidator.cs b/OpenNest.Benchmark/NestValidator.cs index 5771059..5d81996 100644 --- a/OpenNest.Benchmark/NestValidator.cs +++ b/OpenNest.Benchmark/NestValidator.cs @@ -339,7 +339,7 @@ namespace OpenNest.Benchmark { var entities = ConvertProgram .ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) .ToList(); if (entities.Count == 0) diff --git a/OpenNest.Core/CanonicalAngle.cs b/OpenNest.Core/CanonicalAngle.cs index be0898c..0fa9733 100644 --- a/OpenNest.Core/CanonicalAngle.cs +++ b/OpenNest.Core/CanonicalAngle.cs @@ -52,7 +52,7 @@ namespace OpenNest var entities = ConvertProgram .ToGeometry(drawing.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); + .Where(e => SpecialLayers.IsMaterial(e.Layer)); var shapes = ShapeBuilder.GetShapes(entities); if (shapes.Count == 0) diff --git a/OpenNest.Core/Converters/ConvertGeometry.cs b/OpenNest.Core/Converters/ConvertGeometry.cs index fcbcf7e..c740fe0 100644 --- a/OpenNest.Core/Converters/ConvertGeometry.cs +++ b/OpenNest.Core/Converters/ConvertGeometry.cs @@ -139,14 +139,17 @@ namespace OpenNest.Converters return lastpt; } - // Engrave/etch geometry maps to Scribe so the post processor can treat it as a - // separate tool pass; everything else keeps the move's default Cut layer. + // Engrave/etch/scribe geometry maps to Scribe so the post processor can treat it as a + // separate tool pass; everything else keeps the move's default Cut layer. SCRIBE is the + // name marks carry once saved (SpecialLayers.Scribe), so drawings rebuilt from stored + // entities must map it too or their marks silently become cut moves. private static LayerType ClassifyLayer(Entity geo) { var name = geo.Layer?.Name; if ( string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase) || string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase) + || string.Equals(name, SpecialLayers.Scribe.Name, System.StringComparison.OrdinalIgnoreCase) ) return LayerType.Scribe; diff --git a/OpenNest.Core/Converters/ScribeLayerRepair.cs b/OpenNest.Core/Converters/ScribeLayerRepair.cs new file mode 100644 index 0000000..00dd503 --- /dev/null +++ b/OpenNest.Core/Converters/ScribeLayerRepair.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Converters +{ + /// + /// Restores the Scribe layer on program moves that were saved as cuts. Programs built from + /// stored entities before ConvertGeometry recognized the SCRIBE layer name turned etch marks + /// into Cut moves, which nesting then treated as open cut geometry. The drawing's source + /// entities still carry the mark layer, so matching moves are reclassified from them. + /// + public static class ScribeLayerRepair + { + private const double MatchTolerance = 0.001; + + /// + /// Reclassifies Cut moves in that lie on a mark entity in + /// . Program coordinates are source coordinates shifted + /// by -. Returns the number of moves reclassified. + /// + public static int Apply(Program program, IEnumerable sourceEntities, Vector sourceOffset) + { + if (program == null || sourceEntities == null) + return 0; + + var marks = sourceEntities.Where(e => IsMarkLayer(e.Layer)).ToList(); + if (marks.Count == 0 || program.Codes.Any(c => c is SubProgramCall)) + return 0; + + // ToGeometry emits exactly one entity per rapid/linear/arc move of a flat program. + var motions = program.Codes.Where(c => c is RapidMove or LinearMove or ArcMove).ToList(); + var geometry = ConvertProgram.ToGeometry(program); + if (geometry.Count != motions.Count) + return 0; + + var repaired = 0; + for (var i = 0; i < motions.Count; i++) + { + var source = Translate(geometry[i], sourceOffset); + switch (motions[i]) + { + case LinearMove line when line.Layer == LayerType.Cut && IsOnMark(source, marks): + line.Layer = LayerType.Scribe; + repaired++; + break; + case ArcMove arc when arc.Layer == LayerType.Cut && IsOnMark(source, marks): + arc.Layer = LayerType.Scribe; + repaired++; + break; + } + } + return repaired; + } + + public static bool IsMarkLayer(Layer layer) => + layer != null + && ( + layer == SpecialLayers.Scribe + || string.Equals(layer.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase) + || string.Equals(layer.Name, "ETCH", StringComparison.OrdinalIgnoreCase) + || string.Equals(layer.Name, "ENGRAVE", StringComparison.OrdinalIgnoreCase) + ); + + private static List Translate(Entity entity, Vector offset) + { + var points = entity switch + { + Line l => new List + { + l.StartPoint, + l.EndPoint, + new Vector((l.StartPoint.X + l.EndPoint.X) / 2, (l.StartPoint.Y + l.EndPoint.Y) / 2), + }, + Arc a => new List { a.StartPoint(), a.EndPoint(), a.MidPoint() }, + Circle c => new List + { + new Vector(c.Center.X + c.Radius, c.Center.Y), + new Vector(c.Center.X - c.Radius, c.Center.Y), + new Vector(c.Center.X, c.Center.Y + c.Radius), + }, + _ => new List(), + }; + return points.ConvertAll(p => new Vector(p.X + offset.X, p.Y + offset.Y)); + } + + // A move is a mark when every sample point lies on one single mark entity. + private static bool IsOnMark(List points, List marks) => + points.Count > 0 + && marks.Any(m => points.All(p => m.ClosestPointTo(p).DistanceTo(p) <= MatchTolerance)); + } +} diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs index 54c8b55..bb77d72 100644 --- a/OpenNest.Core/Drawing.cs +++ b/OpenNest.Core/Drawing.cs @@ -125,7 +125,7 @@ namespace OpenNest { var geometry = ConvertProgram .ToGeometry(Program) - .Where(entity => entity.Layer != SpecialLayers.Rapid); + .Where(entity => SpecialLayers.IsMaterial(entity.Layer)); var shapes = ShapeBuilder.GetShapes(geometry); if (shapes.Count == 0) diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index ba39537..d1c6199 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -239,11 +239,11 @@ namespace OpenNest var entities1 = ConvertProgram .ToGeometry(Program) - .Where(e => e.Layer != SpecialLayers.Rapid) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) .ToList(); var entities2 = ConvertProgram .ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) .ToList(); if (entities1.Count == 0 || entities2.Count == 0) diff --git a/OpenNest.Core/PartGeometry.cs b/OpenNest.Core/PartGeometry.cs index d81f777..7ead820 100644 --- a/OpenNest.Core/PartGeometry.cs +++ b/OpenNest.Core/PartGeometry.cs @@ -11,7 +11,7 @@ namespace OpenNest { var entities = ConvertProgram.ToGeometry(part.Program); var shapes = ShapeBuilder.GetShapes( - entities.Where(e => e.Layer != SpecialLayers.Rapid) + entities.Where(e => SpecialLayers.IsMaterial(e.Layer)) ); var lines = new List(); @@ -33,7 +33,7 @@ namespace OpenNest { var entities = ConvertProgram.ToGeometry(part.Program); var shapes = ShapeBuilder.GetShapes( - entities.Where(e => e.Layer != SpecialLayers.Rapid) + entities.Where(e => SpecialLayers.IsMaterial(e.Layer)) ); var lines = new List(); @@ -56,7 +56,7 @@ namespace OpenNest PerfCounters.CountOffsetPerimeterEntities(); var geoEntities = ConvertProgram.ToGeometry(part.Program); var profile = new ShapeProfile( - geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() + geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList() ); var offsetShape = profile.Perimeter.OffsetOutward(spacing); @@ -79,7 +79,7 @@ namespace OpenNest { var geoEntities = ConvertProgram.ToGeometry(part.Program); var profile = new ShapeProfile( - geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() + geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList() ); var entities = new List(); @@ -112,7 +112,7 @@ namespace OpenNest { var geoEntities = ConvertProgram.ToGeometry(part.Program); var profile = new ShapeProfile( - geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() + geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList() ); return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location); @@ -126,7 +126,7 @@ namespace OpenNest { var geoEntities = ConvertProgram.ToGeometry(part.Program); var profile = new ShapeProfile( - geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() + geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList() ); var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location); @@ -158,7 +158,7 @@ namespace OpenNest { var entities = ConvertProgram.ToGeometry(part.Program); var shapes = ShapeBuilder.GetShapes( - entities.Where(e => e.Layer != SpecialLayers.Rapid) + entities.Where(e => SpecialLayers.IsMaterial(e.Layer)) ); var lines = new List(); diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 1b6c7f4..5078c4b 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -138,7 +138,7 @@ namespace OpenNest { var entities = Converters .ConvertProgram.ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) .ToList(); if (entities.Count > 0) diff --git a/OpenNest.Core/SpecialLayers.cs b/OpenNest.Core/SpecialLayers.cs index 5010647..b1cbfff 100644 --- a/OpenNest.Core/SpecialLayers.cs +++ b/OpenNest.Core/SpecialLayers.cs @@ -18,5 +18,12 @@ namespace OpenNest public static readonly Layer Leadout = new Layer("LEADOUT") { Color = Color.Brown }; public static readonly Layer Scribe = new Layer("SCRIBE") { Color = Color.Magenta }; + + /// + /// True when an entity converted from a part program describes part material. Rapids + /// and scribe/etch marks are excluded: marks are only on the surface, so they never + /// bound material and must not affect nesting, collision, area, or validation. + /// + public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe; } } diff --git a/OpenNest.Engine.Tests/Jobs/NestJobValidationTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobValidationTests.cs index c0651e9..17a5ad0 100644 --- a/OpenNest.Engine.Tests/Jobs/NestJobValidationTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestJobValidationTests.cs @@ -179,6 +179,41 @@ public class NestJobValidationTests ); } + [Fact] + public void EtchMarksAreLeftOutOfNestingGeometry() + { + // The etch tick sticks 0.5 past the right edge. As material it would be open geometry + // leaving the part and would overlap the neighbour placed 0.2 away; as a mark it is ignored. + var job = new NestJob( + new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(RectangleWithEtch(LayerType.Scribe)), 2) }, + new[] { new NestPlateStock("stock", new Size(30, 30), 1) } + ); + + var result = Solve(job, new NestJobPlacement("part", 0, 0, 0, 0), new NestJobPlacement("part", 1, 10.2, 0, 0)); + + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(new PartFulfillment("part", 2, 2, 0), Assert.Single(result.Fulfillment)); + } + + [Fact] + public void OpenCutGeometryLeavingThePartIsStillRejected() + { + var job = new NestJob( + new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(RectangleWithEtch(LayerType.Cut)), 1) }, + new[] { new NestPlateStock("stock", new Size(30, 30), 1) } + ); + + Assert.Throws(() => Solve(job, new NestJobPlacement("part", 0, 0, 0, 0))); + } + + private static Program RectangleWithEtch(LayerType etchLayer) + { + var program = TestDrawingFactory.Rectangle(10, 10); + program.MoveTo(9.5, 5); + program.Codes.Add(new LinearMove(10.5, 5) { Layer = etchLayer }); + return program; + } + private static NestJobResult Solve(NestJob job, params NestJobPlacement[] placements) => new NestJobRunner(_ => new CandidateNester(placements)).Solve(job); diff --git a/OpenNest.Engine/BestFit/BestFitFinder.cs b/OpenNest.Engine/BestFit/BestFitFinder.cs index 47b9ac0..08072a9 100644 --- a/OpenNest.Engine/BestFit/BestFitFinder.cs +++ b/OpenNest.Engine/BestFit/BestFitFinder.cs @@ -167,7 +167,7 @@ namespace OpenNest.Engine.BestFit { var entities = ConvertProgram .ToGeometry(drawing.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); + .Where(e => SpecialLayers.IsMaterial(e.Layer)); var shapes = ShapeBuilder.GetShapes(entities); var points = new List(); diff --git a/OpenNest.Engine/BestFit/BestFitResult.cs b/OpenNest.Engine/BestFit/BestFitResult.cs index 6822139..5c118c1 100644 --- a/OpenNest.Engine/BestFit/BestFitResult.cs +++ b/OpenNest.Engine/BestFit/BestFitResult.cs @@ -101,7 +101,7 @@ namespace OpenNest.Engine.BestFit { var partEntities = ConvertProgram .ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) .ToList(); foreach (var entity in partEntities) diff --git a/OpenNest.Engine/BestFit/PairEvaluator.cs b/OpenNest.Engine/BestFit/PairEvaluator.cs index cdc07a3..0bd201e 100644 --- a/OpenNest.Engine/BestFit/PairEvaluator.cs +++ b/OpenNest.Engine/BestFit/PairEvaluator.cs @@ -149,7 +149,7 @@ namespace OpenNest.Engine.BestFit { var entities = ConvertProgram .ToGeometry(source.Program) - .Where(e => e.Layer != SpecialLayers.Rapid) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) .ToList(); var profile = new ShapeProfile(entities); var program = ConvertGeometry.ToProgram(profile.Perimeter); @@ -160,7 +160,7 @@ namespace OpenNest.Engine.BestFit { var entities = ConvertProgram .ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) .ToList(); var shapes = ShapeBuilder.GetShapes(entities); if (shapes.Count == 0) @@ -173,7 +173,7 @@ namespace OpenNest.Engine.BestFit { var entities = ConvertProgram .ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) .ToList(); var shapes = ShapeBuilder.GetShapes(entities); var points = new List(); diff --git a/OpenNest.Engine/BestFit/PolygonHelper.cs b/OpenNest.Engine/BestFit/PolygonHelper.cs index 865dadd..8ccae21 100644 --- a/OpenNest.Engine/BestFit/PolygonHelper.cs +++ b/OpenNest.Engine/BestFit/PolygonHelper.cs @@ -14,7 +14,7 @@ namespace OpenNest.Engine.BestFit { var entities = ConvertProgram .ToGeometry(drawing.Program) - .Where(e => e.Layer != SpecialLayers.Rapid) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) .ToList(); if (entities.Count == 0) diff --git a/OpenNest.Engine/Fill/RotationAnalysis.cs b/OpenNest.Engine/Fill/RotationAnalysis.cs index b43be1a..4cb2431 100644 --- a/OpenNest.Engine/Fill/RotationAnalysis.cs +++ b/OpenNest.Engine/Fill/RotationAnalysis.cs @@ -16,7 +16,7 @@ namespace OpenNest.Engine.Fill { var entities = ConvertProgram .ToGeometry(item.Drawing.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); + .Where(e => SpecialLayers.IsMaterial(e.Layer)); var shapes = ShapeBuilder.GetShapes(entities); @@ -65,7 +65,7 @@ namespace OpenNest.Engine.Fill { var entities = ConvertProgram .ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); + .Where(e => SpecialLayers.IsMaterial(e.Layer)); var shapes = ShapeBuilder.GetShapes(entities); diff --git a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs index 97426aa..81424fe 100644 --- a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs +++ b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs @@ -90,7 +90,7 @@ internal static class NestJobPlacementValidator var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(geometry)); var cutEntities = new List(); foreach (var entity in entities) - if (!ReferenceEquals(entity.Layer, SpecialLayers.Rapid)) + if (SpecialLayers.IsMaterial(entity.Layer)) cutEntities.Add(entity); var contours = ShapeBuilder.GetShapes(cutEntities); diff --git a/OpenNest.Engine/ML/FeatureExtractor.cs b/OpenNest.Engine/ML/FeatureExtractor.cs index 1127802..5a09e0e 100644 --- a/OpenNest.Engine/ML/FeatureExtractor.cs +++ b/OpenNest.Engine/ML/FeatureExtractor.cs @@ -32,7 +32,7 @@ namespace OpenNest.Engine.ML var entities = OpenNest .Converters.ConvertProgram.ToGeometry(canonical.Program) - .Where(e => e.Layer != SpecialLayers.Rapid) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) .ToList(); var profile = new ShapeProfile(entities); diff --git a/OpenNest.Engine/PartClassifier.cs b/OpenNest.Engine/PartClassifier.cs index 9ae9cec..dfd0570 100644 --- a/OpenNest.Engine/PartClassifier.cs +++ b/OpenNest.Engine/PartClassifier.cs @@ -34,7 +34,7 @@ namespace OpenNest.Engine var entities = ConvertProgram .ToGeometry(drawing.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); + .Where(e => SpecialLayers.IsMaterial(e.Layer)); var shapes = ShapeBuilder.GetShapes(entities); diff --git a/OpenNest.Gpu/GpuPairEvaluator.cs b/OpenNest.Gpu/GpuPairEvaluator.cs index cd79632..cb1bf96 100644 --- a/OpenNest.Gpu/GpuPairEvaluator.cs +++ b/OpenNest.Gpu/GpuPairEvaluator.cs @@ -291,7 +291,7 @@ namespace OpenNest.Gpu { var entities = ConvertProgram .ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); + .Where(e => SpecialLayers.IsMaterial(e.Layer)); var shapes = ShapeBuilder.GetShapes(entities); var points = new List(); diff --git a/OpenNest.Gpu/PartBitmap.cs b/OpenNest.Gpu/PartBitmap.cs index ac89eb9..5ad0661 100644 --- a/OpenNest.Gpu/PartBitmap.cs +++ b/OpenNest.Gpu/PartBitmap.cs @@ -56,7 +56,7 @@ namespace OpenNest.Gpu { var entities = ConvertProgram .ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); + .Where(e => SpecialLayers.IsMaterial(e.Layer)); var shapes = ShapeBuilder.GetShapes(entities); var polygons = new List(); @@ -167,7 +167,7 @@ namespace OpenNest.Gpu { var entities = ConvertProgram .ToGeometry(drawing.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); + .Where(e => SpecialLayers.IsMaterial(e.Layer)); var shapes = ShapeBuilder.GetShapes(entities); var polygons = new List(); diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 684578c..3eb5834 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Text.Json; using OpenNest.Bending; using OpenNest.CNC; +using OpenNest.Converters; using OpenNest.Engine.BestFit; using OpenNest.Geometry; using static OpenNest.IO.NestFormat; @@ -215,6 +216,17 @@ namespace OpenNest.IO { drawing.SourceEntities = entitySet.entities; drawing.SuppressedEntityIds = entitySet.suppressed; + + // Older files saved etch marks as cut moves; restore their Scribe layer so + // nesting ignores them. The program was mutated in place, so refresh. + if ( + drawing.Program != null + && ScribeLayerRepair.Apply(drawing.Program, drawing.SourceEntities, drawing.Source.Offset) > 0 + ) + { + drawing.UpdateArea(); + drawing.RecomputeCanonicalAngle(); + } } map[d.Id] = drawing; diff --git a/OpenNest.Tests/Converters/ConvertGeometryLayerTests.cs b/OpenNest.Tests/Converters/ConvertGeometryLayerTests.cs index b4b2ecf..87625a0 100644 --- a/OpenNest.Tests/Converters/ConvertGeometryLayerTests.cs +++ b/OpenNest.Tests/Converters/ConvertGeometryLayerTests.cs @@ -34,6 +34,18 @@ public class ConvertGeometryLayerTests Assert.All(pgm.Codes.OfType(), m => Assert.Equal(LayerType.Scribe, m.Layer)); } + [Fact] + public void AddLine_SavedScribeLayer_TagsScribe() + { + // Marks are stored on the SCRIBE layer; rebuilding a program from stored entities + // must keep them marks rather than turning them into cut moves. + var line = new Line(0, 0, 1, 0) { Layer = SpecialLayers.Scribe }; + + var pgm = ProgramFor(line); + + Assert.All(pgm.Codes.OfType(), m => Assert.Equal(LayerType.Scribe, m.Layer)); + } + [Fact] public void AddArc_EngraveLayer_TagsScribe() { diff --git a/OpenNest.Tests/Converters/ScribeLayerRepairTests.cs b/OpenNest.Tests/Converters/ScribeLayerRepairTests.cs new file mode 100644 index 0000000..1295f31 --- /dev/null +++ b/OpenNest.Tests/Converters/ScribeLayerRepairTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Converters; + +public class ScribeLayerRepairTests +{ + // Mirrors a PEP-exported drawing: incremental program whose first rapid was moved to the + // origin (Source.Offset), with an etch tick saved as a plain cut move. + private static readonly Vector Offset = new(5, 2); + + private static Program RectangleWithCutEtch() + { + var program = new Program(Mode.Incremental); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(1, 0)); // etch tick, source (5,2)->(6,2) + program.Codes.Add(new RapidMove(-5, -2)); + program.Codes.Add(new LinearMove(10, 0)); + program.Codes.Add(new LinearMove(0, 10)); + program.Codes.Add(new LinearMove(-10, 0)); + program.Codes.Add(new LinearMove(0, -10)); + return program; + } + + private static List SourceEntities() => + new() + { + new Line(5, 2, 6, 2) { Layer = SpecialLayers.Scribe }, + new Line(0, 0, 10, 0), + new Line(10, 0, 10, 10), + new Line(10, 10, 0, 10), + new Line(0, 10, 0, 0), + }; + + [Fact] + public void RestoresScribeOnMovesThatLieOnMarkEntities() + { + var program = RectangleWithCutEtch(); + + var repaired = ScribeLayerRepair.Apply(program, SourceEntities(), Offset); + + var lines = program.Codes.OfType().ToList(); + Assert.Equal(1, repaired); + Assert.Equal(LayerType.Scribe, lines[0].Layer); + Assert.All(lines.Skip(1), m => Assert.Equal(LayerType.Cut, m.Layer)); + } + + [Fact] + public void RecognizesRawEtchLayerNames() + { + var program = RectangleWithCutEtch(); + var entities = SourceEntities(); + entities[0].Layer = new Layer("etch"); + + Assert.Equal(1, ScribeLayerRepair.Apply(program, entities, Offset)); + } + + [Fact] + public void LeavesProgramAloneWithoutMarkEntities() + { + var program = RectangleWithCutEtch(); + + var repaired = ScribeLayerRepair.Apply(program, SourceEntities().Skip(1), Offset); + + Assert.Equal(0, repaired); + Assert.All(program.Codes.OfType(), m => Assert.Equal(LayerType.Cut, m.Layer)); + } + + [Fact] + public void WrongOffsetMatchesNothing() + { + var program = RectangleWithCutEtch(); + + Assert.Equal(0, ScribeLayerRepair.Apply(program, SourceEntities(), new Vector(0, 0))); + } +}