mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 19:41:08 -04:00
test(core): pin frame lifecycle
Proves the source-to-program mapping the pins rely on (plan policy: prove it or stop): the program frame is the source frame moved by -Source.Offset, never rotated in place; a placement rotates it about the program origin and adds the part location. Source.Angle is canonical metadata and plays no part. The oracle does not use Source.Offset or part rotation bookkeeping. It recovers the rigid source-to-plate transform from the placed geometry itself, using two holes of different sizes, and checks every pin at 0, 90 and 37 degrees, placed directly and the way engines place parts (CreateAtOrigin), after: - import (BuildDrawing re-origins at the first rapid); - a converter edit that hides the first-cut hole and so moves Source.Offset; - a converter edit of a drawing without source entities (the derived entities are moved back by Source.Offset); - save and reload; - a drawing package round trip. To exercise the real converter paths headlessly, the desktop's entity load and in-place apply move into CadImporter.EditableEntities and CadImporter.ApplyEdit, unchanged; the in-place apply keeps the library link and cut pins as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f06748f28
commit
734346eeec
4 files changed
+253
-34
No files matched your search
@@ -185,6 +185,57 @@ namespace OpenNest.IO
|
||||
return drawing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The entities an editor shows for an existing drawing, in its source frame: the stored
|
||||
/// source entities with their suppression applied to <see cref="Entity.IsVisible"/>, or
|
||||
/// for older drawings without them, the program geometry moved back by
|
||||
/// <c>Source.Offset</c> without rapids.
|
||||
/// </summary>
|
||||
public static List<Entity> EditableEntities(Drawing drawing)
|
||||
{
|
||||
if (drawing.SourceEntities != null)
|
||||
{
|
||||
// Use stored entities with stable GUIDs; apply suppression state
|
||||
var entities = new List<Entity>(drawing.SourceEntities);
|
||||
|
||||
foreach (var entity in entities)
|
||||
entity.IsVisible = !drawing.SuppressedEntityIds.Contains(entity.Id);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
var derived = ConvertProgram.ToGeometry(drawing.Program);
|
||||
|
||||
// Re-apply source offset so entities appear in their natural position
|
||||
if (drawing.Source?.Offset != null && drawing.Source.Offset != Vector.Zero)
|
||||
{
|
||||
foreach (var entity in derived)
|
||||
entity.Offset(drawing.Source.Offset);
|
||||
}
|
||||
|
||||
// Remove rapid traversals — they aren't part of the cut geometry
|
||||
derived.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
return derived;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies an edited rebuild of <paramref name="existing"/> in place, so placed parts keep
|
||||
/// their drawing reference. Geometry, source, customer, quantity and bends are taken from
|
||||
/// <paramref name="edited"/>; the library link and cut pins stay, since pins live in the
|
||||
/// source frame and are resolved against the new program when used.
|
||||
/// </summary>
|
||||
public static void ApplyEdit(Drawing existing, Drawing edited)
|
||||
{
|
||||
existing.Program = edited.Program;
|
||||
existing.SourceEntities = edited.SourceEntities;
|
||||
existing.SuppressedEntityIds = edited.SuppressedEntityIds;
|
||||
existing.Source = edited.Source;
|
||||
existing.Customer = edited.Customer;
|
||||
existing.Quantity.Required = edited.Quantity.Required;
|
||||
existing.Bends.Clear();
|
||||
existing.Bends.AddRange(edited.Bends);
|
||||
}
|
||||
|
||||
private static bool IsRepairMark(Entity e) =>
|
||||
string.Equals(e.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(e.Layer?.Name, "SCRIBE", System.StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Pins are stored in the drawing's source frame and must land on the same physical point of a
|
||||
/// placed part however the drawing was produced, edited, saved, packaged or rotated. The oracle
|
||||
/// never uses Source.Offset or part rotation bookkeeping: it recovers the rigid source-to-plate
|
||||
/// transform from the placed geometry itself, using two holes of different sizes.
|
||||
/// </summary>
|
||||
public class CutPinFrameLifecycleTests
|
||||
{
|
||||
// Source (DXF) coordinates, deliberately far from the origin.
|
||||
private static readonly Vector BigHole = new(23, 13);
|
||||
private static readonly Vector SmallHole = new(31.25, 10.75);
|
||||
private const double BigRadius = 1;
|
||||
private const double SmallRadius = 0.25;
|
||||
|
||||
private static readonly Vector[] SourcePins =
|
||||
{
|
||||
new(32, 18), // perimeter corner
|
||||
new(24, 13), // big hole, rightmost point
|
||||
new(31.25, 11), // small hole, top point
|
||||
};
|
||||
|
||||
private static readonly double[] Rotations = { 0, Angle.HalfPI, Angle.ToRadians(37) };
|
||||
|
||||
/// <summary>
|
||||
/// A 12 x 8 plate at (20, 10) with two pinned holes and an unpinned first-cut hole: the
|
||||
/// program origin sits on that hole, so hiding it in the converter moves the origin.
|
||||
/// </summary>
|
||||
private static (CadImportResult Result, Entity FirstHole) Import()
|
||||
{
|
||||
var firstHole = new Circle(29, 15, 0.5);
|
||||
var entities = new List<Entity>
|
||||
{
|
||||
new Line(20, 10, 32, 10),
|
||||
new Line(32, 10, 32, 18),
|
||||
new Line(32, 18, 20, 18),
|
||||
new Line(20, 18, 20, 10),
|
||||
new Circle(BigHole, BigRadius),
|
||||
firstHole,
|
||||
new Circle(SmallHole, SmallRadius),
|
||||
};
|
||||
return (new CadImportResult { Entities = entities, Name = "Synthetic plate", SourcePath = "synthetic.dxf" },
|
||||
firstHole);
|
||||
}
|
||||
|
||||
private static Drawing ImportPinned(out CadImportResult result, out Entity firstHole)
|
||||
{
|
||||
(result, firstHole) = Import();
|
||||
var drawing = CadImporter.BuildDrawing(result, result.Entities, result.Bends, 1, null, null);
|
||||
drawing.CutPins.Add(new ContourCutPin(SourcePins[0], new LineLeadIn { Length = 0.25, ApproachAngle = 90 },
|
||||
new NoLeadOut(), new NormalTab { Size = 0.03 }));
|
||||
drawing.CutPins.Add(new ContourCutPin(SourcePins[1], new ArcLeadIn { Radius = 0.1 }, new NoLeadOut()));
|
||||
drawing.CutPins.Add(new ContourCutPin(SourcePins[2], new LineLeadIn { Length = 0.1 },
|
||||
new LineLeadOut { Length = 0.05 }));
|
||||
return drawing;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_ReoriginsTheProgramAwayFromTheSourceFrame()
|
||||
{
|
||||
var drawing = ImportPinned(out _, out _);
|
||||
|
||||
Assert.NotEqual(Vector.Zero, drawing.Source.Offset);
|
||||
AssertPinsLand(drawing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConverterEditThatMovesTheProgramOrigin_KeepsPinsOnTheirContours()
|
||||
{
|
||||
var drawing = ImportPinned(out var result, out var firstHole);
|
||||
var before = drawing.Source.Offset;
|
||||
|
||||
// Load into the editor, hide the first-cut hole, rebuild, and apply in place as the desktop does.
|
||||
var editable = CadImporter.EditableEntities(drawing);
|
||||
firstHole.IsVisible = false;
|
||||
result.Entities = editable;
|
||||
var visible = editable.Where(e => e.Layer.IsVisible && e.IsVisible).ToList();
|
||||
var edited = CadImporter.BuildDrawing(result, visible, result.Bends, 1, null, null);
|
||||
CadImporter.ApplyEdit(drawing, edited);
|
||||
|
||||
Assert.NotEqual(before, drawing.Source.Offset);
|
||||
Assert.Equal(3, drawing.CutPins.Count);
|
||||
AssertPinsLand(drawing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConverterEditOfADrawingWithoutSourceEntities_KeepsPinsOnTheirContours()
|
||||
{
|
||||
var drawing = ImportPinned(out var result, out _);
|
||||
drawing.SourceEntities = null;
|
||||
|
||||
var editable = CadImporter.EditableEntities(drawing);
|
||||
var edited = CadImporter.BuildDrawing(
|
||||
new CadImportResult { Entities = editable, Name = result.Name }, editable, new List<OpenNest.Bending.Bend>(), 1,
|
||||
null, null);
|
||||
CadImporter.ApplyEdit(drawing, edited);
|
||||
|
||||
AssertPinsLand(drawing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveAndReload_KeepsPinsOnEveryPlacement()
|
||||
{
|
||||
var drawing = ImportPinned(out _, out _);
|
||||
var nest = new Nest("Synthetic") { Units = Units.Inches };
|
||||
nest.Drawings.Add(drawing);
|
||||
var plate = new Plate(96, 48);
|
||||
nest.Plates.Add(plate);
|
||||
for (var i = 0; i < Rotations.Length; i++)
|
||||
plate.Parts.Add(Place(drawing, Rotations[i], new Vector(5 + 25 * i, 20)));
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
Assert.True(new NestWriter(nest).Write(stream));
|
||||
var reader = new NestReader(new MemoryStream(stream.ToArray()));
|
||||
var restored = reader.Read();
|
||||
|
||||
Assert.Empty(reader.Warnings);
|
||||
var parts = Assert.Single(restored.Plates).Parts;
|
||||
Assert.Equal(Rotations.Length, parts.Count);
|
||||
foreach (var part in parts)
|
||||
AssertPinsLand(part);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Package_KeepsPinsOnTheirContours()
|
||||
{
|
||||
var drawing = ImportPinned(out _, out _);
|
||||
|
||||
var contents = DrawingPackage.Read(DrawingPackage.Write(drawing, Units.Inches));
|
||||
|
||||
Assert.Equal(drawing.Source.Offset, contents.Drawing.Source.Offset);
|
||||
AssertPinsLand(contents.Drawing);
|
||||
}
|
||||
|
||||
private static Part Place(Drawing drawing, double rotation, Vector location)
|
||||
{
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(rotation);
|
||||
part.Offset(location);
|
||||
return part;
|
||||
}
|
||||
|
||||
/// <summary>Places the drawing at each rotation, directly and the way engines do, and checks every pin.</summary>
|
||||
private static void AssertPinsLand(Drawing drawing)
|
||||
{
|
||||
foreach (var rotation in Rotations)
|
||||
{
|
||||
AssertPinsLand(Place(drawing, rotation, new Vector(50, 40)));
|
||||
var atOrigin = Part.CreateAtOrigin(drawing, rotation);
|
||||
atOrigin.Offset(new Vector(7, 3));
|
||||
AssertPinsLand(atOrigin);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertPinsLand(Part part)
|
||||
{
|
||||
var results = CutPinResolver.Resolve(part);
|
||||
|
||||
Assert.Equal(SourcePins.Length, results.Count);
|
||||
Assert.All(results, r => Assert.True(r.IsResolved, $"Pin {r.PinIndex + 1}: {r.Status}"));
|
||||
Assert.Equal(SourcePins.Length, results.Select(r => r.ContourOrdinal).Distinct().Count());
|
||||
var toPlate = SourceToPlate(part);
|
||||
for (var i = 0; i < SourcePins.Length; i++)
|
||||
{
|
||||
var expected = toPlate(SourcePins[i]);
|
||||
var actual = results[i].Point + part.Location;
|
||||
Assert.True(expected.DistanceTo(actual) <= 1e-9,
|
||||
$"Pin {i + 1} at rotation {part.Rotation}: expected {expected}, got {actual}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The rigid source-to-plate transform recovered from the placed holes alone.</summary>
|
||||
private static Func<Vector, Vector> SourceToPlate(Part part)
|
||||
{
|
||||
var shapes = ShapeBuilder.GetShapes(
|
||||
ConvertProgram.ToGeometry(part.Program).Where(e => SpecialLayers.IsMaterial(e.Layer)));
|
||||
var big = HoleCentre(shapes, BigRadius) + part.Location;
|
||||
var small = HoleCentre(shapes, SmallRadius) + part.Location;
|
||||
var turn = Direction(small - big) - Direction(SmallHole - BigHole);
|
||||
return source => (source - BigHole).Rotate(turn) + big;
|
||||
}
|
||||
|
||||
// A circle's bounding box is centred on it at any rotation.
|
||||
private static Vector HoleCentre(List<Shape> shapes, double radius)
|
||||
{
|
||||
var box = Assert.Single(shapes.Select(s => s.Entities.GetBoundingBox()),
|
||||
b => System.Math.Abs(b.Width - 2 * radius) < 1e-6 && System.Math.Abs(b.Length - 2 * radius) < 1e-6);
|
||||
return box.Center;
|
||||
}
|
||||
|
||||
private static double Direction(Vector v) => System.Math.Atan2(v.Y, v.X);
|
||||
}
|
||||
@@ -804,32 +804,7 @@ namespace OpenNest.Forms
|
||||
{
|
||||
foreach (var drawing in drawings)
|
||||
{
|
||||
List<Entity> entities;
|
||||
|
||||
if (drawing.SourceEntities != null)
|
||||
{
|
||||
// Use stored entities with stable GUIDs; apply suppression state
|
||||
entities = new List<Entity>(drawing.SourceEntities);
|
||||
|
||||
foreach (var entity in entities)
|
||||
entity.IsVisible = !drawing.SuppressedEntityIds.Contains(entity.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: derive entities from Program (older drawings without source entities)
|
||||
entities = ConvertProgram.ToGeometry(drawing.Program);
|
||||
|
||||
// Re-apply source offset so entities appear in their natural position
|
||||
if (drawing.Source?.Offset != null && drawing.Source.Offset != Vector.Zero)
|
||||
{
|
||||
foreach (var entity in entities)
|
||||
entity.Offset(drawing.Source.Offset);
|
||||
}
|
||||
|
||||
// Remove rapid traversals — they aren't part of the cut geometry
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
}
|
||||
|
||||
var entities = CadImporter.EditableEntities(drawing);
|
||||
var bounds = entities.GetBoundingBox();
|
||||
|
||||
var item = new FileListItem
|
||||
|
||||
@@ -1290,14 +1290,7 @@ namespace OpenNest.Forms
|
||||
{
|
||||
if (newByName.TryGetValue(existing.Name, out var updated))
|
||||
{
|
||||
existing.Program = updated.Program;
|
||||
existing.SourceEntities = updated.SourceEntities;
|
||||
existing.SuppressedEntityIds = updated.SuppressedEntityIds;
|
||||
existing.Source = updated.Source;
|
||||
existing.Customer = updated.Customer;
|
||||
existing.Quantity.Required = updated.Quantity.Required;
|
||||
existing.Bends.Clear();
|
||||
existing.Bends.AddRange(updated.Bends);
|
||||
CadImporter.ApplyEdit(existing, updated);
|
||||
newByName.Remove(existing.Name);
|
||||
}
|
||||
else
|
||||
|
||||
Reference in new issue
Block a user