From f4d5cfe98efead9f3d58650276e2e86554dbdab0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 7 Oct 2026 17:22:06 -0400 Subject: [PATCH] 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. --- .../Fixtures/absolute-coordinate-parts.nest | Bin 0 -> 1870 bytes .../GraphicsHelperPlacementTests.cs | 155 ++++++++++++++++++ .../OpenNest.WinForms.Tests.csproj | 1 + OpenNest/GraphicsHelper.cs | 17 +- docs/cutting-planner.md | 5 +- 5 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 OpenNest.WinForms.Tests/Fixtures/absolute-coordinate-parts.nest create mode 100644 OpenNest.WinForms.Tests/GraphicsHelperPlacementTests.cs diff --git a/OpenNest.WinForms.Tests/Fixtures/absolute-coordinate-parts.nest b/OpenNest.WinForms.Tests/Fixtures/absolute-coordinate-parts.nest new file mode 100644 index 0000000000000000000000000000000000000000..8de708cd68c2eda1b395e23b68ed8a75b034cfa2 GIT binary patch literal 1870 zcmZ{ldpr|r7{@o4Sfz5G7H5Z9xweqe#mD6+i!yUd=CW|sGGtqcquHFaQWJ71=OiuH z$}!hrt%f2(?o7Fr+nEuSW2Btvb3W&sRPXzFp7(t|&-?v7?_bY@c9fP;0RRBdF?*&eSMg`Q;U zFl(Y+Ekf4kGDpgG9laTp4N5d$*Pf~73&%>704pof0Q48d?v-k&{~*a2O6;4(Ox6^W zcY@Rqu2%xpzbS{aN-)-yWcGbe&UB5OloOuYz>)S(w&9cO1l+}9`C1z=6?W>;hu2bQbCbKIg8Y@Zdvbl2xf&6;-C z?uf5vj4v3+!^b031h?v`N3yF&^?Wc~y@Xetb>S57%>kzK_PNDTakNSU9-r6Vj-%`o zga%>h#3OJFrr=>>n!GY5$7{udRjm34YW=NDh)y}HQChlSn$=`W&w?GK*B8d62PiaiC>RmiTGO zwml=)A(|L~quuf3iDg2x*17tpTA&VeIsruj`O<*qablG~H=1imZ*saeus7VMIqp>S z>=-7NBtln*lWkESR2+>L9>}tA;4?$HoM&Zy0sX4PS)7+X$`uUD5enQ5qRxDTaTAs& zz&PDTIjJ;D;yRG}#>FmE;=M_Y3IV}(1MtQvGG2vyg#FCrz) zA2*%mrWu}9WBOKZ%d>T-_MMxaSR?p zIJ|uOBA}7ZlR{p?2y{-c=oh@#N&p$(Srq?{r^;cz_|3>z8=0fsc)MukxqB~grX|fY zDQ>y>sWx)|HG(5{njSd0e) z>=7foV*hfo@#i;ZN(9BcsON)8PYmZ%HUMP`B7pXMa!wiHjXx#GHGR)fZmiT_l+JBz zq(6%D#>~&8&+ZCDBR^~~A8)A0t{5P2+hoG|FvSM%B5HXuQR>hoo&};PvZIfFEF53Y zhV=9qI4jJ&$hdNFJJ(5AO$q6tA#u}7mO}&=ZuntL{5h-GD1uYieDSBa=9YKfH3~7w zu9@k z%nQ4w-J25XdZ~Wh&JhZyK2my}Bf_feM0s3l_0&DCdL`0(P;&bm=5bdD)#QN4hEAXP z-iP!F{-Rv(>D#63D4kST3{2x?R;zV1Y4+{rfBPj+N=5qnBV@I&uC8qQs{eYRpdG*D z*G`31YISAPLnQty-efJmb_{%mKl2a1#hb3>*LM8>ctzm<`ajxHcHL@9MzR+qr_JZ+ EZ$c9xBme*a literal 0 HcmV?d00001 diff --git a/OpenNest.WinForms.Tests/GraphicsHelperPlacementTests.cs b/OpenNest.WinForms.Tests/GraphicsHelperPlacementTests.cs new file mode 100644 index 0000000..c5574a0 --- /dev/null +++ b/OpenNest.WinForms.Tests/GraphicsHelperPlacementTests.cs @@ -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); + } + } +} diff --git a/OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj b/OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj index 4ea51cf..3347222 100644 --- a/OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj +++ b/OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj @@ -15,5 +15,6 @@ + diff --git a/OpenNest/GraphicsHelper.cs b/OpenNest/GraphicsHelper.cs index 5afc00b..ed3cf42 100644 --- a/OpenNest/GraphicsHelper.cs +++ b/OpenNest/GraphicsHelper.cs @@ -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( diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index bfa2a5c..319df19 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -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