Compare commits

...

10 Commits

Author SHA1 Message Date
aj 1a7e458282 fix(core): arc bounding box inflated for near-zero sweep arcs
Arcs with sweep angles smaller than Tolerance.Epsilon were treated as
full circles by IsBetweenRad's shortcut check, causing UpdateBounds to
expand the bounding box to Center ± Radius. This made zoom-to-fit zoom
out far beyond the actual part extents.

Skip cardinal angle expansion when sweep is near-zero so the bounding
box uses only the arc's start/end points.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:20:39 -04:00
aj d26853ee11 fix(io): handle flipped OCS normal on DXF ellipse import
Ellipses with extrusion direction Z=-1 had their parametric direction
reversed, causing the curve to appear mirrored. Negate start/end
parameters when Normal.Z < 0 to correct the minor-axis traversal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:01:45 -04:00
aj 2245d28d55 fix(engine): canonicalize PlaceBestFitPairs builds to match BestFitCache frame 2026-04-20 09:43:56 -04:00
aj 9df97c2cf2 feat(engine): wrap single-item Fill with canonicalize/un-rotate bookends 2026-04-20 09:36:13 -04:00
aj f5a51bd9cd feat(engine): BestFitCache operates in canonical frame; TryPlaceBestFitPair builds from canonical drawing 2026-04-20 09:26:34 -04:00
aj 9de492bae1 feat(engine): extract ML features from canonical drawing frame 2026-04-20 09:23:21 -04:00
aj 55849fb0bb refactor(engine): share MBR between PartClassifier and CanonicalAngle 2026-04-20 09:20:33 -04:00
aj a56b85918d docs(core): refresh SourceInfo.Angle doc now that setter wiring lands 2026-04-20 09:18:54 -04:00
aj d7f009575d feat(core): store Source.Angle; recompute when Program changes 2026-04-20 09:13:37 -04:00
aj 79129c9428 feat(engine): add CanonicalFrame helper for drawing-to-canonical rotation 2026-04-20 09:06:30 -04:00
13 changed files with 499 additions and 50 deletions
+31 -1
View File
@@ -54,9 +54,9 @@ namespace OpenNest
Id = Interlocked.Increment(ref nextId); Id = Interlocked.Increment(ref nextId);
Name = name; Name = name;
Material = new Material(); Material = new Material();
Program = pgm;
Constraints = new NestConstraints(); Constraints = new NestConstraints();
Source = new SourceInfo(); Source = new SourceInfo();
Program = pgm;
} }
public int Id { get; } public int Id { get; }
@@ -78,9 +78,29 @@ namespace OpenNest
{ {
program = value; program = value;
UpdateArea(); UpdateArea();
RecomputeCanonicalAngle();
} }
} }
/// <summary>
/// Recomputes and stores the canonical angle from the current Program.
/// Callers that mutate Program in place (rather than reassigning it) must invoke this explicitly.
/// Cut-off drawings are left with Angle=0.
/// </summary>
public void RecomputeCanonicalAngle()
{
if (Source == null)
Source = new SourceInfo();
if (program == null || IsCutOff)
{
Source.Angle = 0.0;
return;
}
Source.Angle = CanonicalAngle.Compute(this);
}
public Color Color { get; set; } public Color Color { get; set; }
public bool IsCutOff { get; set; } public bool IsCutOff { get; set; }
@@ -163,5 +183,15 @@ namespace OpenNest
/// Offset distances to the original location. /// Offset distances to the original location.
/// </summary> /// </summary>
public Vector Offset { get; set; } public Vector Offset { get; set; }
/// <summary>
/// Rotation (radians) that maps the source program geometry to its canonical
/// (MBR-axis-aligned) frame. Populated automatically by the <see cref="Drawing.Program"/>
/// setter via <see cref="CanonicalAngle.Compute"/>. A value of 0 means the drawing is
/// already canonical or <see cref="Drawing.IsCutOff"/> is true. Callers that mutate
/// <see cref="Drawing.Program"/> in place must invoke
/// <see cref="Drawing.RecomputeCanonicalAngle"/> to refresh.
/// </summary>
public double Angle { get; set; }
} }
} }
+4 -1
View File
@@ -404,10 +404,12 @@ namespace OpenNest.Geometry
maxY = startpt.Y; maxY = startpt.Y;
} }
var sweep = SweepAngle();
if (sweep > Tolerance.Epsilon)
{
var angle1 = StartAngle; var angle1 = StartAngle;
var angle2 = EndAngle; var angle2 = EndAngle;
// switch the angle to counter clockwise.
if (IsReversed) if (IsReversed)
Generic.Swap(ref angle1, ref angle2); Generic.Swap(ref angle1, ref angle2);
@@ -424,6 +426,7 @@ namespace OpenNest.Geometry
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2)) if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
maxX = Center.X + Radius; maxX = Center.X + Radius;
}
boundingBox.X = minX; boundingBox.X = minX;
boundingBox.Y = minY; boundingBox.Y = minY;
+10 -4
View File
@@ -24,6 +24,9 @@ namespace OpenNest.Engine.BestFit
if (_cache.TryGetValue(key, out var cached)) if (_cache.TryGetValue(key, out var cached))
return cached; return cached;
// Operate on the canonical frame so cached pair positions are orientation-invariant.
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
IPairEvaluator evaluator = null; IPairEvaluator evaluator = null;
ISlideComputer slideComputer = null; ISlideComputer slideComputer = null;
@@ -31,7 +34,7 @@ namespace OpenNest.Engine.BestFit
{ {
if (CreateEvaluator != null) if (CreateEvaluator != null)
{ {
try { evaluator = CreateEvaluator(drawing, spacing); } try { evaluator = CreateEvaluator(canonical, spacing); }
catch { /* fall back to default evaluator */ } catch { /* fall back to default evaluator */ }
} }
@@ -42,7 +45,7 @@ namespace OpenNest.Engine.BestFit
} }
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer); var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
var results = finder.FindBestFits(drawing, spacing, StepSize); var results = finder.FindBestFits(canonical, spacing, StepSize);
_cache.TryAdd(key, results); _cache.TryAdd(key, results);
return results; return results;
@@ -86,9 +89,12 @@ namespace OpenNest.Engine.BestFit
try try
{ {
// Operate on the canonical frame so cached pair positions are orientation-invariant.
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
if (CreateEvaluator != null) if (CreateEvaluator != null)
{ {
try { evaluator = CreateEvaluator(drawing, spacing); } try { evaluator = CreateEvaluator(canonical, spacing); }
catch { /* fall back to default evaluator */ } catch { /* fall back to default evaluator */ }
} }
@@ -100,7 +106,7 @@ namespace OpenNest.Engine.BestFit
// Compute candidates and evaluate once with the largest plate. // Compute candidates and evaluate once with the largest plate.
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer); var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
var baseResults = finder.FindBestFits(drawing, spacing, StepSize); var baseResults = finder.FindBestFits(canonical, spacing, StepSize);
// Cache a filtered copy for each plate size. // Cache a filtered copy for each plate size.
foreach (var size in needed) foreach (var size in needed)
+76
View File
@@ -0,0 +1,76 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Engine
{
/// <summary>
/// Produces transient canonical (MBR-axis-aligned) copies of drawings for engine consumption
/// and un-rotates placed parts back to the drawing's original frame.
/// </summary>
public static class CanonicalFrame
{
/// <summary>
/// Returns a new Drawing whose Program geometry is rotated to the canonical frame.
/// The source drawing is not mutated.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public static List<Part> FromCanonical(List<Part> 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;
}
}
}
+63 -19
View File
@@ -47,14 +47,29 @@ namespace OpenNest
PhaseResults.Clear(); PhaseResults.Clear();
AngleResults.Clear(); AngleResults.Clear();
// Fast path: for very small quantities, skip the full strategy pipeline. // Replace the item's Drawing with a canonical copy for the duration of this fill.
if (item.Quantity > 0 && item.Quantity <= 2) // All internal methods see canonical geometry; this wrapper un-canonicalizes the final result.
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
var originalDrawing = item.Drawing;
var canonicalItem = new NestItem
{ {
var fast = TryFillSmallQuantity(item, workArea); Drawing = CanonicalFrame.AsCanonicalCopy(item.Drawing),
if (fast != null && fast.Count >= item.Quantity) Quantity = item.Quantity,
Priority = item.Priority,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
StepAngle = item.StepAngle,
};
// Fast path for qty 1-2.
if (canonicalItem.Quantity > 0 && canonicalItem.Quantity <= 2)
{ {
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={item.Quantity}"); var fast = TryFillSmallQuantity(canonicalItem, workArea);
if (fast != null && fast.Count >= canonicalItem.Quantity)
{
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}");
WinnerPhase = NestPhase.Pairs; WinnerPhase = NestPhase.Pairs;
fast = RebindAndUnCanonicalize(fast, originalDrawing, sourceAngle);
ReportProgress(progress, new ProgressReport ReportProgress(progress, new ProgressReport
{ {
Phase = WinnerPhase, Phase = WinnerPhase,
@@ -68,32 +83,30 @@ namespace OpenNest
} }
} }
// For low quantities, shrink the work area in both dimensions to avoid
// running expensive strategies against the full plate.
var effectiveWorkArea = workArea; var effectiveWorkArea = workArea;
if (item.Quantity > 0) if (canonicalItem.Quantity > 0)
{ {
effectiveWorkArea = ShrinkWorkArea(item, workArea, Plate.PartSpacing); effectiveWorkArea = ShrinkWorkArea(canonicalItem, workArea, Plate.PartSpacing);
if (effectiveWorkArea != workArea) if (effectiveWorkArea != workArea)
Debug.WriteLine($"[Fill] Low-qty shrink: {item.Quantity} requested, " + Debug.WriteLine($"[Fill] Low-qty shrink: {canonicalItem.Quantity} requested, " +
$"from {workArea.Width:F1}x{workArea.Length:F1} " + $"from {workArea.Width:F1}x{workArea.Length:F1} " +
$"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}"); $"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}");
} }
var best = RunFillPipeline(item, effectiveWorkArea, progress, token); var best = RunFillPipeline(canonicalItem, effectiveWorkArea, progress, token);
// Fallback: if the reduced area didn't yield enough, retry with full area. if (canonicalItem.Quantity > 0 && best.Count < canonicalItem.Quantity && effectiveWorkArea != workArea)
if (item.Quantity > 0 && best.Count < item.Quantity && effectiveWorkArea != workArea)
{ {
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {item.Quantity}, retrying full area"); Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {canonicalItem.Quantity}, retrying full area");
PhaseResults.Clear(); PhaseResults.Clear();
AngleResults.Clear(); AngleResults.Clear();
best = RunFillPipeline(item, workArea, progress, token); best = RunFillPipeline(canonicalItem, workArea, progress, token);
} }
if (item.Quantity > 0 && best.Count > item.Quantity) if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity)
best = ShrinkFiller.TrimToCount(best, item.Quantity, TrimAxis); best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis);
best = RebindAndUnCanonicalize(best, originalDrawing, sourceAngle);
ReportProgress(progress, new ProgressReport ReportProgress(progress, new ProgressReport
{ {
@@ -108,6 +121,31 @@ namespace OpenNest
return best; return best;
} }
/// <summary>
/// Single exit point for canonical -> source frame conversion. Rebinds every Part to the
/// original Drawing (so consumers see the user's drawing identity, not the transient canonical copy)
/// and composes sourceAngle onto each Part's rotation via CanonicalFrame.FromCanonical.
/// </summary>
private static List<Part> RebindAndUnCanonicalize(List<Part> parts, Drawing original, double sourceAngle)
{
if (parts == null || parts.Count == 0)
return parts;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
// Rebind to `original` while preserving world pose. CreateAtOrigin rotates
// at the origin (keeping bbox at world (0,0)) then we offset to match p's bbox.
var rebound = Part.CreateAtOrigin(original, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return CanonicalFrame.FromCanonical(parts, sourceAngle);
}
/// <summary> /// <summary>
/// Fast path for qty 1-2: place a single part or a best-fit pair /// Fast path for qty 1-2: place a single part or a best-fit pair
/// without running the full strategy pipeline. /// without running the full strategy pipeline.
@@ -139,6 +177,10 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute( var bestFits = BestFitCache.GetOrCompute(
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing); drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
// Build pair candidates with a canonical drawing so their geometry matches
// the coordinate frame of the cached fit results.
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(drawing);
List<Part> bestPlacement = null; List<Part> bestPlacement = null;
foreach (var fit in bestFits) foreach (var fit in bestFits)
@@ -152,7 +194,7 @@ namespace OpenNest
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon) if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
continue; continue;
var landscape = fit.BuildParts(drawing); var landscape = fit.BuildParts(canonicalDrawing);
var portrait = RotatePair90(landscape); var portrait = RotatePair90(landscape);
var lFits = TryOffsetToWorkArea(landscape, workArea); var lFits = TryOffsetToWorkArea(landscape, workArea);
@@ -174,6 +216,8 @@ namespace OpenNest
bestPlacement = candidate; bestPlacement = candidate;
} }
// Parts are returned in canonical frame, bound to the canonical drawing.
// The outer Fill wrapper (Task 7) rebinds to `drawing` and composes sourceAngle onto rotation.
return bestPlacement; return bestPlacement;
} }
+9 -6
View File
@@ -27,7 +27,10 @@ namespace OpenNest.Engine.ML
{ {
public static PartFeatures Extract(Drawing drawing) public static PartFeatures Extract(Drawing drawing)
{ {
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(drawing.Program) // Normalize to canonical frame so features are invariant to import orientation.
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(canonical.Program)
.Where(e => e.Layer != SpecialLayers.Rapid) .Where(e => e.Layer != SpecialLayers.Rapid)
.ToList(); .ToList();
@@ -45,18 +48,18 @@ namespace OpenNest.Engine.ML
var features = new PartFeatures var features = new PartFeatures
{ {
Area = drawing.Area, Area = canonical.Area,
Convexity = drawing.Area / (hullArea > 0 ? hullArea : 1.0), Convexity = canonical.Area / (hullArea > 0 ? hullArea : 1.0),
AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0), AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0),
BoundingBoxFill = drawing.Area / (bb.Area() > 0 ? bb.Area() : 1.0), BoundingBoxFill = canonical.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
VertexCount = polygon.Vertices.Count, VertexCount = polygon.Vertices.Count,
Bitmask = GenerateBitmask(polygon, 32) Bitmask = GenerateBitmask(polygon, 32)
}; };
// Circularity = 4 * PI * Area / Perimeter^2 // Circularity = 4 * PI * Area / Perimeter^2
var perimeterLen = polygon.Perimeter(); var perimeterLen = polygon.Perimeter();
features.Circularity = (4 * System.Math.PI * drawing.Area) / (perimeterLen * perimeterLen); features.Circularity = (4 * System.Math.PI * canonical.Area) / (perimeterLen * perimeterLen);
features.PerimeterToAreaRatio = drawing.Area > 0 ? perimeterLen / drawing.Area : 0; features.PerimeterToAreaRatio = canonical.Area > 0 ? perimeterLen / canonical.Area : 0;
return features; return features;
} }
+35 -1
View File
@@ -334,6 +334,12 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute( var bestFits = BestFitCache.GetOrCompute(
item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing); item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
// BestFitCache stores pair coordinates in canonical frame. Build candidates
// from a canonical drawing copy so geometry and coords share a frame; rebind
// + un-rotate winning pair to the original drawing's frame before returning.
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing);
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
List<Part> bestPlacement = null; List<Part> bestPlacement = null;
Box bestTarget = null; Box bestTarget = null;
@@ -342,7 +348,7 @@ namespace OpenNest
if (!fit.Keep) if (!fit.Keep)
continue; continue;
var parts = fit.BuildParts(item.Drawing); var parts = fit.BuildParts(canonicalDrawing);
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox(); var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var pairW = pairBbox.Width; var pairW = pairBbox.Width;
var pairL = pairBbox.Length; var pairL = pairBbox.Length;
@@ -374,6 +380,10 @@ namespace OpenNest
if (bestPlacement == null) continue; if (bestPlacement == null) continue;
// Rebind to the original drawing and compose sourceAngle onto rotation so the
// final placed parts sit in the user's visible frame.
bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing, sourceAngle);
result.AddRange(bestPlacement); result.AddRange(bestPlacement);
item.Quantity = 0; item.Quantity = 0;
@@ -388,6 +398,30 @@ namespace OpenNest
return result; return result;
} }
/// <summary>
/// Rebinds each canonical-frame Part in the pair to the original Drawing at its current
/// world pose, then composes sourceAngle onto each via CanonicalFrame.FromCanonical so
/// the returned list is in the original drawing's visible frame. Mirrors
/// DefaultNestEngine.RebindAndUnCanonicalize.
/// </summary>
private static List<Part> RebindPairToOriginal(List<Part> parts, Drawing original, double sourceAngle)
{
if (parts == null || parts.Count == 0)
return parts;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
var rebound = Part.CreateAtOrigin(original, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return CanonicalFrame.FromCanonical(parts, sourceAngle);
}
/// <summary> /// <summary>
/// Determines whether a drawing should use grid-fill (true) or bin-pack (false). /// Determines whether a drawing should use grid-fill (true) or bin-pack (false).
/// Low-quantity items whose total area is a small fraction of the plate are /// Low-quantity items whose total area is a small fraction of the plate are
+2 -2
View File
@@ -64,8 +64,8 @@ namespace OpenNest.Engine
var mbrArea = mbr.Area; var mbrArea = mbr.Area;
var mbrPerimeter = 2 * (mbr.Width + mbr.Height); var mbrPerimeter = 2 * (mbr.Width + mbr.Height);
// Store primary angle (negated to align MBR with axes, same as RotationAnalysis). // Share the single angle formula with CanonicalAngle (no duplicate MBR compute).
result.PrimaryAngle = -mbr.Angle; result.PrimaryAngle = CanonicalAngle.FromMbr(mbr);
// Drawing perimeter for circularity and perimeter ratio. // Drawing perimeter for circularity and perimeter ratio.
var drawingPerimeter = polygon.Perimeter(); var drawingPerimeter = polygon.Perimeter();
+12 -3
View File
@@ -181,13 +181,22 @@ namespace OpenNest.IO
{ {
var center = new Vector(ellipse.Center.X, ellipse.Center.Y); var center = new Vector(ellipse.Center.X, ellipse.Center.Y);
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y); var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y);
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var semiMinor = semiMajor * ellipse.RadiusRatio;
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
var startParam = ellipse.StartParameter; var startParam = ellipse.StartParameter;
var endParam = ellipse.EndParameter; var endParam = ellipse.EndParameter;
if (ellipse.Normal.Z < 0)
{
var newStart = OpenNest.Math.Angle.TwoPI - endParam;
var newEnd = OpenNest.Math.Angle.TwoPI - startParam;
startParam = newStart;
endParam = newEnd;
}
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var semiMinor = semiMajor * ellipse.RadiusRatio;
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
var layer = ellipse.Layer.ToOpenNest(); var layer = ellipse.Layer.ToOpenNest();
var color = ellipse.ResolveColor(); var color = ellipse.ResolveColor();
var lineTypeName = ellipse.ResolveLineTypeName(); var lineTypeName = ellipse.ResolveLineTypeName();
+3
View File
@@ -4,6 +4,9 @@
<RootNamespace>OpenNest.IO</RootNamespace> <RootNamespace>OpenNest.IO</RootNamespace>
<AssemblyName>OpenNest.IO</AssemblyName> <AssemblyName>OpenNest.IO</AssemblyName>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Tests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" /> <ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" /> <ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
@@ -97,3 +97,60 @@ public class CanonicalAngleTests
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6); Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
} }
} }
public class DrawingCanonicalAngleWiringTests
{
private static OpenNest.CNC.Program RotatedRectProgram(double w, double h, double theta)
{
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 (!OpenNest.Math.Tolerance.IsEqualTo(theta, 0))
pgm.Rotate(theta, pgm.BoundingBox().Center);
return pgm;
}
[Fact]
public void Constructor_ComputesAngleOnProgramAssignment()
{
var pgm = RotatedRectProgram(100, 50, 0.5);
var d = new Drawing("r", pgm);
Assert.InRange(d.Source.Angle, -0.52, -0.48);
}
[Fact]
public void SetProgram_RecomputesAngle()
{
var d = new Drawing("r", RotatedRectProgram(100, 50, 0.0));
Assert.Equal(0.0, d.Source.Angle, precision: 6);
d.Program = RotatedRectProgram(100, 50, 0.5);
Assert.InRange(d.Source.Angle, -0.52, -0.48);
}
[Fact]
public void IsCutOff_SkipsAngleComputation()
{
var d = new Drawing("cut", RotatedRectProgram(100, 50, 0.5)) { IsCutOff = true };
// Re-assign after flag is set so the setter observes IsCutOff.
d.Program = RotatedRectProgram(100, 50, 0.5);
Assert.Equal(0.0, d.Source.Angle, precision: 6);
}
[Fact]
public void RecomputeCanonicalAngle_UpdatesAfterMutation()
{
var d = new Drawing("r", RotatedRectProgram(100, 50, 0.0));
Assert.Equal(0.0, d.Source.Angle, precision: 6);
// Mutate in-place (doesn't trigger setter).
d.Program.Rotate(0.5, d.Program.BoundingBox().Center);
Assert.Equal(0.0, d.Source.Angle, precision: 6); // still stale
d.RecomputeCanonicalAngle();
Assert.InRange(d.Source.Angle, -0.52, -0.48);
}
}
@@ -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> { 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> { 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);
}
}
@@ -1,14 +1,19 @@
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math; using OpenNest.Math;
using Xunit; using Xunit;
using Xunit.Abstractions;
using System.Linq; using System.Linq;
namespace OpenNest.Tests.Geometry; namespace OpenNest.Tests.Geometry;
public class EllipseConverterTests public class EllipseConverterTests
{ {
private readonly ITestOutputHelper _output;
private const double Tol = 1e-10; private const double Tol = 1e-10;
public EllipseConverterTests(ITestOutputHelper output) => _output = output;
[Fact] [Fact]
public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd() public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd()
{ {
@@ -244,6 +249,101 @@ public class EllipseConverterTests
} }
} }
[Fact]
public void DxfImport_ArcBoundingBoxes_Diagnostic()
{
var path = @"C:\Users\aisaacs\Desktop\11ga tab.dxf";
if (!System.IO.File.Exists(path)) return;
var result = Dxf.Import(path);
var all = (System.Collections.Generic.IEnumerable<IBoundable>)result.Entities;
var bbox = all.GetBoundingBox();
_output.WriteLine($"Overall: X={bbox.X:F4} Y={bbox.Y:F4} W={bbox.Length:F4} H={bbox.Width:F4}");
for (var i = 0; i < result.Entities.Count; i++)
{
var e = result.Entities[i];
var b = e.BoundingBox;
var flag = (b.Length > 1 || b.Width > 1) ? " ***" : "";
_output.WriteLine($"{i + 1,3}. {e.GetType().Name,-8} X={b.X:F4} Y={b.Y:F4} W={b.Length:F4} H={b.Width:F4}{flag}");
}
}
[Fact]
public void ToOpenNest_FlippedNormalZ_ProducesCorrectArcs()
{
var normal = new ACadSharp.Entities.Ellipse
{
Center = new CSMath.XYZ(-0.275, -0.245, 0),
MajorAxisEndPoint = new CSMath.XYZ(0.0001, 1.245, 0),
RadiusRatio = 0.28,
StartParameter = 0.017,
EndParameter = 1.571,
Normal = new CSMath.XYZ(0, 0, 1)
};
var flipped = new ACadSharp.Entities.Ellipse
{
Center = new CSMath.XYZ(0.275, -0.245, 0),
MajorAxisEndPoint = new CSMath.XYZ(-0.0001, 1.245, 0),
RadiusRatio = 0.28,
StartParameter = 0.017,
EndParameter = 1.571,
Normal = new CSMath.XYZ(0, 0, -1)
};
var normalArcs = normal.ToOpenNest();
var flippedArcs = flipped.ToOpenNest();
Assert.True(normalArcs.Count > 0);
Assert.True(flippedArcs.Count > 0);
Assert.True(normalArcs.All(e => e is Arc));
Assert.True(flippedArcs.All(e => e is Arc));
var normalFirst = (Arc)normalArcs.First();
var flippedFirst = (Arc)flippedArcs.First();
var normalStart = GetArcStart(normalFirst);
var flippedStart = GetArcStart(flippedFirst);
Assert.True(normalStart.X < 0, $"Normal ellipse start X should be negative, got {normalStart.X}");
Assert.True(flippedStart.X > 0, $"Flipped ellipse should bulge right, got {flippedStart.X}");
var normalBbox = GetBoundingBox(normalArcs.Cast<Arc>());
var flippedBbox = GetBoundingBox(flippedArcs.Cast<Arc>());
Assert.True(flippedBbox.minX > 0, $"Flipped ellipse should stay on positive X side, minX={flippedBbox.minX}");
Assert.True(normalBbox.maxX < 0, $"Normal ellipse should stay on negative X side, maxX={normalBbox.maxX}");
}
private static (double minX, double maxX) GetBoundingBox(IEnumerable<Arc> arcs)
{
var minX = double.MaxValue;
var maxX = double.MinValue;
foreach (var arc in arcs)
{
var s = GetArcStart(arc);
var e = GetArcEnd(arc);
minX = System.Math.Min(minX, System.Math.Min(s.X, e.X));
maxX = System.Math.Max(maxX, System.Math.Max(s.X, e.X));
}
return (minX, maxX);
}
private static Vector GetArcStart(Arc arc)
{
var angle = arc.IsReversed ? arc.EndAngle : arc.StartAngle;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
}
private static Vector GetArcEnd(Arc arc)
{
var angle = arc.IsReversed ? arc.StartAngle : arc.EndAngle;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
}
private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter, private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter,
double semiMajor, double semiMinor, double rotation, int samples) double semiMajor, double semiMinor, double rotation, int samples)
{ {