using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Breakoffs
{
///
/// Resolves and applies s: the joining line becomes open cuts on the
/// break layer between tabs, each travelling with the scrap on its left so the cutting
/// strategies pierce and lead in from the scrap.
///
public static class BreakoffBuilder
{
/// How close the joining line's ends must be to the outline.
public const double JoinTolerance = 0.001;
private const double ArcTolerance = 0.0005;
///
/// with every valid breakoff applied: its joining line replaced
/// by break cuts and its outline's joints made exact. Inputs are not modified; generated
/// entities are new. Breakoffs that cannot be resolved are skipped and described in
/// .
///
public static List Apply(IReadOnlyList entities, IReadOnlyList breakoffs,
out List errors)
{
errors = new List();
var working = entities.ToList();
if (breakoffs == null || breakoffs.Count == 0)
return working;
var allEdges = breakoffs.SelectMany(b => b.EdgeIds).ToHashSet();
for (var i = 0; i < breakoffs.Count; i++)
{
var layout = Layout(working, breakoffs[i], allEdges);
if (!layout.IsValid)
{
errors.Add($"Breakoff {i + 1}: {layout.Error}");
continue;
}
var replaced = new HashSet(layout.ReplacedEntities, ReferenceEqualityComparer.Instance);
working.RemoveAll(replaced.Contains);
working.AddRange(layout.Outline.Select(e => e.Clone()));
working.AddRange(layout.Cuts.SelectMany(c => c).Select(e => e.Clone()));
}
return working;
}
///
/// Resolves against (the entities that
/// will be cut). are joining lines of other breakoffs on the
/// same drawing, which are ignored while finding the outline.
///
public static BreakoffLayout Layout(IReadOnlyList entities, Breakoff breakoff,
IEnumerable otherEdgeIds = null)
{
ArgumentNullException.ThrowIfNull(entities);
ArgumentNullException.ThrowIfNull(breakoff);
if (!(breakoff.TabWidth > 0) || !double.IsFinite(breakoff.TabWidth))
return Fail("The tab width must be greater than zero.");
var edgeIds = breakoff.EdgeIds.ToHashSet();
var edgeSources = entities.Where(e => edgeIds.Contains(e.Id)).ToList();
if (edgeIds.Count == 0 || edgeSources.Count != edgeIds.Count)
return Fail("Its joining line is hidden or missing.");
var line = BreakoffPath.ChainOpen(edgeSources, JoinTolerance);
if (line == null)
return Fail("Its joining line must be one open chain.");
// Measure tabs from a fixed end so positions survive re-chaining.
var a = BreakoffPath.Start(line[0]);
var b = BreakoffPath.End(line[^1]);
if (b.X < a.X - JoinTolerance || (System.Math.Abs(b.X - a.X) <= JoinTolerance && b.Y < a.Y))
{
line = BreakoffPath.Reversed(line);
(a, b) = (b, a);
}
var excluded = new HashSet(otherEdgeIds ?? Enumerable.Empty());
excluded.UnionWith(edgeIds);
var sources = new Dictionary(ReferenceEqualityComparer.Instance);
var candidates = new List();
foreach (var entity in entities)
{
if (excluded.Contains(entity.Id) || IsMark(entity) || entity is not (Line or Arc))
continue;
var copy = BreakoffPath.Copy(entity);
sources[copy] = entity;
candidates.Add(copy);
}
Shape outline = null;
var outlineArea = 0.0;
foreach (var shape in ShapeBuilder.GetShapes(WithoutDanglingEnds(candidates)))
{
if (shape.Entities.Count < 2 || !shape.IsClosed()
|| shape.ClosestPointTo(a).DistanceTo(a) > JoinTolerance
|| shape.ClosestPointTo(b).DistanceTo(b) > JoinTolerance)
continue;
var area = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(shape.Entities, ArcTolerance)));
if (outline == null || area > outlineArea)
{
outline = shape;
outlineArea = area;
}
}
if (outline == null)
return Fail("Both ends of the joining line must touch a closed outline.");
var loop = CloseExactly(outline.Entities);
var total = BreakoffPath.Length(loop);
var atA = BreakoffPath.DistanceOf(loop, a);
var rotated = BreakoffPath.Sub(loop, atA, total).Concat(BreakoffPath.Sub(loop, 0, atA)).ToList();
var atB = BreakoffPath.DistanceOf(rotated, b);
if (atB <= JoinTolerance || atB >= total - JoinTolerance)
return Fail("The joining line must touch the outline at two different points.");
var toB = BreakoffPath.Sub(rotated, 0, atB);
var backToA = BreakoffPath.Sub(rotated, atB, total);
// The two sides: the outline from A to B closed back along the line, and the line
// closed back along the rest of the outline.
var sideTo = toB.Concat(BreakoffPath.Reversed(line)).ToList();
var sideBack = line.Concat(backToA).ToList();
var sideToArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(sideTo, ArcTolerance)));
var sideBackArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(sideBack, ArcTolerance)));
if (System.Math.Abs(sideToArea + sideBackArea - outlineArea) > 0.01 * outlineArea)
return Fail("The joining line must run inside the outline.");
var scrap = (sideToArea <= sideBackArea) != breakoff.Flip ? sideTo : sideBack;
var scrapPoints = BreakoffPath.ToPoints(scrap, ArcTolerance);
var length = BreakoffPath.Length(line);
var tabWidth = breakoff.TabWidth;
var centers = breakoff.TabCenters.Count == 0
? EvenTabCenters(length, Breakoff.DefaultTabCount, tabWidth)
: breakoff.TabCenters.OrderBy(c => c).ToList();
if (centers.Count < Breakoff.MinTabCount || length <= tabWidth * centers.Count)
return Fail("The joining line is too short for its tabs.");
if (centers[0] < tabWidth / 2 - JoinTolerance || centers[^1] > length - tabWidth / 2 + JoinTolerance)
return Fail("A tab runs off the joining line; space the tabs evenly again.");
for (var i = 1; i < centers.Count; i++)
if (centers[i] - centers[i - 1] <= tabWidth + JoinTolerance)
return Fail("Two tabs overlap; space the tabs evenly again.");
var side = SideOf(line, length, scrapPoints);
if (side == 0)
return Fail("Could not tell which side of the joining line is scrap.");
var cuts = new List>();
for (var i = 0; i + 1 < centers.Count; i++)
{
var cut = BreakoffPath.Sub(line, centers[i] + tabWidth / 2, centers[i + 1] - tabWidth / 2);
if (side < 0)
cut = BreakoffPath.Reversed(cut);
foreach (var e in cut)
{
e.Id = Guid.NewGuid();
e.Layer = SpecialLayers.Break;
}
cuts.Add(cut);
}
var replaced = outline.Entities.Select(e => sources[e]).ToList();
replaced.AddRange(edgeSources);
return new BreakoffLayout
{
BreakLine = line,
Length = length,
Outline = loop,
Scrap = scrap,
Cuts = cuts,
TabCenters = centers,
TabWidth = tabWidth,
ReplacedEntities = replaced,
};
}
/// tab centers spaced evenly with the first and last at the ends.
public static List EvenTabCenters(double length, int count, double tabWidth)
{
count = System.Math.Max(count, Breakoff.MinTabCount);
var half = tabWidth / 2;
var span = length - tabWidth;
return Enumerable.Range(0, count).Select(i => half + span * i / (count - 1)).ToList();
}
///
/// The chain of visible lines and arcs through , grown until it
/// reaches a point where it does not continue as a single path (such as where the joining
/// line meets the outline). Empty when cannot be a joining line.
///
public static List FindEdgeChain(IReadOnlyList entities, Entity picked)
{
var pool = entities.Where(e => e is Line or Arc && e.IsVisible && e.Layer?.IsVisible != false && !IsMark(e))
.ToList();
if (!pool.Contains(picked))
return new List();
var chain = new List { picked };
Grow(BreakoffPath.End(picked));
Grow(BreakoffPath.Start(picked));
return chain;
void Grow(Vector tip)
{
while (true)
{
var touching = pool.Where(e => !chain.Contains(e)
&& (BreakoffPath.Start(e).DistanceTo(tip) <= JoinTolerance
|| BreakoffPath.End(e).DistanceTo(tip) <= JoinTolerance)).ToList();
if (touching.Count != 1)
return;
var next = touching[0];
chain.Add(next);
tip = BreakoffPath.Start(next).DistanceTo(tip) <= JoinTolerance
? BreakoffPath.End(next)
: BreakoffPath.Start(next);
}
}
}
///
/// Splits each visible line or arc of where another one ends on
/// its interior (a T-junction), so a joining line can end exactly where it meets other
/// geometry. The first piece keeps the entity's ID. Returns the IDs of every split entity's
/// pieces, keyed by its original ID.
///
public static Dictionary> SplitAtJunctions(List entities)
{
var pool = entities.Where(e => e is Line or Arc && e.IsVisible && e.Layer?.IsVisible != false && !IsMark(e))
.ToList();
var ends = pool.SelectMany(e => new[] { BreakoffPath.Start(e), BreakoffPath.End(e) }).ToList();
var split = new Dictionary>();
foreach (var host in pool)
{
var box = host.BoundingBox;
var cuts = new List();
foreach (var point in ends)
{
if (point.X < box.Left - JoinTolerance || point.X > box.Right + JoinTolerance
|| point.Y < box.Bottom - JoinTolerance || point.Y > box.Top + JoinTolerance)
continue;
if (point.DistanceTo(BreakoffPath.Start(host)) <= JoinTolerance
|| point.DistanceTo(BreakoffPath.End(host)) <= JoinTolerance
|| host.ClosestPointTo(point).DistanceTo(point) > JoinTolerance)
continue;
var at = BreakoffPath.DistanceOf(new[] { host }, point);
if (cuts.All(c => System.Math.Abs(c - at) > JoinTolerance))
cuts.Add(at);
}
if (cuts.Count == 0)
continue;
cuts.Sort();
var bounds = new List { 0 };
bounds.AddRange(cuts);
bounds.Add(host.Length);
var pieces = new List();
for (var i = 0; i + 1 < bounds.Count; i++)
pieces.AddRange(BreakoffPath.Sub(new[] { host }, bounds[i], bounds[i + 1]));
pieces[0].Id = host.Id;
foreach (var piece in pieces)
piece.IsVisible = host.IsVisible;
var index = entities.IndexOf(host);
entities.RemoveAt(index);
entities.InsertRange(index, pieces);
split[host.Id] = pieces.Select(p => p.Id).ToList();
}
return split;
}
private static BreakoffLayout Fail(string error) => new(error);
// A stray open line ending on the outline (an unmarked etch line, say) would otherwise be
// chained into it and keep the loop from closing. Nothing with a free end can be part of a
// closed outline, so such entities are dropped, repeatedly, before chaining.
private static List WithoutDanglingEnds(List entities)
{
// Cluster endpoints that meet within the chain tolerance, using a grid of that size.
const double cell = Tolerance.ChainTolerance;
var grid = new Dictionary<(long, long), List<(Vector Point, int Cluster)>>();
var clusterOf = new int[entities.Count, 2];
var sizes = new List();
for (var i = 0; i < entities.Count; i++)
for (var side = 0; side < 2; side++)
{
var point = side == 0 ? BreakoffPath.Start(entities[i]) : BreakoffPath.End(entities[i]);
var gx = (long)System.Math.Floor(point.X / cell);
var gy = (long)System.Math.Floor(point.Y / cell);
var cluster = -1;
for (var dx = -1; dx <= 1 && cluster < 0; dx++)
for (var dy = -1; dy <= 1 && cluster < 0; dy++)
if (grid.TryGetValue((gx + dx, gy + dy), out var near))
foreach (var (other, id) in near)
if (other.DistanceTo(point) <= cell)
{
cluster = id;
break;
}
if (cluster < 0)
{
cluster = sizes.Count;
sizes.Add(0);
}
if (!grid.TryGetValue((gx, gy), out var bucket))
grid[(gx, gy)] = bucket = new List<(Vector, int)>();
bucket.Add((point, cluster));
sizes[cluster]++;
clusterOf[i, side] = cluster;
}
// Peel entities with a free end until none is left; closed loops never peel.
var members = new List[sizes.Count];
for (var i = 0; i < entities.Count; i++)
for (var side = 0; side < 2; side++)
(members[clusterOf[i, side]] ??= new List()).Add(i);
var removed = new bool[entities.Count];
var queue = new Queue(Enumerable.Range(0, entities.Count)
.Where(i => sizes[clusterOf[i, 0]] < 2 || sizes[clusterOf[i, 1]] < 2));
while (queue.Count > 0)
{
var i = queue.Dequeue();
if (removed[i])
continue;
removed[i] = true;
for (var side = 0; side < 2; side++)
{
var cluster = clusterOf[i, side];
if (--sizes[cluster] == 1)
foreach (var other in members[cluster])
if (!removed[other])
queue.Enqueue(other);
}
}
return entities.Where((_, i) => !removed[i]).ToList();
}
internal static bool IsMark(Entity e) =>
string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.Layer?.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase);
// The planner closes contours far tighter than the import chain tolerance, and the outline
// is re-chained from any entity when the program is built, so every joint must be exact.
internal static List CloseExactly(IReadOnlyList loop)
{
var result = new List();
for (var i = 0; i < loop.Count; i++)
{
var current = loop[i];
var next = loop[(i + 1) % loop.Count];
result.Add(current);
var end = BreakoffPath.End(current);
var start = BreakoffPath.Start(next);
if (end.DistanceTo(start) == 0)
continue;
if (next is Line nextLine)
nextLine.StartPoint = end;
else if (current is Line currentLine)
currentLine.EndPoint = start;
else if (end.DistanceTo(start) > 1e-8)
result.Add(new Line(end, start) { Layer = current.Layer, Color = current.Color, LineTypeName = current.LineTypeName });
}
return result;
}
private static Vector Normal(IReadOnlyList path, double length, double distance)
{
const double nudge = 1e-6;
var before = BreakoffPath.TangentAt(path, System.Math.Max(distance - nudge, 0));
var after = BreakoffPath.TangentAt(path, System.Math.Min(distance + nudge, length));
var t = new Vector(before.X + after.X, before.Y + after.Y);
var size = System.Math.Sqrt(t.X * t.X + t.Y * t.Y);
t = size <= Tolerance.Epsilon ? after : new Vector(t.X / size, t.Y / size);
return new Vector(-t.Y, t.X);
}
// +1 when the scrap lies left of the line's travel, -1 when right, 0 when unclear.
private static int SideOf(IReadOnlyList line, double length, IReadOnlyList scrap)
{
var probe = System.Math.Min(0.01, length * 0.01);
var votes = 0;
foreach (var fraction in new[] { 0.25, 0.5, 0.75 })
{
var d = length * fraction;
var p = BreakoffPath.PointAt(line, d);
var n = Normal(line, length, d);
var left = BreakoffPath.Contains(scrap, p + n * probe);
var right = BreakoffPath.Contains(scrap, p - n * probe);
if (left != right)
votes += left ? 1 : -1;
}
return System.Math.Sign(votes);
}
}
}