From d7204e8d07416237fc457c0bd5156b6e53dba09a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 4 Oct 2026 21:30:47 -0400 Subject: [PATCH] fix(cutting): share only exact prepared circle programs --- .../CuttingStrategy/ContourCuttingStrategy.cs | 54 +++++++++++++- .../ExplicitCircleIdentityTests.cs | 72 +++++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/ExplicitCircleIdentityTests.cs diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index a5584e5..f779a72 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -243,7 +243,8 @@ namespace OpenNest.CNC.CuttingStrategy { var shape = shapes[choice.ContourOrdinal]; EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal], - choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null); + choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null, + exactCirclePrograms: true); } result.Mode = Mode.Incremental; return result; @@ -315,12 +316,57 @@ namespace OpenNest.CNC.CuttingStrategy return HashCode.Combine(r, a); } + // Prepared emission compares the actual resolved, generated motions, not a + // rounded geometry hash. Labels are deterministic encounter-order identifiers; + // legacy Apply/ApplySingle retain their existing cache and labels unchanged. + private static int RegisterPreparedCircleProgram(Program owner, Program generated) + { + CuttingPlanning.ExecutionMotionReader.ReadSupported(generated, Vector.Zero, null); + if (generated.Variables.Count != 0 || generated.SubPrograms.Count != 0) + throw new NotSupportedException("Unsupported generated circle program metadata."); + foreach (var code in generated.Codes) + if (code is not (RapidMove or LinearMove or ArcMove) || ((Motion)code).VariableRefs != null) + throw new NotSupportedException("Unsupported generated circle instruction."); + foreach (var pair in owner.SubPrograms) + if (SameGeneratedCircleProgram(pair.Value, generated)) + return pair.Key; + var key = checked(owner.SubPrograms.Count + 1); + owner.SubPrograms.Add(key, generated); + return key; + } + + private static bool SameGeneratedCircleProgram(Program a, Program b) + { + if (a.Mode != b.Mode || !SameBits(a.Rotation, b.Rotation) || a.Codes.Count != b.Codes.Count) + return false; + for (var i = 0; i < a.Codes.Count; i++) + { + var left = (Motion)a.Codes[i]; + var right = (Motion)b.Codes[i]; + if (left.GetType() != right.GetType() || left.Suppressed != right.Suppressed + || left.UseExactStop != right.UseExactStop || left.Feedrate != right.Feedrate + || !SameVector(left.EndPoint, right.EndPoint)) + return false; + if (left is LinearMove line && line.Layer != ((LinearMove)right).Layer) + return false; + if (left is ArcMove arc && (arc.Layer != ((ArcMove)right).Layer + || arc.Rotation != ((ArcMove)right).Rotation + || !SameVector(arc.CenterPoint, ((ArcMove)right).CenterPoint))) + return false; + } + return true; + } + + private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y); + private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b); + private void EmitContour( Program program, Shape shape, Vector point, Entity entity, - ContourType? forceType = null + ContourType? forceType = null, + bool exactCirclePrograms = false ) { var contourType = forceType ?? DetectContourType(shape); @@ -366,7 +412,9 @@ namespace OpenNest.CNC.CuttingStrategy subPgm.Mode = Mode.Incremental; // Deduplicate: check if an identical sub-program already exists - var key = ComputeSubProgramKey(circle.Radius, normal); + var key = exactCirclePrograms + ? RegisterPreparedCircleProgram(program, subPgm) + : ComputeSubProgramKey(circle.Radius, normal); if (!program.SubPrograms.ContainsKey(key)) program.SubPrograms[key] = subPgm; diff --git a/OpenNest.Tests/CuttingPlanning/ExplicitCircleIdentityTests.cs b/OpenNest.Tests/CuttingPlanning/ExplicitCircleIdentityTests.cs new file mode 100644 index 0000000..df17b43 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ExplicitCircleIdentityTests.cs @@ -0,0 +1,72 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class ExplicitCircleIdentityTests +{ + [Fact] + public void Emit_DistinctNearRoundedNormalsPreserveActualEntry() + { + var source = LeadPathValidationTests.Rectangle(0, 0, 10, 10); + foreach (var x in new[] { 3, 7 }) + { + source.MoveTo(x + 1, 3); + source.ArcTo(x + 1, 3, x, 3, RotationType.CCW); + } + var prepared = PreparedContours.Capture(source, new CuttingParameters()); + var angle = 0.0000001; + var choices = new[] { prepared.Entry(0, 0, new Vector(4, 3)), + prepared.Entry(1, 0, new Vector(7 + System.Math.Cos(angle), 3 + System.Math.Sin(angle))), + prepared.ClosestEntry(2, Vector.Zero) }; + var emitted = prepared.Emit(choices); + var circles = ExecutionMotionReader.Read(emitted, Vector.Zero, null, default).Motions + .Where(m => !m.Rapid && m.Curve.ToEntity() is Circle).ToArray(); + Assert.Equal(choices[1].Point.Y, circles[1].Start!.Value.Y); + var calls = emitted.Codes.OfType().ToArray(); + Assert.NotSame(calls[0].Program, calls[1].Program); + Assert.Equal(new[] { 1, 2 }, calls.Select(c => c.Id)); + } + + [Theory] + [InlineData(1.0000004, false, false)] + [InlineData(1, true, false)] + [InlineData(1, false, true)] + public void Emit_SharesOnlyExactlyEquivalentCirclePrograms(double secondRadius, bool reverse, bool shared) + { + var source = LeadPathValidationTests.Rectangle(0, 0, 10, 10); + source.MoveTo(4, 3); + source.ArcTo(4, 3, 3, 3, RotationType.CCW); + source.MoveTo(7 + secondRadius, 3); + source.ArcTo(7 + secondRadius, 3, 7, 3, reverse ? RotationType.CW : RotationType.CCW); + var prepared = PreparedContours.Capture(source, new CuttingParameters()); + var choices = Enumerable.Range(0, prepared.Count) + .Select(i => prepared.ClosestEntry(i, new Vector(20, 3))).ToArray(); + var emitted = prepared.Emit(choices); + var calls = emitted.Codes.OfType().ToArray(); + Assert.Equal(2, calls.Length); + var cuts = ExecutionMotionReader.Read(emitted, Vector.Zero, null, default).Motions + .Where(m => !m.Rapid && m.Curve.ToEntity() is Circle).ToArray(); + Assert.Equal(2, cuts.Length); + Assert.Equal(choices[0].Point, cuts[0].Start); + Assert.Equal(choices[1].Point, cuts[1].Start); + Assert.Equal(1, ((Circle)cuts[0].Curve.ToEntity()).Radius, 12); + Assert.Equal(secondRadius, ((Circle)cuts[1].Curve.ToEntity()).Radius, 12); + Assert.Equal(RotationType.CCW, Assert.Single(calls[0].Program.Codes.OfType()).Rotation); + Assert.Equal(reverse ? RotationType.CW : RotationType.CCW, + Assert.Single(calls[1].Program.Codes.OfType()).Rotation); + Assert.Equal(shared, ReferenceEquals(calls[0].Program, calls[1].Program)); + Assert.Equal(shared, calls[0].Id == calls[1].Id); + Assert.Equal(new[] { 1, shared ? 1 : 2 }, calls.Select(c => c.Id)); + Assert.All(calls, c => Assert.Same(c.Program, emitted.SubPrograms[c.Id])); + var fresh = prepared.Emit(choices); + var freshCalls = fresh.Codes.OfType().ToArray(); + Assert.Equal(calls.Select(c => c.Id), freshCalls.Select(c => c.Id)); + Assert.All(freshCalls, c => Assert.DoesNotContain(calls, old => ReferenceEquals(old.Program, c.Program))); + calls[0].Program.Codes.Clear(); + Assert.NotEmpty(freshCalls[0].Program.Codes); + Assert.Equal(ExplicitContourTests.Fingerprint(fresh), ExplicitContourTests.Fingerprint(prepared.Emit(choices))); + } +}