mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-17 20:52:07 -04:00
fix(nests): keep etch marks when clearing lead-ins of PEP nests
PEP-converted nests stored etch marks only in each placed part's locked cutting program; the drawing program held the contour cuts alone. Because Remove Lead-ins rebuilds a part from its drawing, clearing lead-ins also erased the marks from every part. Recover the marks while reading the nest: when a drawing has no scribe moves and every placement that kept its saved program carries the same marks, add them to the drawing in its own frame and refresh that drawing's clean placements. Saved programs are never rewritten, drawings that already have marks are untouched, and drawings whose saved programs disagree are left alone rather than guessed at.
This commit is contained in:
1 parent
6424512e8c
commit
74f32f0516
4 files changed
+416
No files matched your search
@@ -0,0 +1,170 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Puts etch marks back on drawings that lost them. Nests converted from PEP kept etch marks
|
||||
/// only in each placed part's saved cutting program; the drawing itself had none, so removing
|
||||
/// lead-ins (which rebuilds the part from its drawing) silently removed the marks too.
|
||||
/// </summary>
|
||||
public static class DrawingMarkRecovery
|
||||
{
|
||||
private const double MatchTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// For each drawing without etch marks whose saved placed programs all carry the same marks,
|
||||
/// adds those marks to the drawing in its own frame and refreshes the drawing's clean placed
|
||||
/// parts. Saved programs are never changed. Drawings whose saved programs disagree, or that
|
||||
/// already have marks, are left alone. Returns the number of drawings that were given marks.
|
||||
/// </summary>
|
||||
public static int Apply(Nest nest)
|
||||
{
|
||||
if (nest == null)
|
||||
return 0;
|
||||
|
||||
var parts = nest.Plates.SelectMany(plate => plate.Parts).ToList();
|
||||
var recovered = 0;
|
||||
foreach (var group in parts.GroupBy(part => part.BaseDrawing, ReferenceEqualityComparer.Instance))
|
||||
{
|
||||
var drawing = (Drawing)group.Key;
|
||||
if (Apply(drawing, group.ToList()))
|
||||
recovered++;
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
/// <summary>Recovers marks for one drawing from its placed parts; true when marks were added.</summary>
|
||||
public static bool Apply(Drawing drawing, IReadOnlyList<Part> placedParts)
|
||||
{
|
||||
if (drawing?.Program == null || drawing.IsCutOff || placedParts == null)
|
||||
return false;
|
||||
// An incremental call's end position is not tracked by Program.EndPoint, so only
|
||||
// flat drawings (which is what conversion produces) can be extended safely.
|
||||
if (HasMarks(drawing.Program) || drawing.Program.Codes.Any(code => code is SubProgramCall))
|
||||
return false;
|
||||
|
||||
var saved = placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing) && part.HasManualLeadIns)
|
||||
.ToList();
|
||||
if (saved.Count == 0)
|
||||
return false;
|
||||
|
||||
var marks = MarksInDrawingFrame(saved[0]);
|
||||
if (marks.Count == 0 || saved.Skip(1).Any(part => !SameMarks(marks, MarksInDrawingFrame(part))))
|
||||
return false;
|
||||
|
||||
AppendMarks(drawing.Program, marks);
|
||||
drawing.UpdateArea();
|
||||
drawing.RecomputeCanonicalAngle();
|
||||
|
||||
foreach (var part in placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing)
|
||||
&& !part.HasManualLeadIns))
|
||||
part.Update();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasMarks(Program program) =>
|
||||
program.Codes.Any(code => code is LinearMove { Layer: LayerType.Scribe } or ArcMove { Layer: LayerType.Scribe }
|
||||
|| code is SubProgramCall { Program: not null } call && HasMarks(call.Program));
|
||||
|
||||
/// <summary>
|
||||
/// The part's marks as they would sit in its drawing: <see cref="Part.RemoveLeadIns"/>
|
||||
/// rebuilds the part as the drawing turned by <see cref="Part.Rotation"/>, so undo that turn.
|
||||
/// </summary>
|
||||
private static List<Entity> MarksInDrawingFrame(Part part)
|
||||
{
|
||||
var marks = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(entity => entity.Layer == SpecialLayers.Scribe)
|
||||
.ToList();
|
||||
if (!Tolerance.IsEqualTo(part.Rotation, 0))
|
||||
foreach (var mark in marks)
|
||||
mark.Rotate(-part.Rotation);
|
||||
return marks;
|
||||
}
|
||||
|
||||
private static bool SameMarks(List<Entity> expected, List<Entity> actual)
|
||||
{
|
||||
if (expected.Count != actual.Count)
|
||||
return false;
|
||||
var unmatched = actual.ToList();
|
||||
foreach (var mark in expected)
|
||||
{
|
||||
var index = unmatched.FindIndex(other => SameMark(mark, other));
|
||||
if (index < 0)
|
||||
return false;
|
||||
unmatched.RemoveAt(index);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Direction does not matter for an etch: both marks must lie on each other.
|
||||
private static bool SameMark(Entity a, Entity b) =>
|
||||
a.Type == b.Type && LiesOn(Samples(a), b) && LiesOn(Samples(b), a);
|
||||
|
||||
private static bool LiesOn(List<Vector> points, Entity entity) =>
|
||||
points.Count > 0 && points.All(point => entity.ClosestPointTo(point).DistanceTo(point) <= MatchTolerance);
|
||||
|
||||
private static List<Vector> Samples(Entity entity) => entity switch
|
||||
{
|
||||
Line line => new List<Vector>
|
||||
{
|
||||
line.StartPoint,
|
||||
line.EndPoint,
|
||||
new((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2),
|
||||
},
|
||||
Arc arc => new List<Vector> { arc.StartPoint(), arc.EndPoint(), arc.MidPoint() },
|
||||
Circle circle => new List<Vector>
|
||||
{
|
||||
new(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||
new(circle.Center.X - circle.Radius, circle.Center.Y),
|
||||
new(circle.Center.X, circle.Center.Y + circle.Radius),
|
||||
new(circle.Center.X, circle.Center.Y - circle.Radius),
|
||||
},
|
||||
_ => new List<Vector>(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Appends the marks after the drawing's existing motion, so the existing codes and the
|
||||
/// drawing's first-rapid origin stay exactly as they were.
|
||||
/// </summary>
|
||||
private static void AppendMarks(Program program, List<Entity> marks)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.AddRange(marks);
|
||||
var markProgram = ConvertGeometry.ToProgram(shape);
|
||||
|
||||
// Start from the drawing's end point, with an explicit rapid onto the first mark.
|
||||
var start = EndOf(program);
|
||||
var first = markProgram.Codes.OfType<Motion>().First();
|
||||
var positioned = new Program(Mode.Absolute);
|
||||
positioned.Codes.Add(new RapidMove(start));
|
||||
if (first is not RapidMove)
|
||||
positioned.Codes.Add(new RapidMove(StartOf(marks[0])));
|
||||
positioned.Codes.AddRange(markProgram.Codes);
|
||||
|
||||
if (program.Mode == Mode.Incremental)
|
||||
positioned.Mode = Mode.Incremental;
|
||||
// Drop the move onto the drawing's end point; it is where the drawing already stops.
|
||||
program.Codes.AddRange(positioned.Codes.Skip(1));
|
||||
}
|
||||
|
||||
// Where a flat program leaves the head (callers exclude sub-program calls).
|
||||
private static Vector EndOf(Program program)
|
||||
{
|
||||
var position = Vector.Zero;
|
||||
foreach (var motion in program.Codes.OfType<Motion>())
|
||||
position = program.Mode == Mode.Incremental ? position + motion.EndPoint : motion.EndPoint;
|
||||
return position;
|
||||
}
|
||||
|
||||
private static Vector StartOf(Entity entity) => entity switch
|
||||
{
|
||||
Line line => line.StartPoint,
|
||||
Arc arc => arc.StartPoint(),
|
||||
Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||
_ => Vector.Zero,
|
||||
};
|
||||
}
|
||||
@@ -46,6 +46,9 @@ namespace OpenNest.IO
|
||||
var drawingMap = BuildDrawings(dto, programs, entitySets);
|
||||
ReadBestFits(drawingMap);
|
||||
var nest = BuildNest(dto, drawingMap);
|
||||
// Nests converted from PEP kept etch marks only in the placed programs; give them
|
||||
// back to the drawing so removing lead-ins does not remove the marks too.
|
||||
DrawingMarkRecovery.Apply(nest);
|
||||
|
||||
zipArchive.Dispose();
|
||||
stream.Close();
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Tests.IO;
|
||||
|
||||
/// <summary>
|
||||
/// Nests converted from PEP carry etch marks only in each placed part's locked cutting program;
|
||||
/// the drawing has none. Removing lead-ins rebuilds a part from its drawing, so loading such a
|
||||
/// nest must give the marks back to the drawing or clearing lead-ins erases them.
|
||||
/// </summary>
|
||||
public class ConvertedEtchMarkTests
|
||||
{
|
||||
private const double HalfTurn = System.Math.PI;
|
||||
private const double QuarterTurn = System.Math.PI / 2;
|
||||
|
||||
[Theory]
|
||||
[InlineData(Mode.Incremental)]
|
||||
[InlineData(Mode.Absolute)]
|
||||
public void ClearingLeadInsKeepsEtchMarksOfConvertedDrawing(Mode mode)
|
||||
{
|
||||
var loaded = Read(Save(ConvertedNest(mode)));
|
||||
var parts = loaded.Plates[0].Parts;
|
||||
|
||||
AppliedCuttingWork.Clear(loaded.Plates.ToList());
|
||||
|
||||
Assert.All(parts, part => Assert.False(part.HasManualLeadIns));
|
||||
AssertMarks(Expected(0, new Vector(0, 0)), parts[0]);
|
||||
AssertMarks(Expected(QuarterTurn, new Vector(30, 2)), parts[1]);
|
||||
AssertMarks(Expected(HalfTurn, new Vector(50, 10)), parts[2]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turning a mark is not symmetric, so a slanted placement proves the marks are put back in
|
||||
/// the drawing's own frame rather than the frame of the placement they were read from.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(Mode.Incremental)]
|
||||
[InlineData(Mode.Absolute)]
|
||||
public void MarksOfASlantedPlacementAreTurnedBackIntoTheDrawingFrame(Mode mode)
|
||||
{
|
||||
const double slant = System.Math.PI / 6;
|
||||
var drawing = new Drawing("converted", Build(marks: false, mode));
|
||||
var clean = new Part(drawing);
|
||||
clean.Rotate(slant);
|
||||
clean.Offset(12, 7);
|
||||
var nest = NestOf(drawing, SavedPart(drawing, Build(marks: true, mode), slant, new Vector(12, 7)), clean);
|
||||
|
||||
var loaded = Read(Save(nest));
|
||||
var parts = loaded.Plates[0].Parts;
|
||||
|
||||
Assert.Equal(2, MarkMoves(loaded.Drawings.Single().Program));
|
||||
var inDrawingFrame = MarkSamples(loaded.Drawings.Single().Program, Vector.Zero);
|
||||
Assert.Equal(MarkSamples(Build(marks: true, Mode.Incremental), Vector.Zero),
|
||||
inDrawingFrame, VectorSame);
|
||||
AssertMarks(Expected(slant, new Vector(12, 7)), parts[1]);
|
||||
AppliedCuttingWork.Clear(loaded.Plates.ToList());
|
||||
AssertMarks(Expected(slant, new Vector(12, 7)), parts[0]);
|
||||
AssertMarks(Expected(slant, new Vector(12, 7)), parts[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadingGivesTheMarksToCleanPartsAndLeavesSavedProgramsAlone()
|
||||
{
|
||||
var nest = ConvertedNest(Mode.Incremental);
|
||||
var saved = nest.Plates[0].Parts.Take(2).Select(p => NestWriter.GetProgramText(p.Program)).ToArray();
|
||||
|
||||
var loaded = Read(Save(nest));
|
||||
var parts = loaded.Plates[0].Parts;
|
||||
|
||||
Assert.Equal(2, MarkMoves(loaded.Drawings.Single().Program));
|
||||
Assert.True(parts[0].LeadInsLocked);
|
||||
Assert.Equal(saved[0], NestWriter.GetProgramText(parts[0].Program));
|
||||
Assert.Equal(saved[1], NestWriter.GetProgramText(parts[1].Program));
|
||||
Assert.False(parts[2].HasManualLeadIns);
|
||||
AssertMarks(Expected(HalfTurn, new Vector(50, 10)), parts[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecoveredMarksAreSavedOnceAndKeepTheSavedPrograms()
|
||||
{
|
||||
var once = Read(Save(ConvertedNest(Mode.Incremental)));
|
||||
var text = NestWriter.GetProgramText(once.Drawings.Single().Program);
|
||||
|
||||
var twice = Read(Save(once));
|
||||
|
||||
Assert.Equal(text, NestWriter.GetProgramText(twice.Drawings.Single().Program));
|
||||
Assert.Equal(2, MarkMoves(twice.Drawings.Single().Program));
|
||||
Assert.True(twice.Plates[0].Parts[0].HasManualLeadIns);
|
||||
Assert.True(twice.Plates[0].Parts[1].HasManualLeadIns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawingThatAlreadyHasMarksIsUnchanged()
|
||||
{
|
||||
var drawing = new Drawing("imported", Build(marks: true, Mode.Incremental));
|
||||
var text = NestWriter.GetProgramText(drawing.Program);
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(QuarterTurn);
|
||||
part.Offset(30, 2);
|
||||
part.ApplyLeadIns(new OpenNest.CNC.CuttingStrategy.CuttingParameters(), new Vector(-5, -5));
|
||||
var nest = NestOf(drawing, part);
|
||||
|
||||
var loaded = Read(Save(nest));
|
||||
AppliedCuttingWork.Clear(loaded.Plates.ToList());
|
||||
|
||||
Assert.Equal(text, NestWriter.GetProgramText(loaded.Drawings.Single().Program));
|
||||
AssertMarks(Expected(QuarterTurn, new Vector(30, 2)), loaded.Plates[0].Parts[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartsWithDifferentMarksLeaveTheDrawingWithout()
|
||||
{
|
||||
var drawing = new Drawing("converted", Build(marks: false, Mode.Incremental));
|
||||
var withMarks = SavedPart(drawing, Build(marks: true, Mode.Incremental), 0, new Vector(0, 0));
|
||||
var other = Build(marks: true, Mode.Incremental);
|
||||
other.Codes.RemoveAt(other.Codes.FindLastIndex(code => code is ArcMove));
|
||||
var withLineOnly = SavedPart(drawing, other, 0, new Vector(20, 0));
|
||||
|
||||
var reader = new NestReader(new MemoryStream(Save(NestOf(drawing, withMarks, withLineOnly))));
|
||||
var loaded = reader.Read();
|
||||
|
||||
Assert.Empty(reader.Warnings);
|
||||
Assert.Equal(0, MarkMoves(loaded.Drawings.Single().Program));
|
||||
Assert.True(loaded.Plates[0].Parts.All(p => p.HasManualLeadIns));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A converted nest: the drawing is material only, two placements keep the full PEP cutting
|
||||
/// program (etch line and etch circle) at different turns, and one placement is clean.
|
||||
/// </summary>
|
||||
private static Nest ConvertedNest(Mode mode)
|
||||
{
|
||||
var drawing = new Drawing("converted", Build(marks: false, mode));
|
||||
var clean = new Part(drawing);
|
||||
clean.Rotate(HalfTurn);
|
||||
clean.Offset(50, 10);
|
||||
return NestOf(drawing,
|
||||
SavedPart(drawing, Build(marks: true, mode), 0, new Vector(0, 0)),
|
||||
SavedPart(drawing, Build(marks: true, mode), QuarterTurn, new Vector(30, 2)),
|
||||
clean);
|
||||
}
|
||||
|
||||
private static Part SavedPart(Drawing drawing, Program cutting, double rotation, Vector location)
|
||||
{
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(rotation);
|
||||
part.Offset(location);
|
||||
var program = (Program)cutting.Clone();
|
||||
program.Rotate(rotation);
|
||||
Assert.True(part.RestoreLeadInProgram(program, locked: true));
|
||||
return part;
|
||||
}
|
||||
|
||||
/// <summary>A 10 x 6 plate with an etch line and an etch circle; without marks they are rapids.</summary>
|
||||
private static Program Build(bool marks, Mode mode)
|
||||
{
|
||||
var program = new Program(Mode.Absolute);
|
||||
program.Codes.Add(new RapidMove(2, 3));
|
||||
program.Codes.Add(marks ? new LinearMove(4, 3) { Layer = LayerType.Scribe } : new RapidMove(4, 3));
|
||||
program.Codes.Add(new RapidMove(7.5, 3));
|
||||
program.Codes.Add(marks
|
||||
? new ArcMove(new Vector(7.5, 3), new Vector(7, 3), RotationType.CCW) { Layer = LayerType.Scribe }
|
||||
: new RapidMove(7.5, 3));
|
||||
program.Codes.Add(new RapidMove(0, 0));
|
||||
program.Codes.Add(new LinearMove(10, 0));
|
||||
program.Codes.Add(new LinearMove(10, 6));
|
||||
program.Codes.Add(new LinearMove(0, 6));
|
||||
program.Codes.Add(new LinearMove(0, 0));
|
||||
program.Mode = mode;
|
||||
return program;
|
||||
}
|
||||
|
||||
private static Nest NestOf(Drawing drawing, params Part[] parts)
|
||||
{
|
||||
var nest = new Nest { Name = "converted-etch" };
|
||||
nest.Drawings.Add(drawing);
|
||||
var plate = new Plate { Size = new Size(48, 96) };
|
||||
foreach (var part in parts)
|
||||
plate.Parts.Add(part);
|
||||
nest.Plates.Add(plate);
|
||||
return nest;
|
||||
}
|
||||
|
||||
/// <summary>World-space mark samples of the full program turned and placed like a part.</summary>
|
||||
private static List<Vector> Expected(double rotation, Vector location)
|
||||
{
|
||||
var program = Build(marks: true, Mode.Incremental);
|
||||
program.Rotate(rotation);
|
||||
return MarkSamples(program, location);
|
||||
}
|
||||
|
||||
private static void AssertMarks(List<Vector> expected, Part part)
|
||||
{
|
||||
var actual = MarkSamples(part.Program, part.Location);
|
||||
Assert.Equal(expected.Count, actual.Count);
|
||||
foreach (var point in expected)
|
||||
{
|
||||
var index = actual.FindIndex(p => p.DistanceTo(point) <= 1e-4);
|
||||
Assert.True(index >= 0, $"No etch mark at {point} on the part at {part.Location}.");
|
||||
actual.RemoveAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
// Turn-independent samples: line ends, and each circle's centre and radius.
|
||||
private static List<Vector> MarkSamples(Program program, Vector location) =>
|
||||
ConvertProgram.ToGeometry(program)
|
||||
.Where(entity => entity.Layer == SpecialLayers.Scribe)
|
||||
.SelectMany(entity => entity switch
|
||||
{
|
||||
Line line => new[] { line.StartPoint + location, line.EndPoint + location },
|
||||
Circle circle => new[] { circle.Center + location, new Vector(circle.Radius, 0) },
|
||||
Arc arc => new[] { arc.StartPoint() + location, arc.EndPoint() + location },
|
||||
_ => Array.Empty<Vector>(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
private static bool VectorSame(Vector left, Vector right) => left.DistanceTo(right) <= 1e-6;
|
||||
|
||||
private static int MarkMoves(Program program) =>
|
||||
program.Codes.Count(code => code is LinearMove { Layer: LayerType.Scribe } or ArcMove { Layer: LayerType.Scribe });
|
||||
|
||||
private static byte[] Save(Nest nest)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
Assert.True(new NestWriter(nest).Write(stream));
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static Nest Read(byte[] bytes) => new NestReader(new MemoryStream(bytes)).Read();
|
||||
}
|
||||
@@ -50,6 +50,17 @@ the plate, zero-based part index, and entry; File > Open displays these warnings
|
||||
The program reader is not a general G-code or geometric safety validator; the
|
||||
saved cutting geometry is not validated against the drawing.
|
||||
|
||||
Nests converted from PEP before their drawings carried etch marks keep those
|
||||
marks only in the saved placed programs. When a drawing has no etch (scribe)
|
||||
moves and every placement with a saved program carries the same marks, the
|
||||
reader adds those marks to the drawing in its own frame (after its existing
|
||||
moves) and refreshes that drawing's clean placements, so Remove Lead-ins and
|
||||
Clear Lead-ins & Cut-offs keep the marks. Saved programs are never changed. If
|
||||
the saved programs disagree, the drawing is left without marks and no warning
|
||||
is raised. The drawing hash is checked against the file text before this
|
||||
repair, so saved programs still load; the next save writes the marks into
|
||||
`programs/program-N`.
|
||||
|
||||
## Plate cutting parameters
|
||||
|
||||
Each plate's optional `cuttingParameters` stores the settings used by Assign
|
||||
|
||||
Reference in new issue
Block a user