fix(ui): translate absolute-coordinate part paths by placement

Build paths in the drawing-local frame and translate both ordinary and split cut/lead paths once, without changing CNC programs. Add native Windows regressions using the operator's synthetic G90 fixture and incremental controls. Four absolute-mode cases fail before the fix; all seven pass after.
This commit is contained in:
aj committed 2026-10-07 17:22:06 -04:00
1 parent e7e4012974
commit f4d5cfe98e
5 files changed
+172 -6

No files matched your search

@@ -0,0 +1,155 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using OpenNest.CNC;
using OpenNest.Controls;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.WinForms.Tests;
public class GraphicsHelperPlacementTests
{
[Theory]
[InlineData(Mode.Absolute, false)]
[InlineData(Mode.Absolute, true)]
[InlineData(Mode.Incremental, false)]
[InlineData(Mode.Incremental, true)]
public void PlacementTranslatesEveryOutlineAndLeadPointWithoutChangingProgram(Mode mode, bool splitPaths)
{
var program = new CNC.Program(Mode.Absolute);
program.Codes.AddRange(new ICode[]
{
new RapidMove(-1, 0),
new LinearMove(0, 0) { Layer = LayerType.Leadin },
new LinearMove(0, 4),
new LinearMove(3, 4),
new ArcMove(4, 3, 3, 3, RotationType.CW),
new LinearMove(4, 0),
new LinearMove(0, 0),
new ArcMove(-1, -1, -1, 0, RotationType.CW) { Layer = LayerType.Leadout },
new RapidMove(3, 2),
new ArcMove(3, 2, 2, 2),
});
program.Mode = mode;
var before = NestWriter.GetProgramText(program);
var instructions = program.Codes.ToArray();
using var local = program.GetGraphicsPath();
program.GetGraphicsPaths(Vector.Zero, out var localCut, out var localLead);
using (localCut)
using (localLead)
{
Assert.True(local.PointCount > 0);
Assert.True(localCut.PointCount > 0);
Assert.True(localLead.PointCount > 0);
foreach (var origin in new[] { new Vector(10.25, 6.5), new Vector(-7.5, 12.25), Vector.Zero })
{
if (!splitPaths)
{
using var placed = program.GetGraphicsPath(origin);
AssertTranslated(local, placed, origin);
}
else
{
program.GetGraphicsPaths(origin, out var cut, out var lead);
using (cut)
using (lead)
{
AssertTranslated(localCut, cut, origin);
AssertTranslated(localLead, lead, origin);
}
}
}
}
Assert.Equal(before, NestWriter.GetProgramText(program));
Assert.Equal(instructions, program.Codes);
Assert.Equal(mode, program.Mode);
}
[Fact]
public void IncrementalSubprogramOffsetsStayRelativeToProgramOrigin()
{
var hole = new CNC.Program(Mode.Incremental);
hole.Codes.AddRange(new ICode[]
{
new RapidMove(1, 0),
new ArcMove(0, 0, -1, 0),
});
var program = new CNC.Program(Mode.Incremental);
program.Codes.Add(new SubProgramCall(hole, 0) { Offset = new Vector(3, 4) });
program.Codes.Add(new SubProgramCall(hole, 0) { Offset = new Vector(9, 4) });
var before = NestWriter.GetSubProgramsText(program);
using var local = program.GetGraphicsPath();
using var placed = program.GetGraphicsPath(new Vector(20, 30));
AssertTranslated(local, placed, new Vector(20, 30));
Assert.Equal(new RectangleF(22, 33, 8, 2), placed.GetBounds());
program.GetGraphicsPaths(new Vector(20, 30), out var cut, out var lead);
using (cut)
using (lead)
{
AssertTranslated(local, cut, new Vector(20, 30));
Assert.Equal(0, lead.PointCount);
}
Assert.Same(hole, ((SubProgramCall)program.Codes[0]).Program);
Assert.Same(hole, ((SubProgramCall)program.Codes[1]).Program);
Assert.Equal(before, NestWriter.GetSubProgramsText(program));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void SavedAbsolutePartsFollowRepeatedMovesAndViewTransform(bool splitPaths) => StaTestThread.Run(() =>
{
var fixture = Path.Combine(AppContext.BaseDirectory, "Fixtures", "absolute-coordinate-parts.nest");
var nest = new NestReader(fixture).Read();
Assert.Equal(2, nest.Plates.Count);
Assert.Equal(3, nest.Plates.Sum(plate => plate.Parts.Count));
using var view = new PlateView();
view.Matrix.Reset();
view.Matrix.Scale(2, -2);
view.Matrix.Translate(40, 80, MatrixOrder.Append);
foreach (var part in nest.Plates.SelectMany(plate => plate.Parts))
{
Assert.Equal(Mode.Absolute, part.Program.Mode);
part.HasManualLeadIns = splitPaths;
var before = NestWriter.GetProgramText(part.Program);
var initial = part.Location;
var layout = LayoutPart.Create(part, view);
try
{
foreach (var location in new[] { initial, new Vector(4.25, 10.75), new Vector(15.5, 3.25), initial })
{
part.Location = location;
layout.Path.Dispose();
layout.Update(view);
using var expected = part.Program.GetGraphicsPath();
using var translation = new Matrix();
translation.Translate((float)location.X, (float)location.Y);
expected.Transform(translation);
expected.Transform(view.Matrix);
AssertTranslated(expected, layout.Path, Vector.Zero);
if (splitPaths)
Assert.Equal(0, layout.LeadInPath.PointCount);
Assert.Equal(before, NestWriter.GetProgramText(part.Program));
}
}
finally
{
layout.Path.Dispose();
layout.LeadInPath?.Dispose();
}
}
}, TimeSpan.FromMinutes(1), "Absolute-coordinate placement test did not complete.");
private static void AssertTranslated(GraphicsPath local, GraphicsPath placed, Vector offset)
{
Assert.Equal(local.PointCount, placed.PointCount);
Assert.Equal(local.PathTypes, placed.PathTypes);
var expected = local.PathPoints;
var actual = placed.PathPoints;
for (var i = 0; i < expected.Length; i++)
{
Assert.InRange(actual[i].X - (expected[i].X + offset.X), -0.00001, 0.00001);
Assert.InRange(actual[i].Y - (expected[i].Y + offset.Y), -0.00001, 0.00001);
}
}
}
@@ -15,5 +15,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest\OpenNest.csproj" />
<Content Include="Fixtures\absolute-coordinate-parts.nest" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+12 -5
View File
@@ -20,10 +20,12 @@ namespace OpenNest
public static GraphicsPath GetGraphicsPath(this Program pgm, Vector origin)
{
var path = new GraphicsPath();
var curpos = origin;
AddProgram(path, pgm, pgm.Mode, ref curpos);
// Program coordinates are drawing-local in either mode. Translate the
// finished path so absolute moves cannot discard the part placement.
var path = pgm.GetGraphicsPath();
using var translation = new Matrix();
translation.Translate((float)origin.X, (float)origin.Y);
path.Transform(translation);
return path;
}
@@ -100,9 +102,14 @@ namespace OpenNest
{
cutPath = new GraphicsPath();
leadPath = new GraphicsPath();
var curpos = origin;
var curpos = Vector.Zero;
AddProgramSplit(cutPath, leadPath, pgm, pgm.Mode, ref curpos);
using var translation = new Matrix();
translation.Translate((float)origin.X, (float)origin.Y);
cutPath.Transform(translation);
leadPath.Transform(translation);
}
private static void AddProgramSplit(
+4 -1
View File
@@ -303,7 +303,10 @@ plans every plate that has parts. Both open one dialog built on
the thread it was created on rather than to whichever context is current.
- `PlateView` follows `Plate.PartsReordered`: it redraws parts in the plate's order (the
numbers it draws are the cutting order), rebuilds their graphics and marks the overlap
check out of date.
check out of date. Both the editor and preview build outlines and lead paths in the
drawing-local frame, then apply the part placement once. Absolute (G90) programs
therefore follow moves and clones just like incremental (G91) programs; displaying a
part does not rewrite its program or coordinate mode.
## Remaining integration boundaries