using System.Collections.Generic; using System.Linq; using OpenNest.Geometry; using OpenNest.Math; namespace OpenNest.Engine.Fill { /// /// Pushes a group of parts left and down to close gaps after placement. /// Uses the same directional-distance logic as PlateView.PushSelected /// but operates on Part objects directly. /// public static class Compactor { /// /// The plate's clamp keep-outs (zones grown square by the clamp clearance) as static push /// obstacles (C1), held at zero spacing rather than the part spacing. Unlike ordinary already-overlapping parts, /// a clamp stays a wall even when a selected member straddles it: dropping it would /// let other clear members move into the zone. Empty zone lists change every /// caller's arithmetic by nothing (C2). /// private static ObstacleSet ZoneObstacles(Plate plate) => new(Jobs.InteractiveClamps.ObstacleParts(plate), 0); public static double Push(List movingParts, Plate plate, PushDirection direction) => Push(movingParts, plate, SpatialQuery.DirectionToOffset(direction, 1.0)); /// /// Pushes movingParts along an arbitrary angle (radians, 0 = right, π/2 = up). /// public static double Push(List movingParts, Plate plate, double angle) => Push(movingParts, plate, new Vector(System.Math.Cos(angle), System.Math.Sin(angle))); private static double Push(List movingParts, Plate plate, Vector direction) { var obstacleParts = plate.Parts .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) .ToList(); return Push(movingParts, plate.WorkArea(), direction, new ObstacleSet(obstacleParts, plate.PartSpacing), ZoneObstacles(plate)); } /// /// Pushes movingParts along an arbitrary angle (radians, 0 = right, π/2 = up). /// public static double Push( List movingParts, List obstacleParts, Box workArea, double partSpacing, Vector direction ) => Push(movingParts, workArea, direction, new ObstacleSet(obstacleParts, partSpacing)); /// /// Pushes movingParts to the first contact with the work-area edge or an obstacle, /// keeping each obstacle set's own spacing. /// private static double Push( List movingParts, Box workArea, Vector direction, params ObstacleSet[] obstacleSets ) { var opposite = -direction; var distance = double.MaxValue; foreach (var moving in movingParts) { var edgeDist = SpatialQuery.EdgeDistance(moving.BoundingBox, workArea, direction); if (edgeDist <= 0) distance = 0; else if (edgeDist < distance) distance = edgeDist; foreach (var set in obstacleSets) distance = set.Travel(moving, direction, opposite, distance); } if (distance < double.MaxValue && distance > 0) { var offset = direction * distance; foreach (var moving in movingParts) moving.Offset(offset); return distance; } return 0; } /// /// Obstacles kept at one spacing, with their bounds and spacing-offset contours /// cached for one push. /// private sealed class ObstacleSet { private readonly List parts; private readonly Box[] spacingBoxes; private readonly List[] entities; private readonly double halfSpacing; public ObstacleSet(List parts, double spacing) { this.parts = parts; Spacing = System.Math.Max(0, spacing); halfSpacing = Spacing / 2; Boxes = new Box[parts.Count]; spacingBoxes = new Box[parts.Count]; entities = new List[parts.Count]; for (var i = 0; i < parts.Count; i++) { Boxes[i] = parts[i].BoundingBox; spacingBoxes[i] = SpacingBounds(Boxes[i], halfSpacing); } } public double Spacing { get; } public Box[] Boxes { get; } /// The shorter of and how far /// travels before it comes within the spacing. public double Travel(Part moving, Vector direction, Vector opposite, double distance) { var movingBox = moving.BoundingBox; List movingEntities = null; // Check if any obstacle is inside the moving part — only then // do we need cutout entities on the moving part. var needCutouts = false; for (var i = 0; i < Boxes.Length; i++) { if (movingBox.Contains(Boxes[i])) { needCutouts = true; break; } } // Broad-phase bounds must enclose the spacing-offset contours. // Raw bounds can miss near passes and overestimate the safe travel. var movingSpacingBox = SpacingBounds(movingBox, halfSpacing); for (var i = 0; i < Boxes.Length; i++) { var obstacleSpacingBox = spacingBoxes[i]; var reverseGap = SpatialQuery.DirectionalGap( movingSpacingBox, obstacleSpacingBox, opposite ); if (reverseGap > 0) continue; var gap = SpatialQuery.DirectionalGap( movingSpacingBox, obstacleSpacingBox, direction ); if (gap >= distance) continue; if ( !SpatialQuery.PerpendicularOverlap( movingSpacingBox, obstacleSpacingBox, direction ) ) continue; movingEntities ??= halfSpacing > 0 ? ( needCutouts ? PartGeometry.GetOffsetPartEntities(moving, halfSpacing) : PartGeometry.GetOffsetPerimeterEntities(moving, halfSpacing) ) : ( needCutouts ? PartGeometry.GetPartEntities(moving) : PartGeometry.GetPerimeterEntities(moving) ); // A moving part can be inside an obstacle's cutout. Omitting that // loop would let it cross the inner wall before seeing the perimeter. entities[i] ??= halfSpacing > 0 ? PartGeometry.GetOffsetPartEntities(parts[i], halfSpacing) : PartGeometry.GetPartEntities(parts[i]); // Contacts left by a previous push only block directions that would // push material into material; the kernel classifies them. var d = SpatialQuery.DirectionalDistance( movingEntities, entities[i], direction ); if (d < distance) distance = d; } return distance; } } private static Box SpacingBounds(Box box, double spacing) { return new Box( box.Left - spacing, box.Bottom - spacing, box.Length + 2 * spacing, box.Width + 2 * spacing ); } private static bool IntersectsAny(Part candidate, List parts) { for (var i = 0; i < parts.Count; i++) { if (!candidate.Intersects(parts[i], out _)) continue; // Part.Intersects compares outer perimeters only. A valid insert in a // cutout must remain an obstacle, not be discarded as already overlapping. var a = new ShapeProfile(PartGeometry.GetPartEntities(candidate)); var b = new ShapeProfile(PartGeometry.GetPartEntities(parts[i])); if (a.Cutouts.Count == 0 && b.Cutouts.Count == 0) return true; if (Collision.HasOverlap( a.Perimeter.ToPolygonWithTolerance(0.001), b.Perimeter.ToPolygonWithTolerance(0.001), a.Cutouts.Select(hole => hole.ToPolygonWithTolerance(0.001)).ToList(), b.Cutouts.Select(hole => hole.ToPolygonWithTolerance(0.001)).ToList())) return true; } return false; } public static double Push( List movingParts, List obstacleParts, Box workArea, double partSpacing, PushDirection direction ) { var vector = SpatialQuery.DirectionToOffset(direction, 1.0); return Push(movingParts, obstacleParts, workArea, partSpacing, vector); } /// /// Pushes movingParts using bounding-box distances only (no geometry lines). /// Much faster but less precise — use as a coarse positioning pass before /// a full geometry Push. /// public static double PushBoundingBox( List movingParts, Plate plate, PushDirection direction ) { var obstacleParts = plate.Parts .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) .ToList(); var zones = ZoneObstacles(plate); return PushBoundingBox( movingParts, plate.WorkArea(), direction, (BoundingBoxes(obstacleParts), plate.PartSpacing), (zones.Boxes, zones.Spacing) ); } public static double PushBoundingBox( List movingParts, List obstacleParts, Box workArea, double partSpacing, PushDirection direction ) => PushBoundingBox(movingParts, workArea, direction, (BoundingBoxes(obstacleParts), partSpacing)); private static Box[] BoundingBoxes(List parts) { var boxes = new Box[parts.Count]; for (var i = 0; i < parts.Count; i++) boxes[i] = parts[i].BoundingBox; return boxes; } private static double PushBoundingBox( List movingParts, Box workArea, PushDirection direction, params (Box[] Boxes, double Spacing)[] obstacleSets ) { var opposite = SpatialQuery.OppositeDirection(direction); var isHorizontal = SpatialQuery.IsHorizontalDirection(direction); var distance = double.MaxValue; foreach (var moving in movingParts) { var edgeDist = SpatialQuery.EdgeDistance(moving.BoundingBox, workArea, direction); if (edgeDist <= 0) distance = 0; else if (edgeDist < distance) distance = edgeDist; var movingBox = moving.BoundingBox; foreach (var (obstacleBoxes, spacing) in obstacleSets) { for (var i = 0; i < obstacleBoxes.Length; i++) { var reverseGap = SpatialQuery.DirectionalGap( movingBox, obstacleBoxes[i], opposite ); if (reverseGap > 0) continue; var perpOverlap = isHorizontal ? movingBox.IsHorizontalTo(obstacleBoxes[i], out _) : movingBox.IsVerticalTo(obstacleBoxes[i], out _); if (!perpOverlap) continue; var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction); var d = gap - spacing - 0.002; if (d < 0) d = 0; if (d < distance) distance = d; } } } if (distance < double.MaxValue && distance > 0) { var offset = SpatialQuery.DirectionToOffset(direction, distance); foreach (var moving in movingParts) moving.Offset(offset); return distance; } return 0; } /// /// Settles a copied placement against the plate in both axis orders, choosing /// the group closest to the quadrant's work-area corner. A coarse box pass /// runs only when no moving box starts inside an existing part's box. /// public static void SettlePlacement( List movingParts, Plate plate, PushDirection horizontal, PushDirection vertical, int maxIterations = 20 ) { if (movingParts.Count == 0) return; var workArea = plate.WorkArea(); var skipBoxes = movingParts.Any(moving => plate.Parts.Any(obstacle => moving.BoundingBox.Intersects(obstacle.BoundingBox)) ); var bestScore = double.MaxValue; Vector[] best = null; foreach (var first in new[] { horizontal, vertical }) { var second = first == horizontal ? vertical : horizontal; var trial = movingParts.Select(p => (Part)p.Clone()).ToList(); if (!skipBoxes) { PushBoundingBox(trial, plate, first); PushBoundingBox(trial, plate, second); } for (var i = 0; i < maxIterations; i++) { var moved = Push(trial, plate, first) + Push(trial, plate, second); if (moved < 0.01) break; } var bounds = trial.GetBoundingBox(); var dx = horizontal == PushDirection.Left ? bounds.Left - workArea.Left : workArea.Right - bounds.Right; var dy = vertical == PushDirection.Down ? bounds.Bottom - workArea.Bottom : workArea.Top - bounds.Top; var score = dx * dx + dy * dy; if (score < bestScore) { bestScore = score; best = trial.Select(p => p.Location).ToArray(); } } for (var i = 0; i < movingParts.Count; i++) movingParts[i].Location = best[i]; } /// /// Repeatedly pushes parts left then down until total movement per /// iteration falls below the given threshold. /// public static void Settle( List parts, Box workArea, double partSpacing, double threshold = 0.01, int maxIterations = 20 ) { if (parts.Count < 2) return; var noObstacles = new List(); for (var i = 0; i < maxIterations; i++) { var moved = 0.0; moved += Push(parts, noObstacles, workArea, partSpacing, PushDirection.Left); moved += Push(parts, noObstacles, workArea, partSpacing, PushDirection.Down); if (moved < threshold) break; } } /// /// As , settling inside the /// plate's work area with its clamp zones as static obstacles: settling never parks /// a part inside a keep-out (C1), and a clear cluster still compacts against zones /// like any other obstacle. With no zones this behaves exactly like settling in the /// plate's work area (C2). /// public static void Settle( List parts, Plate plate, double threshold = 0.01, int maxIterations = 20 ) => Settle(parts, plate, plate.WorkArea(), threshold, maxIterations); /// Keep a fill group's own work-area bounds while excluding plate clamps. public static void Settle( List parts, Plate plate, Box workArea, double threshold = 0.01, int maxIterations = 20 ) { if (parts.Count < 2) return; var obstacles = plate.Parts.Where(part => !parts.Contains(part) && !part.BaseDrawing.IsCutOff).ToList(); var zones = Jobs.InteractiveClamps.ObstacleParts(plate); for (var i = 0; i < maxIterations; i++) { var moved = 0.0; foreach (var direction in new[] { PushDirection.Left, PushDirection.Down }) moved += Push(parts, workArea, SpatialQuery.DirectionToOffset(direction, 1.0), new ObstacleSet(obstacles, plate.PartSpacing), new ObstacleSet(zones, 0)); if (moved < threshold) break; } } } }