diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs
index 8a2e61e..080569f 100644
--- a/OpenNest.Core/Drawing.cs
+++ b/OpenNest.Core/Drawing.cs
@@ -163,5 +163,14 @@ namespace OpenNest
/// Offset distances to the original location.
///
public Vector Offset { get; set; }
+
+ ///
+ /// Rotation (radians) that maps the source program geometry to its canonical
+ /// (MBR-axis-aligned) frame. A value of 0 means the drawing is already canonical.
+ /// Task 3 will wire setter to populate this via
+ /// ; for now it is assigned manually by
+ /// engine helpers (see OpenNest.Engine.CanonicalFrame).
+ ///
+ public double Angle { get; set; }
}
}
diff --git a/OpenNest.Engine/CanonicalFrame.cs b/OpenNest.Engine/CanonicalFrame.cs
new file mode 100644
index 0000000..8911d26
--- /dev/null
+++ b/OpenNest.Engine/CanonicalFrame.cs
@@ -0,0 +1,76 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+using OpenNest.Math;
+using System.Collections.Generic;
+
+namespace OpenNest.Engine
+{
+ ///
+ /// Produces transient canonical (MBR-axis-aligned) copies of drawings for engine consumption
+ /// and un-rotates placed parts back to the drawing's original frame.
+ ///
+ public static class CanonicalFrame
+ {
+ ///
+ /// Returns a new Drawing whose Program geometry is rotated to the canonical frame.
+ /// The source drawing is not mutated.
+ ///
+ public static Drawing AsCanonicalCopy(Drawing drawing)
+ {
+ if (drawing == null)
+ return null;
+
+ var angle = drawing.Source?.Angle ?? 0.0;
+
+ // Clone program (never mutate the source).
+ var pgm = (drawing.Program.Clone() as OpenNest.CNC.Program)
+ ?? new OpenNest.CNC.Program();
+
+ if (!Tolerance.IsEqualTo(angle, 0))
+ pgm.Rotate(angle, pgm.BoundingBox().Center);
+
+ var copy = new Drawing(drawing.Name ?? string.Empty, pgm)
+ {
+ Color = drawing.Color,
+ Constraints = drawing.Constraints,
+ Material = drawing.Material,
+ Priority = drawing.Priority,
+ Customer = drawing.Customer,
+ IsCutOff = drawing.IsCutOff,
+ Source = new SourceInfo
+ {
+ Path = drawing.Source?.Path,
+ Offset = drawing.Source?.Offset ?? new Vector(0, 0),
+ Angle = 0.0,
+ },
+ };
+ return copy;
+ }
+
+ ///
+ /// Composes the source drawing's canonical angle onto each placed part so the
+ /// returned list is in the drawing's original (visible) frame.
+ ///
+ /// Derivation: let sourceAngle = S (rotation mapping source -> canonical).
+ /// Canonical part at rotation R shows visible orientation R.
+ /// Source part at rotation R' shows visible orientation R' + (-S), because the
+ /// source geometry is already rotated by -S relative to canonical.
+ /// Setting equal gives R' = R + S, so we ADD sourceAngle to each placed part.
+ ///
+ /// Rotation is performed around the part's Location so its placement position is preserved;
+ /// only the orientation composes.
+ ///
+ public static List FromCanonical(List placed, double sourceAngle)
+ {
+ if (placed == null || placed.Count == 0)
+ return placed;
+ if (Tolerance.IsEqualTo(sourceAngle, 0))
+ return placed;
+
+ foreach (var p in placed)
+ p.Rotate(sourceAngle, p.Location);
+
+ return placed;
+ }
+ }
+}
diff --git a/OpenNest.Tests/Engine/CanonicalFrameTests.cs b/OpenNest.Tests/Engine/CanonicalFrameTests.cs
new file mode 100644
index 0000000..9facb0e
--- /dev/null
+++ b/OpenNest.Tests/Engine/CanonicalFrameTests.cs
@@ -0,0 +1,84 @@
+using OpenNest.CNC;
+using OpenNest.Engine;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Tests.Engine;
+
+public class CanonicalFrameTests
+{
+ private static Drawing MakeRect(double w, double h, double rotation)
+ {
+ var pgm = new OpenNest.CNC.Program();
+ pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(w, h)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, h)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
+ if (!Tolerance.IsEqualTo(rotation, 0))
+ pgm.Rotate(rotation, pgm.BoundingBox().Center);
+ return new Drawing("rect", pgm) { Source = new SourceInfo { Angle = -rotation } };
+ }
+
+ [Fact]
+ public void AsCanonicalCopy_AxisAlignsMbr()
+ {
+ var d = MakeRect(100, 50, 0.6);
+ var canonical = CanonicalFrame.AsCanonicalCopy(d);
+
+ var bb = canonical.Program.BoundingBox();
+ var longer = System.Math.Max(bb.Length, bb.Width);
+ var shorter = System.Math.Min(bb.Length, bb.Width);
+ Assert.InRange(longer, 100 - 0.1, 100 + 0.1);
+ Assert.InRange(shorter, 50 - 0.1, 50 + 0.1);
+ Assert.Equal(0.0, canonical.Source.Angle, precision: 6);
+ }
+
+ [Fact]
+ public void AsCanonicalCopy_DoesNotMutateSource()
+ {
+ var d = MakeRect(100, 50, 0.6);
+ var originalBbox = d.Program.BoundingBox();
+ var originalAngle = d.Source.Angle;
+
+ CanonicalFrame.AsCanonicalCopy(d);
+
+ var afterBbox = d.Program.BoundingBox();
+ Assert.Equal(originalBbox.Width, afterBbox.Width, precision: 6);
+ Assert.Equal(originalBbox.Length, afterBbox.Length, precision: 6);
+ Assert.Equal(originalAngle, d.Source.Angle, precision: 6);
+ }
+
+ [Fact]
+ public void FromCanonical_ComposesSourceAngleOntoRotation()
+ {
+ var d = MakeRect(100, 50, 0.0);
+ var part = new Part(d);
+ part.Rotate(0.2); // engine returned a canonical-frame part at R = 0.2
+
+ var placed = CanonicalFrame.FromCanonical(new List { part }, sourceAngle: -0.5);
+
+ // R' = R + sourceAngle = 0.2 + (-0.5) = -0.3
+ // Part.Rotation comes from Program.Rotation which is normalized to [0, 2PI),
+ // so compare after normalizing the expected value as well.
+ Assert.Single(placed);
+ Assert.Equal(Angle.NormalizeRad(-0.3), placed[0].Rotation, precision: 4);
+ }
+
+ [Fact]
+ public void RoundTrip_RestoresGeometry()
+ {
+ var d = MakeRect(100, 50, 0.4);
+ var canonical = CanonicalFrame.AsCanonicalCopy(d);
+
+ // Place a part at origin in the canonical frame.
+ var part = Part.CreateAtOrigin(canonical);
+ var canonicalBbox = part.BoundingBox;
+
+ var placed = CanonicalFrame.FromCanonical(new List { part }, d.Source.Angle);
+
+ var originalBbox = d.Program.BoundingBox();
+ Assert.Equal(originalBbox.Width, placed[0].BoundingBox.Width, precision: 2);
+ Assert.Equal(originalBbox.Length, placed[0].BoundingBox.Length, precision: 2);
+ }
+}