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 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-25 06:44:29 -04:00
co-authored by Claude Opus 5.5
parent e3d10e95ae
commit 1b5e1b14a6
24 changed files with 273 additions and 30 deletions
+2 -1
View File
@@ -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<NestProgress>` 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<GravographPass>, …)` 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<GravographPass>, …)` 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).
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+5 -2
View File
@@ -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;
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Converters
{
/// <summary>
/// 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.
/// </summary>
public static class ScribeLayerRepair
{
private const double MatchTolerance = 0.001;
/// <summary>
/// Reclassifies Cut moves in <paramref name="program"/> that lie on a mark entity in
/// <paramref name="sourceEntities"/>. Program coordinates are source coordinates shifted
/// by -<paramref name="sourceOffset"/>. Returns the number of moves reclassified.
/// </summary>
public static int Apply(Program program, IEnumerable<Entity> 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<Vector> Translate(Entity entity, Vector offset)
{
var points = entity switch
{
Line l => new List<Vector>
{
l.StartPoint,
l.EndPoint,
new Vector((l.StartPoint.X + l.EndPoint.X) / 2, (l.StartPoint.Y + l.EndPoint.Y) / 2),
},
Arc a => new List<Vector> { a.StartPoint(), a.EndPoint(), a.MidPoint() },
Circle c => new List<Vector>
{
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<Vector>(),
};
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<Vector> points, List<Entity> marks) =>
points.Count > 0
&& marks.Any(m => points.All(p => m.ClosestPointTo(p).DistanceTo(p) <= MatchTolerance));
}
}
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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)
+7 -7
View File
@@ -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<Line>();
@@ -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<Line>();
@@ -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<Entity>();
@@ -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<Line>();
+1 -1
View File
@@ -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)
+7
View File
@@ -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 };
/// <summary>
/// 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.
/// </summary>
public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe;
}
}
@@ -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<ArgumentException>(() => 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);
+1 -1
View File
@@ -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<Vector>();
+1 -1
View File
@@ -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)
+3 -3
View File
@@ -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<Vector>();
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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);
@@ -90,7 +90,7 @@ internal static class NestJobPlacementValidator
var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(geometry));
var cutEntities = new List<Entity>();
foreach (var entity in entities)
if (!ReferenceEquals(entity.Layer, SpecialLayers.Rapid))
if (SpecialLayers.IsMaterial(entity.Layer))
cutEntities.Add(entity);
var contours = ShapeBuilder.GetShapes(cutEntities);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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<Vector>();
+2 -2
View File
@@ -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<Polygon>();
@@ -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<Polygon>();
+12
View File
@@ -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;
@@ -34,6 +34,18 @@ public class ConvertGeometryLayerTests
Assert.All(pgm.Codes.OfType<LinearMove>(), 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<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddArc_EngraveLayer_TagsScribe()
{
@@ -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<Entity> 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<LinearMove>().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<LinearMove>(), 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)));
}
}