From 031264e98f52d20b15609cddc5cccceecc293f79 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 8 Mar 2026 13:28:01 -0400 Subject: [PATCH] refactor: pre-compute offset boundaries in FillLinear via PartBoundary Extract offset polygon computation into PartBoundary, which builds and caches inflated boundary polygons per unique part geometry. FillLinear now uses symmetric half-spacing and reuses boundaries across tiling passes, avoiding redundant offset calculations. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Engine/FillLinear.cs | 120 +++++++++++++++++++++------- OpenNest.Engine/PartBoundary.cs | 135 ++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 28 deletions(-) create mode 100644 OpenNest.Engine/PartBoundary.cs diff --git a/OpenNest.Engine/FillLinear.cs b/OpenNest.Engine/FillLinear.cs index 458c9f9..b804495 100644 --- a/OpenNest.Engine/FillLinear.cs +++ b/OpenNest.Engine/FillLinear.cs @@ -8,14 +8,16 @@ namespace OpenNest { public FillLinear(Box workArea, double partSpacing) { - WorkArea = workArea; PartSpacing = partSpacing; + WorkArea = new Box(workArea.X, workArea.Y, workArea.Width, workArea.Height); } public Box WorkArea { get; } public double PartSpacing { get; } + public double HalfSpacing => PartSpacing / 2; + private static Vector MakeOffset(NestDirection direction, double distance) { return direction == NestDirection.Horizontal @@ -66,18 +68,19 @@ namespace OpenNest /// /// Finds the geometry-aware copy distance between two identical parts along an axis. + /// Both parts are inflated by half-spacing for symmetric spacing. /// - private double FindCopyDistance(Part partA, NestDirection direction) + private double FindCopyDistance(Part partA, NestDirection direction, PartBoundary boundary) { var bboxDim = GetDimension(partA.BoundingBox, direction); var pushDir = GetPushDirection(direction); + var opposite = Helper.OppositeDirection(pushDir); var partB = (Part)partA.Clone(); partB.Offset(MakeOffset(direction, bboxDim)); - var opposite = Helper.OppositeDirection(pushDir); - var movingLines = Helper.GetOffsetPartLines(partB, PartSpacing, pushDir); - var stationaryLines = Helper.GetPartLines(partA, opposite); + var movingLines = boundary.GetLines(partB.Location, pushDir); + var stationaryLines = boundary.GetLines(partA.Location, opposite); var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir); return ComputeCopyDistance(bboxDim, slideDistance); @@ -87,29 +90,30 @@ namespace OpenNest /// Finds the geometry-aware copy distance between two identical patterns along an axis. /// Checks every pair of parts across adjacent patterns so that multi-part /// patterns (e.g. interlocking pairs) maintain spacing between ALL parts. + /// Both sides are inflated by half-spacing for symmetric spacing. /// - private double FindPatternCopyDistance(Pattern patternA, NestDirection direction) + private double FindPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary[] boundaries) { if (patternA.Parts.Count <= 1) - return FindSinglePartPatternCopyDistance(patternA, direction); + return FindSinglePartPatternCopyDistance(patternA, direction, boundaries[0]); var bboxDim = GetDimension(patternA.BoundingBox, direction); var pushDir = GetPushDirection(direction); var opposite = Helper.OppositeDirection(pushDir); // Compute a starting offset large enough that every part-pair in - // patternB has its offset geometry beyond patternA's raw geometry. + // patternB has its offset geometry beyond patternA's offset geometry. var startOffset = bboxDim; - foreach (var partA in patternA.Parts) + for (var i = 0; i < patternA.Parts.Count; i++) { var aUpper = direction == NestDirection.Horizontal - ? partA.BoundingBox.Right : partA.BoundingBox.Top; + ? patternA.Parts[i].BoundingBox.Right : patternA.Parts[i].BoundingBox.Top; - foreach (var refB in patternA.Parts) + for (var j = 0; j < patternA.Parts.Count; j++) { var bLower = direction == NestDirection.Horizontal - ? refB.BoundingBox.Left : refB.BoundingBox.Bottom; + ? patternA.Parts[j].BoundingBox.Left : patternA.Parts[j].BoundingBox.Bottom; var required = aUpper - bLower + PartSpacing + Tolerance.Epsilon; @@ -120,19 +124,25 @@ namespace OpenNest var patternB = patternA.Clone(MakeOffset(direction, startOffset)); + // Pre-compute stationary lines for patternA parts. + var stationaryCache = new List[patternA.Parts.Count]; + + for (var i = 0; i < patternA.Parts.Count; i++) + stationaryCache[i] = boundaries[i].GetLines(patternA.Parts[i].Location, opposite); + var maxCopyDistance = 0.0; - foreach (var partB in patternB.Parts) + for (var j = 0; j < patternB.Parts.Count; j++) { - var movingLines = Helper.GetOffsetPartLines(partB, PartSpacing, pushDir); + var partB = patternB.Parts[j]; + var movingLines = boundaries[j].GetLines(partB.Location, pushDir); - foreach (var partA in patternA.Parts) + for (var i = 0; i < patternA.Parts.Count; i++) { - var stationaryLines = Helper.GetPartLines(partA, opposite); - var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir); + var slideDistance = Helper.DirectionalDistance(movingLines, stationaryCache[i], pushDir); if (slideDistance >= double.MaxValue || slideDistance < 0) - continue; // No geometric interaction — pair doesn't constrain distance. + continue; var copyDist = startOffset - slideDistance; @@ -152,29 +162,77 @@ namespace OpenNest /// /// Fast path for single-part patterns — no cross-part conflicts possible. /// - private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction) + private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary boundary) { var bboxDim = GetDimension(patternA.BoundingBox, direction); var pushDir = GetPushDirection(direction); + var opposite = Helper.OppositeDirection(pushDir); var patternB = patternA.Clone(MakeOffset(direction, bboxDim)); - var opposite = Helper.OppositeDirection(pushDir); - var movingLines = patternB.GetOffsetLines(PartSpacing, pushDir); - var stationaryLines = patternA.GetLines(opposite); + var movingLines = GetPatternLines(patternB, boundary, pushDir); + var stationaryLines = GetPatternLines(patternA, boundary, opposite); var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir); return ComputeCopyDistance(bboxDim, slideDistance); } + /// + /// Gets offset boundary lines for all parts in a pattern using a shared boundary. + /// + private static List GetPatternLines(Pattern pattern, PartBoundary boundary, PushDirection direction) + { + var lines = new List(); + + foreach (var part in pattern.Parts) + lines.AddRange(boundary.GetLines(part.Location, direction)); + + return lines; + } + + /// + /// Creates boundaries for all parts in a pattern. Parts that share the same + /// program geometry (same drawing and rotation) reuse the same boundary instance. + /// + private PartBoundary[] CreateBoundaries(Pattern pattern) + { + var boundaries = new PartBoundary[pattern.Parts.Count]; + var cache = new List<(Drawing drawing, double rotation, PartBoundary boundary)>(); + + for (var i = 0; i < pattern.Parts.Count; i++) + { + var part = pattern.Parts[i]; + PartBoundary found = null; + + foreach (var entry in cache) + { + if (entry.drawing == part.BaseDrawing && entry.rotation.IsEqualTo(part.Rotation)) + { + found = entry.boundary; + break; + } + } + + if (found == null) + { + found = new PartBoundary(part, HalfSpacing); + cache.Add((part.BaseDrawing, part.Rotation, found)); + } + + boundaries[i] = found; + } + + return boundaries; + } + /// /// Tiles a pattern along the given axis, returning the cloned parts /// (does not include the original pattern's parts). /// - private List TilePattern(Pattern basePattern, NestDirection direction) + private List TilePattern(Pattern basePattern, NestDirection direction, PartBoundary[] boundaries) { var result = new List(); - var copyDistance = FindPatternCopyDistance(basePattern, direction); + var copyDistance = FindPatternCopyDistance(basePattern, direction, boundaries); if (copyDistance <= 0) return result; @@ -220,9 +278,11 @@ namespace OpenNest template.BoundingBox.Height > WorkArea.Height + Tolerance.Epsilon) return pattern; + var boundary = new PartBoundary(template, HalfSpacing); + pattern.Parts.Add(template); - var copyDistance = FindCopyDistance(template, direction); + var copyDistance = FindCopyDistance(template, direction, boundary); if (copyDistance <= 0) { @@ -270,10 +330,12 @@ namespace OpenNest basePattern.BoundingBox.Height > WorkArea.Height + Tolerance.Epsilon) return result; + var boundaries = CreateBoundaries(basePattern); + result.AddRange(basePattern.Parts); // Tile along the primary axis. - var primaryTiles = TilePattern(basePattern, primaryAxis); + var primaryTiles = TilePattern(basePattern, primaryAxis, boundaries); result.AddRange(primaryTiles); // Build a full-row pattern for perpendicular tiling. @@ -283,10 +345,11 @@ namespace OpenNest rowPattern.Parts.AddRange(result); rowPattern.UpdateBounds(); basePattern = rowPattern; + boundaries = CreateBoundaries(basePattern); } // Tile along the perpendicular axis. - result.AddRange(TilePattern(basePattern, PerpendicularAxis(primaryAxis))); + result.AddRange(TilePattern(basePattern, PerpendicularAxis(primaryAxis), boundaries)); return result; } @@ -302,8 +365,9 @@ namespace OpenNest if (rowPattern.Parts.Count == 0) return new List(); + var boundaries = CreateBoundaries(rowPattern); var result = new List(rowPattern.Parts); - result.AddRange(TilePattern(rowPattern, PerpendicularAxis(primaryAxis))); + result.AddRange(TilePattern(rowPattern, PerpendicularAxis(primaryAxis), boundaries)); return result; } diff --git a/OpenNest.Engine/PartBoundary.cs b/OpenNest.Engine/PartBoundary.cs new file mode 100644 index 0000000..490e901 --- /dev/null +++ b/OpenNest.Engine/PartBoundary.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// Pre-computed offset boundary polygons for a part's geometry. + /// Polygons are stored at program-local origin (no location applied) + /// and can be efficiently translated to any location when extracting lines. + /// + public class PartBoundary + { + private const double ChordTolerance = 0.01; + + private readonly List _polygons; + + public PartBoundary(Part part, double spacing) + { + var entities = ConvertProgram.ToGeometry(part.Program); + var shapes = Helper.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + _polygons = new List(); + + foreach (var shape in shapes) + { + var offsetEntity = shape.OffsetEntity(spacing + ChordTolerance, OffsetSide.Left) as Shape; + + if (offsetEntity == null) + continue; + + var polygon = offsetEntity.ToPolygonWithTolerance(ChordTolerance); + polygon.RemoveSelfIntersections(); + _polygons.Add(polygon); + } + } + + /// + /// Returns offset boundary lines translated to the given location, + /// filtered to edges whose outward normal faces the specified direction. + /// + public List GetLines(Vector location, PushDirection facingDirection) + { + var lines = new List(); + + foreach (var polygon in _polygons) + lines.AddRange(TranslateDirectionalLines(polygon, location, facingDirection)); + + return lines; + } + + /// + /// Returns all offset boundary lines translated to the given location. + /// + public List GetLines(Vector location) + { + var lines = new List(); + + foreach (var polygon in _polygons) + { + var verts = polygon.Vertices; + + if (verts.Count < 2) + continue; + + var last = verts[0].Offset(location); + + for (var i = 1; i < verts.Count; i++) + { + var current = verts[i].Offset(location); + lines.Add(new Line(last, current)); + last = current; + } + } + + return lines; + } + + private static List TranslateDirectionalLines( + Polygon polygon, Vector location, PushDirection facingDirection) + { + var verts = polygon.Vertices; + + if (verts.Count < 3) + { + var fallback = new List(); + + if (verts.Count >= 2) + { + var last = verts[0].Offset(location); + + for (var i = 1; i < verts.Count; i++) + { + var current = verts[i].Offset(location); + fallback.Add(new Line(last, current)); + last = current; + } + } + + return fallback; + } + + var sign = polygon.RotationDirection() == RotationType.CCW ? 1.0 : -1.0; + var result = new List(); + var prev = verts[0].Offset(location); + + for (var i = 1; i < verts.Count; i++) + { + var current = verts[i].Offset(location); + + // Use un-translated deltas — translation doesn't affect direction. + var dx = verts[i].X - verts[i - 1].X; + var dy = verts[i].Y - verts[i - 1].Y; + + bool keep; + + switch (facingDirection) + { + case PushDirection.Left: keep = -sign * dy > 0; break; + case PushDirection.Right: keep = sign * dy > 0; break; + case PushDirection.Up: keep = -sign * dx > 0; break; + case PushDirection.Down: keep = sign * dx > 0; break; + default: keep = true; break; + } + + if (keep) + result.Add(new Line(prev, current)); + + prev = current; + } + + return result; + } + } +}