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)); } } }