using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using OpenNest.CutOffs;
using OpenNest.Desktop.Rendering;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Desktop.Interaction;
///
/// Finds the parts and cut-offs under a point or in a selection window, by the rules of the
/// WinForms plate view: a part is hit inside its filled material (an etch-only part inside its
/// closed mark), even-odd so holes are not part of it; cut-offs are hit on the cut strokes they
/// draw, not their nominal full-length line. Coordinates are plate (world) units.
///
///
/// Cut-off segments are parts on the plate, but they are selected as their
/// definitions (, ), never as
/// parts.
///
public sealed class PlateHitTester
{
// Degrees per chord when an arc is flattened: well under a pixel of error at working zooms.
private const double ArcStepDegrees = 3;
private readonly Func geometryOf;
private readonly Dictionary ownGeometry = new();
private readonly ConditionalWeakTable shapes = new();
///
/// The part's current display geometry, when the view already caches it; by default the hit
/// tester builds and keeps its own.
///
public PlateHitTester(Func geometryOf = null)
{
this.geometryOf = geometryOf ?? OwnGeometry;
}
/// The topmost part whose material holds , or null.
public Part PartAt(Plate plate, Vector world)
{
if (plate == null)
return null;
for (var i = plate.Parts.Count - 1; i >= 0; i--)
{
var part = plate.Parts[i];
if (!IsSelectable(part))
continue;
var box = part.BoundingBox;
if (world.X < box.Left || world.X > box.Right || world.Y < box.Bottom || world.Y > box.Top)
continue;
if (ShapeOf(part).Contains(world - part.Location))
return part;
}
return null;
}
///
/// The parts selects, in plate order: in
/// , those whose material bounds lie inside it; in
/// , those whose material it touches.
///
public IReadOnlyList PartsIn(Plate plate, SelectionWindow window)
{
var result = new List();
if (plate == null)
return result;
foreach (var part in plate.Parts)
{
if (!IsSelectable(part))
continue;
var shape = ShapeOf(part);
if (shape.Bounds == null)
continue;
var local = window.RelativeTo(part.Location);
var hit = window.Mode == WindowSelectionMode.Contains
? local.Contains(shape.Bounds)
: shape.Touches(local);
if (hit)
result.Add(part);
}
return result;
}
///
/// The first cut-off with a cut stroke within of
/// , or null. The WinForms view uses five pixels.
///
public CutOff CutOffAt(Plate plate, Vector world, double tolerance)
{
if (plate?.CutOffs == null)
return null;
foreach (var cutOff in plate.CutOffs)
{
foreach (var stroke in CutOff.CutStrokes(cutOff.Drawing?.Program))
{
if (stroke.ClosestPointTo(world).DistanceTo(world) <= tolerance)
return cutOff;
}
}
return null;
}
///
/// The cut-offs selects: in ,
/// those whose every cut stroke lies inside it; in ,
/// those with a stroke it touches. A cut-off with no strokes is never selected.
///
public IReadOnlyList CutOffsIn(Plate plate, SelectionWindow window)
{
var result = new List();
if (plate?.CutOffs == null)
return result;
foreach (var cutOff in plate.CutOffs)
{
var any = false;
var matches = window.Mode == WindowSelectionMode.Contains;
foreach (var stroke in CutOff.CutStrokes(cutOff.Drawing?.Program))
{
any = true;
if (window.Mode == WindowSelectionMode.Contains)
{
if (!window.Contains(stroke.StartPoint) || !window.Contains(stroke.EndPoint))
{
matches = false;
break;
}
}
else if (window.Intersects(stroke.StartPoint, stroke.EndPoint))
{
matches = true;
break;
}
}
if (any && matches)
result.Add(cutOff);
}
return result;
}
/// Whether is selected as a part: everything but cut-off segments.
public static bool IsSelectable(Part part) => part?.BaseDrawing is { IsCutOff: false };
private PartDisplayGeometry OwnGeometry(Part part)
{
if (!ownGeometry.TryGetValue(part, out var geometry) || !geometry.IsCurrentFor(part))
ownGeometry[part] = geometry = PartDisplayGeometry.Build(part);
return geometry;
}
private HitShape ShapeOf(Part part) => shapes.GetValue(geometryOf(part), HitShape.Build);
/// Drops the parts no longer on from the hit tester's own cache.
public void Trim(Plate plate)
{
if (ownGeometry.Count == 0)
return;
var shown = plate == null ? new HashSet() : new HashSet(plate.Parts);
var stale = new List();
foreach (var part in ownGeometry.Keys)
if (!shown.Contains(part))
stale.Add(part);
foreach (var part in stale)
ownGeometry.Remove(part);
}
/// A part's selection figures flattened to closed rings in its program frame.
private sealed class HitShape
{
private readonly List rings;
private HitShape(List rings, Box bounds)
{
this.rings = rings;
Bounds = bounds;
}
public Box Bounds { get; }
public static HitShape Build(PartDisplayGeometry geometry)
{
var figures = geometry.Selection;
var rings = new List();
foreach (var figure in figures)
{
var ring = Flatten(figure);
if (ring.Count >= 2)
rings.Add(ring.ToArray());
}
return new HitShape(rings, ProgramGeometry.Bounds(figures));
}
private static List Flatten(PathFigure figure)
{
var points = new List { figure.Start };
foreach (var segment in figure.Segments)
{
if (segment is ArcTo arc)
{
var steps = System.Math.Max(2, (int)System.Math.Ceiling(System.Math.Abs(arc.SweepAngle) / ArcStepDegrees));
for (var i = 0; i <= steps; i++)
{
var radians = Angle.ToRadians(arc.StartAngle + arc.SweepAngle * i / steps);
points.Add(new Vector(arc.Center.X + arc.Radius * System.Math.Cos(radians),
arc.Center.Y + arc.Radius * System.Math.Sin(radians)));
}
}
else
{
points.Add(segment.End);
}
}
return points;
}
/// Even-odd containment over every ring, each closed back to its start as a fill closes it.
public bool Contains(Vector point)
{
var inside = false;
foreach (var ring in rings)
{
for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++)
{
var a = ring[i];
var b = ring[j];
if ((a.Y > point.Y) != (b.Y > point.Y)
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
inside = !inside;
}
}
return inside;
}
/// Whether the window touches the filled region: crosses an edge or lies within the fill.
public bool Touches(SelectionWindow window)
{
if (!window.Overlaps(Bounds))
return false;
foreach (var ring in rings)
{
for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++)
{
if (window.Intersects(ring[j], ring[i]))
return true;
}
}
// No edge enters the window, so it lies wholly inside or wholly outside the fill.
return Contains(new Vector((window.Left + window.Right) / 2, (window.Bottom + window.Top) / 2));
}
}
}