From 70ca4c4be6827d677a0fc769206b3dddd00f15c4 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 12 Nov 2026 19:29:02 -0500 Subject: [PATCH] refactor(core): split Plate into partial files by concern Moves whole members verbatim into Plate.CommonLines.cs, Plate.Clamps.cs, Plate.CutOffs.cs, Plate.Transforms.cs and Plate.Sizing.cs. No behavior or API change. Co-Authored-By: Claude Fable 5.2 --- OpenNest.Core/Plate.Clamps.cs | 159 ++++++ OpenNest.Core/Plate.CommonLines.cs | 56 ++ OpenNest.Core/Plate.CutOffs.cs | 282 ++++++++++ OpenNest.Core/Plate.Sizing.cs | 186 ++++++ OpenNest.Core/Plate.Transforms.cs | 237 ++++++++ OpenNest.Core/Plate.cs | 874 +---------------------------- 6 files changed, 921 insertions(+), 873 deletions(-) create mode 100644 OpenNest.Core/Plate.Clamps.cs create mode 100644 OpenNest.Core/Plate.CommonLines.cs create mode 100644 OpenNest.Core/Plate.CutOffs.cs create mode 100644 OpenNest.Core/Plate.Sizing.cs create mode 100644 OpenNest.Core/Plate.Transforms.cs diff --git a/OpenNest.Core/Plate.Clamps.cs b/OpenNest.Core/Plate.Clamps.cs new file mode 100644 index 00000000..26b069f5 --- /dev/null +++ b/OpenNest.Core/Plate.Clamps.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using OpenNest.Collections; +using OpenNest.Geometry; + +namespace OpenNest +{ + public partial class Plate + { + /// + /// Fixed rectangular machine-clamp keep-outs in plate coordinates + /// (). Never parts: excluded from quantity, utilization, + /// cut sequence and CNC output; placement rules and display consume them. Mutate + /// through this list; fires for every change. + /// + public ObservableList ClampZones { get; } + + /// + /// Raised after content changes so views can refresh + /// the zone overlay. Carries no geometry arguments. + /// + public event EventHandler ClampZonesChanged; + + private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty); + + /// + /// The machine clamp rail this plate follows, or null when its + /// are explicit (engine sheets, files without a rail). While + /// set, the zones are derived: re-resolved whenever or + /// changes, because the clamps are fixed to the machine. + /// + public ClampRail ClampRail { get; private set; } + + /// + /// The gap part material keeps from every clamp zone: the rail's own + /// , else the bottom edge spacing (D6), never + /// negative. Part spacing never applies to clamps. + /// + public double ClampClearance => + System.Math.Max(0, ClampRail?.Clearance ?? EdgeSpacing.Bottom); + + private static readonly IReadOnlySet NoRemovedClamps = ImmutableHashSet.Empty; + + /// + /// Rail clamp ids taken off this sheet, by Auto Nest as a last resort (D8) so a part + /// that could not be nested anywhere with every clamp in place still fits, or by hand + /// (). Empty unless + /// is set; already excludes them, so + /// nothing else needs to consult this list to avoid them. Ids stay listed across a + /// resize/rotate even when the matching clamp is temporarily beyond the sheet, so it + /// re-appears removed if a later resize brings it back onto the sheet (C13). + /// + public IReadOnlySet RemovedClamps { get; private set; } = NoRemovedClamps; + + /// + /// Minutes this sheet's cut-time estimate gains from (D9): + /// each listed clamp the rail places on this sheet adds + /// once; a listed clamp that is beyond + /// the sheet (or unknown to the rail) is not pulled and costs nothing. Zero when there + /// is no rail (removal is meaningless without one to remove clamps from). + /// + public double ClampRemovalMinutes => + ClampRail == null || RemovedClamps.Count == 0 + ? 0 + : ClampRail.ResolveFor(Size, Quadrant).Count(zone => RemovedClamps.Contains(zone.Id)) + * ClampRail.RemovalMinutes; + + /// + /// Follows and replaces the zones with its resolution for + /// this sheet; null detaches and removes every zone. Unchanged zones raise no events. + /// + public void SetClampRail(ClampRail rail) => SetClampRail(rail, null); + + /// + /// Follows with the clamps named in + /// taken off this sheet (D8): zones become the rail's resolution for this sheet minus + /// those ids. Null detaches, clears every zone and forgets the + /// removed set (removal only means something while following a rail). + /// + public void SetClampRail(ClampRail rail, IEnumerable removedClamps) + { + ClampRail = rail; + RemovedClamps = rail == null || removedClamps == null + ? NoRemovedClamps + : removedClamps.ToImmutableHashSet(StringComparer.Ordinal); + ReplaceClampZones(ResolveRailZones()); + } + + /// + /// The rail clamp this sheet places at (the parts' placement + /// frame, see ), whether in place or removed, grown by + /// on every side; null without a rail or a clamp there. + /// Returned bounds are in the placement frame. + /// + public ClampZone RailClampAt(Vector point, double tolerance = 0) + { + if (ClampRail == null) + return null; + var left = Quadrant is 2 or 3 ? -Size.Length : 0; + var bottom = Quadrant is 3 or 4 ? -Size.Width : 0; + foreach (var zone in ClampRail.ResolveFor(Size, Quadrant)) + { + var box = zone.Bounds.Translate(left, bottom); + if (box.Offset(tolerance).Contains(point)) + return new ClampZone(zone.Id, box); + } + return null; + } + + /// + /// Takes the rail clamp off this sheet, or puts it back, as a + /// manual counterpart to Auto Nest's last-resort removal (D8): the zones and + /// follow. False when nothing changed: no rail, a clamp + /// the rail does not place on this sheet, or one already in the requested state. + /// + public bool SetClampRemoved(string id, bool removed) + { + if (ClampRail == null || id == null || RemovedClamps.Contains(id) == removed) + return false; + if (removed && !ClampRail.ResolveFor(Size, Quadrant).Any(zone => zone.Id == id)) + return false; + var ids = removed ? RemovedClamps.Append(id) : RemovedClamps.Where(x => x != id); + SetClampRail(ClampRail, ids); + return true; + } + + private void ResolveClampRail() + { + // Called from the constructor's Size assignment before the zone list exists. + if (ClampRail != null) + ReplaceClampZones(ResolveRailZones()); + } + + private IReadOnlyList ResolveRailZones() + { + if (ClampRail == null) + return Array.Empty(); + var resolved = ClampRail.ResolveFor(Size, Quadrant); + if (RemovedClamps.Count == 0) + return resolved; + var kept = new List(resolved.Count); + foreach (var zone in resolved) + if (!RemovedClamps.Contains(zone.Id)) + kept.Add(zone); + return kept; + } + + private void ReplaceClampZones(IReadOnlyList zones) + { + if (ClampRail.SameZones(ClampZones, zones)) + return; + ClampZones.Clear(); + foreach (var zone in zones) + ClampZones.Add(zone); + } + } +} diff --git a/OpenNest.Core/Plate.CommonLines.cs b/OpenNest.Core/Plate.CommonLines.cs new file mode 100644 index 00000000..6e7c07b8 --- /dev/null +++ b/OpenNest.Core/Plate.CommonLines.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Collections; + +namespace OpenNest +{ + public partial class Plate + { + /// + /// Groups of parts placed one kerf apart to share common-line cuts (see + /// docs/common-line-cutting.md). A part removed from leaves its group. + /// + public ObservableList CommonLines { get; } + + /// The common-line group holding , or null. + public CommonLineGroup CommonLineGroupOf(Part part) => + CommonLines.FirstOrDefault(group => group.Contains(part)); + + /// + /// Puts into one common-line group at , + /// merging any groups they already belong to. Every part must be on this plate. + /// + public CommonLineGroup JoinCommonLine(IEnumerable parts, double kerf) + { + ArgumentNullException.ThrowIfNull(parts); + var joined = parts.ToList(); + if (joined.Any(part => part == null || !Parts.Contains(part))) + throw new ArgumentException("Every part in a common line must be on the plate.", nameof(parts)); + var merged = CommonLines.Where(group => joined.Any(group.Contains)).ToList(); + var members = merged.SelectMany(group => group.Members).Concat(joined); + var result = new CommonLineGroup(members, kerf); + foreach (var group in merged) + CommonLines.Remove(group); + CommonLines.Add(result); + return result; + } + + /// + /// Cuts every common line twice, as slice 1 did: each member cuts its whole perimeter and + /// the second pass runs in kerf the first opened. Off by default: Plan Cutting cuts each + /// shared line once and each member's remaining edges after it (see + /// docs/common-line-cutting.md). Choose this for a post that cannot write single-pass cuts. + /// + public bool CutCommonLinesTwice { get; set; } + + /// + /// True when the plate's common lines are cut once: it has groups and does not cut them twice. + /// + public bool CutsCommonLinesOnce => CommonLines.Count > 0 && !CutCommonLinesTwice; + + /// Removes the common-line group holding ; true when there was one. + public bool RemoveCommonLine(Part part) => + CommonLineGroupOf(part) is { } group && CommonLines.Remove(group); + } +} diff --git a/OpenNest.Core/Plate.CutOffs.cs b/OpenNest.Core/Plate.CutOffs.cs new file mode 100644 index 00000000..6cb4bbc6 --- /dev/null +++ b/OpenNest.Core/Plate.CutOffs.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Collections; +using OpenNest.Geometry; + +namespace OpenNest +{ + public partial class Plate + { + /// + /// The cut-off lines defined on this plate. + /// + public ObservableList CutOffs { get; set; } + + /// + /// A copy of the settings the cut-offs were last regenerated with, or null before the + /// first regeneration. Saved with the nest, so reopening it rebuilds the same cut-off + /// geometry rather than geometry from built-in defaults. + /// + public CutOffSettings CutOffSettings { get; set; } + + /// + /// Regenerates all cut-off drawings and materializes each of their trimmed segments as + /// a part. Existing cut-off parts are removed first, then each regenerated segment goes + /// back to the place in the cut sequence ( order) of the saved segment + /// of its cut-off it overlaps most, so a part drag or cut-off move doesn't send it to the + /// end of the sequence; a segment with no saved counterpart follows the segment before + /// it. New cut-offs are added at the end. + /// + public void RegenerateCutOffs(CutOffSettings settings) + { + var saved = new Dictionary>(); + var places = new List(); + for (var i = Parts.Count - 1; i >= 0; i--) + { + if (!Parts[i].BaseDrawing.IsCutOff) + continue; + + places.Add(i); + var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing)); + if (cutoff != null) + { + if (!saved.TryGetValue(cutoff, out var segments)) + saved[cutoff] = segments = new List<(int, double, double)>(); + var extent = CutOffSegments.Extent(Parts[i], cutoff.Axis == CutOffAxis.Vertical); + segments.Add((i, extent?.Start ?? double.NaN, extent?.End ?? double.NaN)); + } + + Parts.RemoveAt(i); + } + + RegenerateCutOffs(settings, (cutoff, extents) => + { + if (!saved.TryGetValue(cutoff, out var segments)) + return null; + var indices = new int[extents.Count]; + var previous = segments.Min(s => s.Index); + for (var k = 0; k < extents.Count; k++) + { + var (start, end) = extents[k]; + var best = -1; + var bestOverlap = 0.0; + foreach (var (index, savedStart, savedEnd) in segments) + { + var overlap = System.Math.Min(end, savedEnd) - System.Math.Max(start, savedStart); + if (overlap > bestOverlap) + { + best = index; + bestOverlap = overlap; + } + } + indices[k] = previous = best >= 0 ? best : previous; + } + return indices; + }, _ => places); + } + + /// + /// Regenerates all cut-off drawings and materializes each of their trimmed segments as a + /// part, placing a cut-off's segments consecutively from its index in + /// (its place in order). Cut-offs missing + /// from it are added at the end. Callers must remove existing cut-off parts first. + /// + public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary sequence) => + RegenerateCutOffs(settings, null, sequence); + + /// + /// As , + /// but each cut-off lists one index per segment in ascending order along its axis. A list + /// whose length differs from the regenerated segment count places the segments + /// consecutively at its smallest index. + /// + public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary> sequence) => + RegenerateCutOffs(settings, sequence, null); + + /// + /// Places each cut-off's regenerated segments where its saved segments were, among the + /// parts saved around them: lists every saved segment's place; + /// gives only the first, for files that recorded no more + /// (their segments were consecutive, but how many is unknown). Settings other than the + /// saved ones can regenerate a different number of segments; the parts keep their + /// places relative to the cut-offs either way. Where only a first place is known the + /// count is a guess, so a cut-off that splits the sheet goes last, as sequencing puts it. + /// + public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary> places, + IReadOnlyDictionary firstPlaces) => + RegenerateCutOffs(settings, (cutoff, extents) => + { + if (places != null && places.TryGetValue(cutoff, out var indices) && indices is { Count: > 0 }) + { + if (indices.Count != extents.Count) + return Enumerable.Repeat(indices.Min(), extents.Count).ToArray(); + // Program order can run toward the origin; the file lists segments ascending. + var ascending = Enumerable.Range(0, extents.Count).OrderBy(k => extents[k].Start).ToArray(); + var result = new int[extents.Count]; + for (var rank = 0; rank < ascending.Length; rank++) + result[ascending[rank]] = indices[rank]; + return result; + } + return firstPlaces != null && firstPlaces.TryGetValue(cutoff, out var first) + ? Enumerable.Repeat(first, extents.Count).ToArray() : null; + }, counts => + { + var saved = new HashSet(); + foreach (var list in places?.Values ?? []) + saved.UnionWith(list ?? []); + if (firstPlaces == null) + return saved; + // A cut-off saved by its first place alone held consecutive places, as many as it + // has segments now unless that runs into the next cut-off's first place. + var starts = saved.Concat(firstPlaces.Values).Distinct().Order().ToArray(); + foreach (var (cutoff, first) in firstPlaces) + { + var next = starts.FirstOrDefault(start => start > first, int.MaxValue); + var count = System.Math.Max(1, System.Math.Min(counts.GetValueOrDefault(cutoff, 1), next - first)); + saved.UnionWith(Enumerable.Range(first, count)); + } + return saved; + }, cutoff => firstPlaces != null && firstPlaces.ContainsKey(cutoff)); + + // sequence gives a cut-off's saved place for each segment in program order, from its + // segments' extents along its axis (NaN when unmeasurable), or null to append them. + // savedPlaces gives every place cut-off segments held in the saved order, from each + // cut-off's regenerated segment count; the parts held the others, in their order. + // separateLast says which cut-offs go after everything when they split the sheet. + private void RegenerateCutOffs(CutOffSettings settings, + Func, IReadOnlyList> sequence, + Func, IEnumerable> savedPlaces, + Func separateLast = null) + { + var cache = BuildPerimeterCache(this); + var sheet = BoundingBox(includeParts: false); + var placed = new List<(int Index, int Order, Part Part)>(); + var separating = new List(); + var counts = new Dictionary(); + var partCount = Parts.Count; + CutOffSettings = settings?.Copy(); + + // Regenerate and materialize each cut-off's segments + foreach (var cutoff in CutOffs) + { + cutoff.Regenerate(this, settings, cache); + + var programs = cutoff.SegmentPrograms(); + if (programs.Count == 0) + continue; + + var vertical = cutoff.Axis == CutOffAxis.Vertical; + var parts = programs.Select(program => new Part(cutoff.Drawing, program)).ToArray(); + var extents = parts.Select(part => CutOffSegments.Extent(part, vertical) ?? (double.NaN, double.NaN)).ToArray(); + var indices = sequence?.Invoke(cutoff, extents); + counts[cutoff] = parts.Length; + for (var k = 0; k < parts.Length; k++) + { + // A line splitting the sheet comes after every part and every other cut-off. + if (separateLast?.Invoke(cutoff) == true && CutOff.SeparatesSheet(parts[k], cutoff, sheet)) + separating.Add(parts[k]); + else if (indices != null && k < indices.Count) + placed.Add((indices[k], placed.Count, parts[k])); + else + Parts.Add(parts[k]); + } + } + + // Saved places count every cut-off segment then on the plate, which may now be more or + // fewer. A segment goes after the parts saved before it and the segments placed before + // it, so a changed count never shifts a later cut-off or part. Segments sharing a place + // keep their program order. + var saved = (savedPlaces?.Invoke(counts) ?? []).Distinct().Order().ToArray(); + var inserted = 0; + foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenBy(p => p.Order)) + { + var savedBefore = Array.BinarySearch(saved, index); + savedBefore = savedBefore < 0 ? ~savedBefore : savedBefore; + var partsBefore = System.Math.Clamp(index - savedBefore, 0, partCount); + Parts.Insert(partsBefore + inserted++, part); + } + foreach (var part in separating) + Parts.Add(part); + } + + /// + /// Builds a dictionary mapping each non-cut-off part to its perimeter entity. + /// Closed shapes use ShapeProfile; open contours fall back to ConvexHull. + /// + public static Dictionary BuildPerimeterCache(Plate plate) + { + var cache = new Dictionary(); + + foreach (var part in plate.Parts) + { + if (part.BaseDrawing.IsCutOff) + continue; + + cache[part] = BuildPartPerimeter(part); + } + + return cache; + } + + /// + /// Converts one part's own program into its placed perimeter entity (closed + /// profile, or a convex hull fallback for a genuinely open contour). This is + /// the same conversion runs per part; callers + /// without a pre-built cache (e.g. a direct, uncached + /// call) use it to get the same conservative, converted-material envelope instead + /// of trusting the part's cached bounding box alone. + /// + public static Geometry.Entity BuildPartPerimeter(Part part) + { + // Null is the conservative answer: the cut-off then excludes the part's whole + // bounding-box span. Only malformed geometry earns it; anything else propagates. + if (part?.Program == null) + return null; + + try + { + var entities = Converters + .ConvertProgram.ToGeometry(part.Program) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ToList(); + + if (entities.Count == 0) + return null; + + // Leads are cutting paths in scrap, not boundary edges. Chaining them + // into the outline can make a closed part appear open and replace its + // recesses with a convex hull. A cut-off may cross a lead, so the open- + // contour hull also leaves them out: hulling a lead's pierce point would + // stop cut-offs far short of the part. + var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin && + e.Layer != SpecialLayers.Leadout).ToList(); + if (outline.Count == 0) + return null; + var shapes = Geometry.ShapeBuilder.GetShapes(outline); + if (shapes.Count == 0) + return null; + var profile = Geometry.ShapeProfile.FromShapes(shapes); + + if (profile.Perimeter.IsClosed()) + { + var perimeter = profile.Perimeter; + perimeter.Offset(part.Location); + return perimeter; + } + + var points = outline.CollectPoints(); + if (points.Count < 3) + return null; + + var hull = Geometry.ConvexHull.Compute(points); + hull.Offset(part.Location); + return hull; + } + catch (Exception e) when (Diagnostics.PlateOverlapAnalyzer.IsGeometryFailure(e)) + { + return null; + } + } + } +} diff --git a/OpenNest.Core/Plate.Sizing.cs b/OpenNest.Core/Plate.Sizing.cs new file mode 100644 index 00000000..e1959e75 --- /dev/null +++ b/OpenNest.Core/Plate.Sizing.cs @@ -0,0 +1,186 @@ +using System.Linq; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + public partial class Plate + { + /// + /// The smallest box that contains the plate. + /// + /// + /// + public Box BoundingBox(bool includeParts = true) + { + var plateBox = new Box(); + + // Width = Y axis (vertical), Length = X axis (horizontal) + switch (Quadrant) + { + case 1: + plateBox.X = 0; + plateBox.Y = 0; + break; + + case 2: + plateBox.X = (float)-Size.Length; + plateBox.Y = 0; + break; + + case 3: + plateBox.X = (float)-Size.Length; + plateBox.Y = (float)-Size.Width; + break; + + case 4: + plateBox.X = 0; + plateBox.Y = (float)-Size.Width; + break; + + default: + return new Box(); + } + + plateBox.Width = Size.Width; + plateBox.Length = Size.Length; + + if (!includeParts) + return plateBox; + + var boundingBox = new Box(); + var partsBox = Parts.GetBoundingBox(); + + boundingBox.X = partsBox.Left < plateBox.Left ? partsBox.Left : plateBox.Left; + + boundingBox.Y = partsBox.Bottom < plateBox.Bottom ? partsBox.Bottom : plateBox.Bottom; + + boundingBox.Length = + partsBox.Right > plateBox.Right + ? partsBox.Right - boundingBox.X + : plateBox.Right - boundingBox.X; + + boundingBox.Width = + partsBox.Top > plateBox.Top + ? partsBox.Top - boundingBox.Y + : plateBox.Top - boundingBox.Y; + + return boundingBox; + } + + /// + /// The area within the edge spacing. + /// + /// + public Box WorkArea() + { + var box = BoundingBox(false); + + box.X += EdgeSpacing.Left; + box.Y += EdgeSpacing.Bottom; + box.Length -= EdgeSpacing.Left + EdgeSpacing.Right; + box.Width -= EdgeSpacing.Top + EdgeSpacing.Bottom; + + return box; + } + + /// + /// The offset that centers the parts' combined bounds in the , + /// so uneven edge spacing (such as a wider clamp-side margin) shifts them with it. + /// Zero when the plate has no parts. + /// + public Vector OffsetToCenterPartsInWorkArea() => + Parts.Count == 0 ? new Vector() : WorkArea().Center - Parts.GetBoundingBox().Center; + + /// + /// Automatically sizes the plate to fit the parts. + /// + /// The factor to round the actual size up to. + /// + /// AutoSize 9.7 x 10.1 + /// * roundingFactor=1.0 new Size=10 x 11 + /// * roundingFactor=0.5 new Size=10 x 10.5 + /// * roundingFactor=0.25 new Size=9.75 x 10.25 + /// * roundingFactor=0.0 new Size=9.7 x 10.1 + /// + public void AutoSize(double roundingFactor = 1.0) + { + // An outlined sheet is a particular piece of material; it cannot be resized to fit. + if (Parts.Count == 0 || Outline != null) + return; + + var bounds = Parts.GetBoundingBox(); + + // Convention: Length = X axis, Width = Y axis + double xExtent; + double yExtent; + + switch (Quadrant) + { + case 1: + xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right; + yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top; + break; + + case 2: + xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left; + yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top; + break; + + case 3: + xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left; + yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom; + break; + + case 4: + xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right; + yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom; + break; + + default: + return; + } + + Size = new Size( + Rounding.RoundUpToNearest(yExtent, roundingFactor), + Rounding.RoundUpToNearest(xExtent, roundingFactor) + ); + } + + /// + /// Gets the area of the top surface of the plate: the material inside its + /// when it has one, otherwise the whole rectangle. + /// + /// + public double Area() + { + return Outline?.Area ?? Size.Width * Size.Length; + } + + /// + /// Gets the volume of the plate. + /// + public double Volume(double thickness) + { + return Area() * thickness; + } + + /// + /// Gets the weight of the plate. + /// + public double Weight(double thickness, double density) + { + return Volume(thickness) * density; + } + + /// + /// Percentage of the material used. + /// + /// Returns a number between 0.0 and 1.0 + public double Utilization() + { + return Parts.Where(p => !p.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) + / Area(); + } + } +} diff --git a/OpenNest.Core/Plate.Transforms.cs b/OpenNest.Core/Plate.Transforms.cs new file mode 100644 index 00000000..1c26df54 --- /dev/null +++ b/OpenNest.Core/Plate.Transforms.cs @@ -0,0 +1,237 @@ +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + public partial class Plate + { + /// + /// Rotates the plate clockwise or counter-clockwise along with all parts. + /// + /// + /// + public void Rotate90(RotationType rotationDirection, bool keepSameQuadrant = true) + { + const double oneAndHalfPI = System.Math.PI * 1.5; + + var turned = Outline?.Rotated90(rotationDirection); + Size = new Size(Size.Length, Size.Width); + Outline = turned; + + // After Size swap above, new Size.Width = old Length (old X extent), + // new Size.Length = old Width (old Y extent). + // Convention: Length = X axis, Width = Y axis. + if (rotationDirection == RotationType.CW) + { + Rotate(oneAndHalfPI); + + if (keepSameQuadrant) + { + switch (Quadrant) + { + case 1: + Offset(0, Size.Width); + break; + + case 2: + Offset(-Size.Length, 0); + break; + + case 3: + Offset(0, -Size.Width); + break; + + case 4: + Offset(Size.Length, 0); + break; + + default: + return; + } + } + else + { + Quadrant = Quadrant < 2 ? 4 : Quadrant - 1; + } + } + else + { + Rotate(Angle.HalfPI); + + if (keepSameQuadrant) + { + switch (Quadrant) + { + case 1: + Offset(Size.Length, 0); + break; + + case 2: + Offset(0, Size.Width); + break; + + case 3: + Offset(-Size.Length, 0); + break; + + case 4: + Offset(0, -Size.Width); + break; + + default: + return; + } + } + else + { + Quadrant = Quadrant > 3 ? 1 : Quadrant + 1; + } + } + } + + /// + /// Rotates the plate 180 degrees along with all parts. + /// + /// + public void Rotate180(bool keepSameQuadrant = true) + { + if (keepSameQuadrant) + { + Vector centerpt; + + switch (Quadrant) + { + case 1: + centerpt = new Vector(Size.Length * 0.5, Size.Width * 0.5); + break; + + case 2: + centerpt = new Vector(-Size.Length * 0.5, Size.Width * 0.5); + break; + + case 3: + centerpt = new Vector(-Size.Length * 0.5, -Size.Width * 0.5); + break; + + case 4: + centerpt = new Vector(Size.Length * 0.5, -Size.Width * 0.5); + break; + + default: + return; + } + + Rotate(System.Math.PI, centerpt); + Outline = Outline?.Rotated180(); + } + else + { + Rotate(System.Math.PI); + Outline = Outline?.Rotated180(); + Quadrant = (Quadrant + 2) % 4; + + if (Quadrant == 0) + Quadrant = 4; + } + } + + /// + /// Rotates the parts on the plate. + /// + /// + public void Rotate(double angle) + { + for (var i = Parts.Count - 1; i >= 0; i--) + { + if (Parts[i].BaseDrawing.IsCutOff) + Parts.RemoveAt(i); + } + + for (var i = 0; i < Parts.Count; ++i) + { + var part = Parts[i]; + part.Rotate(angle); + } + + foreach (var cutoff in CutOffs) + cutoff.Position = cutoff.Position.Rotate(angle); + } + + /// + /// Rotates the parts on the plate around the specified origin. + /// + /// + /// + public void Rotate(double angle, Vector origin) + { + for (var i = Parts.Count - 1; i >= 0; i--) + { + if (Parts[i].BaseDrawing.IsCutOff) + Parts.RemoveAt(i); + } + + for (var i = 0; i < Parts.Count; ++i) + { + var part = Parts[i]; + part.Rotate(angle, origin); + } + + foreach (var cutoff in CutOffs) + { + var pos = cutoff.Position - origin; + pos = pos.Rotate(angle); + cutoff.Position = pos + origin; + } + } + + /// + /// Offsets the parts on the plate. + /// + /// + /// + public void Offset(double x, double y) + { + // Remove cut-off parts before transforming + for (var i = Parts.Count - 1; i >= 0; i--) + { + if (Parts[i].BaseDrawing.IsCutOff) + Parts.RemoveAt(i); + } + + for (var i = 0; i < Parts.Count; ++i) + { + var part = Parts[i]; + part.Offset(x, y); + } + + // Transform cut-off positions + foreach (var cutoff in CutOffs) + cutoff.Position = new Vector(cutoff.Position.X + x, cutoff.Position.Y + y); + } + + /// + /// Offsets the parts on the plate. + /// + /// + public void Offset(Vector voffset) + { + for (var i = Parts.Count - 1; i >= 0; i--) + { + if (Parts[i].BaseDrawing.IsCutOff) + Parts.RemoveAt(i); + } + + for (var i = 0; i < Parts.Count; ++i) + { + var part = Parts[i]; + part.Offset(voffset); + } + + foreach (var cutoff in CutOffs) + cutoff.Position = new Vector( + cutoff.Position.X + voffset.X, + cutoff.Position.Y + voffset.Y + ); + } + } +} diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index e64c124d..33dfbaa2 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -1,15 +1,13 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; using OpenNest.Collections; using OpenNest.Geometry; -using OpenNest.Math; using OpenNest.Support; namespace OpenNest { - public class Plate + public partial class Plate { private int quadrant; @@ -168,96 +166,6 @@ namespace OpenNest /// public ObservableList Parts { get; set; } - /// - /// The cut-off lines defined on this plate. - /// - public ObservableList CutOffs { get; set; } - - /// - /// Groups of parts placed one kerf apart to share common-line cuts (see - /// docs/common-line-cutting.md). A part removed from leaves its group. - /// - public ObservableList CommonLines { get; } - - /// The common-line group holding , or null. - public CommonLineGroup CommonLineGroupOf(Part part) => - CommonLines.FirstOrDefault(group => group.Contains(part)); - - /// - /// Puts into one common-line group at , - /// merging any groups they already belong to. Every part must be on this plate. - /// - public CommonLineGroup JoinCommonLine(IEnumerable parts, double kerf) - { - ArgumentNullException.ThrowIfNull(parts); - var joined = parts.ToList(); - if (joined.Any(part => part == null || !Parts.Contains(part))) - throw new ArgumentException("Every part in a common line must be on the plate.", nameof(parts)); - var merged = CommonLines.Where(group => joined.Any(group.Contains)).ToList(); - var members = merged.SelectMany(group => group.Members).Concat(joined); - var result = new CommonLineGroup(members, kerf); - foreach (var group in merged) - CommonLines.Remove(group); - CommonLines.Add(result); - return result; - } - - /// - /// Cuts every common line twice, as slice 1 did: each member cuts its whole perimeter and - /// the second pass runs in kerf the first opened. Off by default: Plan Cutting cuts each - /// shared line once and each member's remaining edges after it (see - /// docs/common-line-cutting.md). Choose this for a post that cannot write single-pass cuts. - /// - public bool CutCommonLinesTwice { get; set; } - - /// - /// True when the plate's common lines are cut once: it has groups and does not cut them twice. - /// - public bool CutsCommonLinesOnce => CommonLines.Count > 0 && !CutCommonLinesTwice; - - /// Removes the common-line group holding ; true when there was one. - public bool RemoveCommonLine(Part part) => - CommonLineGroupOf(part) is { } group && CommonLines.Remove(group); - - /// - /// A copy of the settings the cut-offs were last regenerated with, or null before the - /// first regeneration. Saved with the nest, so reopening it rebuilds the same cut-off - /// geometry rather than geometry from built-in defaults. - /// - public CutOffSettings CutOffSettings { get; set; } - - /// - /// Fixed rectangular machine-clamp keep-outs in plate coordinates - /// (). Never parts: excluded from quantity, utilization, - /// cut sequence and CNC output; placement rules and display consume them. Mutate - /// through this list; fires for every change. - /// - public ObservableList ClampZones { get; } - - /// - /// Raised after content changes so views can refresh - /// the zone overlay. Carries no geometry arguments. - /// - public event EventHandler ClampZonesChanged; - - private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty); - - /// - /// The machine clamp rail this plate follows, or null when its - /// are explicit (engine sheets, files without a rail). While - /// set, the zones are derived: re-resolved whenever or - /// changes, because the clamps are fixed to the machine. - /// - public ClampRail ClampRail { get; private set; } - - /// - /// The gap part material keeps from every clamp zone: the rail's own - /// , else the bottom edge spacing (D6), never - /// negative. Part spacing never applies to clamps. - /// - public double ClampClearance => - System.Math.Max(0, ClampRail?.Clearance ?? EdgeSpacing.Bottom); - /// /// The machine support bed this sheet rests on (its nest's, see /// ), or null for none. Display, analysis and support @@ -265,204 +173,6 @@ namespace OpenNest /// public SupportBed SupportBed { get; set; } - private static readonly IReadOnlySet NoRemovedClamps = ImmutableHashSet.Empty; - - /// - /// Rail clamp ids taken off this sheet, by Auto Nest as a last resort (D8) so a part - /// that could not be nested anywhere with every clamp in place still fits, or by hand - /// (). Empty unless - /// is set; already excludes them, so - /// nothing else needs to consult this list to avoid them. Ids stay listed across a - /// resize/rotate even when the matching clamp is temporarily beyond the sheet, so it - /// re-appears removed if a later resize brings it back onto the sheet (C13). - /// - public IReadOnlySet RemovedClamps { get; private set; } = NoRemovedClamps; - - /// - /// Minutes this sheet's cut-time estimate gains from (D9): - /// each listed clamp the rail places on this sheet adds - /// once; a listed clamp that is beyond - /// the sheet (or unknown to the rail) is not pulled and costs nothing. Zero when there - /// is no rail (removal is meaningless without one to remove clamps from). - /// - public double ClampRemovalMinutes => - ClampRail == null || RemovedClamps.Count == 0 - ? 0 - : ClampRail.ResolveFor(Size, Quadrant).Count(zone => RemovedClamps.Contains(zone.Id)) - * ClampRail.RemovalMinutes; - - /// - /// Follows and replaces the zones with its resolution for - /// this sheet; null detaches and removes every zone. Unchanged zones raise no events. - /// - public void SetClampRail(ClampRail rail) => SetClampRail(rail, null); - - /// - /// Follows with the clamps named in - /// taken off this sheet (D8): zones become the rail's resolution for this sheet minus - /// those ids. Null detaches, clears every zone and forgets the - /// removed set (removal only means something while following a rail). - /// - public void SetClampRail(ClampRail rail, IEnumerable removedClamps) - { - ClampRail = rail; - RemovedClamps = rail == null || removedClamps == null - ? NoRemovedClamps - : removedClamps.ToImmutableHashSet(StringComparer.Ordinal); - ReplaceClampZones(ResolveRailZones()); - } - - /// - /// The rail clamp this sheet places at (the parts' placement - /// frame, see ), whether in place or removed, grown by - /// on every side; null without a rail or a clamp there. - /// Returned bounds are in the placement frame. - /// - public ClampZone RailClampAt(Vector point, double tolerance = 0) - { - if (ClampRail == null) - return null; - var left = Quadrant is 2 or 3 ? -Size.Length : 0; - var bottom = Quadrant is 3 or 4 ? -Size.Width : 0; - foreach (var zone in ClampRail.ResolveFor(Size, Quadrant)) - { - var box = zone.Bounds.Translate(left, bottom); - if (box.Offset(tolerance).Contains(point)) - return new ClampZone(zone.Id, box); - } - return null; - } - - /// - /// Takes the rail clamp off this sheet, or puts it back, as a - /// manual counterpart to Auto Nest's last-resort removal (D8): the zones and - /// follow. False when nothing changed: no rail, a clamp - /// the rail does not place on this sheet, or one already in the requested state. - /// - public bool SetClampRemoved(string id, bool removed) - { - if (ClampRail == null || id == null || RemovedClamps.Contains(id) == removed) - return false; - if (removed && !ClampRail.ResolveFor(Size, Quadrant).Any(zone => zone.Id == id)) - return false; - var ids = removed ? RemovedClamps.Append(id) : RemovedClamps.Where(x => x != id); - SetClampRail(ClampRail, ids); - return true; - } - - private void ResolveClampRail() - { - // Called from the constructor's Size assignment before the zone list exists. - if (ClampRail != null) - ReplaceClampZones(ResolveRailZones()); - } - - private IReadOnlyList ResolveRailZones() - { - if (ClampRail == null) - return Array.Empty(); - var resolved = ClampRail.ResolveFor(Size, Quadrant); - if (RemovedClamps.Count == 0) - return resolved; - var kept = new List(resolved.Count); - foreach (var zone in resolved) - if (!RemovedClamps.Contains(zone.Id)) - kept.Add(zone); - return kept; - } - - private void ReplaceClampZones(IReadOnlyList zones) - { - if (ClampRail.SameZones(ClampZones, zones)) - return; - ClampZones.Clear(); - foreach (var zone in zones) - ClampZones.Add(zone); - } - - /// - /// Regenerates all cut-off drawings and materializes each of their trimmed segments as - /// a part. Existing cut-off parts are removed first, then each regenerated segment goes - /// back to the place in the cut sequence ( order) of the saved segment - /// of its cut-off it overlaps most, so a part drag or cut-off move doesn't send it to the - /// end of the sequence; a segment with no saved counterpart follows the segment before - /// it. New cut-offs are added at the end. - /// - public void RegenerateCutOffs(CutOffSettings settings) - { - var saved = new Dictionary>(); - var places = new List(); - for (var i = Parts.Count - 1; i >= 0; i--) - { - if (!Parts[i].BaseDrawing.IsCutOff) - continue; - - places.Add(i); - var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing)); - if (cutoff != null) - { - if (!saved.TryGetValue(cutoff, out var segments)) - saved[cutoff] = segments = new List<(int, double, double)>(); - var extent = CutOffSegments.Extent(Parts[i], cutoff.Axis == CutOffAxis.Vertical); - segments.Add((i, extent?.Start ?? double.NaN, extent?.End ?? double.NaN)); - } - - Parts.RemoveAt(i); - } - - RegenerateCutOffs(settings, (cutoff, extents) => - { - if (!saved.TryGetValue(cutoff, out var segments)) - return null; - var indices = new int[extents.Count]; - var previous = segments.Min(s => s.Index); - for (var k = 0; k < extents.Count; k++) - { - var (start, end) = extents[k]; - var best = -1; - var bestOverlap = 0.0; - foreach (var (index, savedStart, savedEnd) in segments) - { - var overlap = System.Math.Min(end, savedEnd) - System.Math.Max(start, savedStart); - if (overlap > bestOverlap) - { - best = index; - bestOverlap = overlap; - } - } - indices[k] = previous = best >= 0 ? best : previous; - } - return indices; - }, _ => places); - } - - /// - /// Regenerates all cut-off drawings and materializes each of their trimmed segments as a - /// part, placing a cut-off's segments consecutively from its index in - /// (its place in order). Cut-offs missing - /// from it are added at the end. Callers must remove existing cut-off parts first. - /// - public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary sequence) => - RegenerateCutOffs(settings, null, sequence); - - /// - /// As , - /// but each cut-off lists one index per segment in ascending order along its axis. A list - /// whose length differs from the regenerated segment count places the segments - /// consecutively at its smallest index. - /// - public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary> sequence) => - RegenerateCutOffs(settings, sequence, null); - - /// - /// Places each cut-off's regenerated segments where its saved segments were, among the - /// parts saved around them: lists every saved segment's place; - /// gives only the first, for files that recorded no more - /// (their segments were consecutive, but how many is unknown). Settings other than the - /// saved ones can regenerate a different number of segments; the parts keep their - /// places relative to the cut-offs either way. Where only a first place is known the - /// count is a guess, so a cut-off that splits the sheet goes last, as sequencing puts it. - /// /// /// A new, unowned plate with this plate's sheet and layout: size, outline, spacing, /// quadrant, grain, clamps, support bed, parts (programs and lead-in state included), @@ -538,182 +248,6 @@ namespace OpenNest return copy; } - public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary> places, - IReadOnlyDictionary firstPlaces) => - RegenerateCutOffs(settings, (cutoff, extents) => - { - if (places != null && places.TryGetValue(cutoff, out var indices) && indices is { Count: > 0 }) - { - if (indices.Count != extents.Count) - return Enumerable.Repeat(indices.Min(), extents.Count).ToArray(); - // Program order can run toward the origin; the file lists segments ascending. - var ascending = Enumerable.Range(0, extents.Count).OrderBy(k => extents[k].Start).ToArray(); - var result = new int[extents.Count]; - for (var rank = 0; rank < ascending.Length; rank++) - result[ascending[rank]] = indices[rank]; - return result; - } - return firstPlaces != null && firstPlaces.TryGetValue(cutoff, out var first) - ? Enumerable.Repeat(first, extents.Count).ToArray() : null; - }, counts => - { - var saved = new HashSet(); - foreach (var list in places?.Values ?? []) - saved.UnionWith(list ?? []); - if (firstPlaces == null) - return saved; - // A cut-off saved by its first place alone held consecutive places, as many as it - // has segments now unless that runs into the next cut-off's first place. - var starts = saved.Concat(firstPlaces.Values).Distinct().Order().ToArray(); - foreach (var (cutoff, first) in firstPlaces) - { - var next = starts.FirstOrDefault(start => start > first, int.MaxValue); - var count = System.Math.Max(1, System.Math.Min(counts.GetValueOrDefault(cutoff, 1), next - first)); - saved.UnionWith(Enumerable.Range(first, count)); - } - return saved; - }, cutoff => firstPlaces != null && firstPlaces.ContainsKey(cutoff)); - - // sequence gives a cut-off's saved place for each segment in program order, from its - // segments' extents along its axis (NaN when unmeasurable), or null to append them. - // savedPlaces gives every place cut-off segments held in the saved order, from each - // cut-off's regenerated segment count; the parts held the others, in their order. - // separateLast says which cut-offs go after everything when they split the sheet. - private void RegenerateCutOffs(CutOffSettings settings, - Func, IReadOnlyList> sequence, - Func, IEnumerable> savedPlaces, - Func separateLast = null) - { - var cache = BuildPerimeterCache(this); - var sheet = BoundingBox(includeParts: false); - var placed = new List<(int Index, int Order, Part Part)>(); - var separating = new List(); - var counts = new Dictionary(); - var partCount = Parts.Count; - CutOffSettings = settings?.Copy(); - - // Regenerate and materialize each cut-off's segments - foreach (var cutoff in CutOffs) - { - cutoff.Regenerate(this, settings, cache); - - var programs = cutoff.SegmentPrograms(); - if (programs.Count == 0) - continue; - - var vertical = cutoff.Axis == CutOffAxis.Vertical; - var parts = programs.Select(program => new Part(cutoff.Drawing, program)).ToArray(); - var extents = parts.Select(part => CutOffSegments.Extent(part, vertical) ?? (double.NaN, double.NaN)).ToArray(); - var indices = sequence?.Invoke(cutoff, extents); - counts[cutoff] = parts.Length; - for (var k = 0; k < parts.Length; k++) - { - // A line splitting the sheet comes after every part and every other cut-off. - if (separateLast?.Invoke(cutoff) == true && CutOff.SeparatesSheet(parts[k], cutoff, sheet)) - separating.Add(parts[k]); - else if (indices != null && k < indices.Count) - placed.Add((indices[k], placed.Count, parts[k])); - else - Parts.Add(parts[k]); - } - } - - // Saved places count every cut-off segment then on the plate, which may now be more or - // fewer. A segment goes after the parts saved before it and the segments placed before - // it, so a changed count never shifts a later cut-off or part. Segments sharing a place - // keep their program order. - var saved = (savedPlaces?.Invoke(counts) ?? []).Distinct().Order().ToArray(); - var inserted = 0; - foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenBy(p => p.Order)) - { - var savedBefore = Array.BinarySearch(saved, index); - savedBefore = savedBefore < 0 ? ~savedBefore : savedBefore; - var partsBefore = System.Math.Clamp(index - savedBefore, 0, partCount); - Parts.Insert(partsBefore + inserted++, part); - } - foreach (var part in separating) - Parts.Add(part); - } - - /// - /// Builds a dictionary mapping each non-cut-off part to its perimeter entity. - /// Closed shapes use ShapeProfile; open contours fall back to ConvexHull. - /// - public static Dictionary BuildPerimeterCache(Plate plate) - { - var cache = new Dictionary(); - - foreach (var part in plate.Parts) - { - if (part.BaseDrawing.IsCutOff) - continue; - - cache[part] = BuildPartPerimeter(part); - } - - return cache; - } - - /// - /// Converts one part's own program into its placed perimeter entity (closed - /// profile, or a convex hull fallback for a genuinely open contour). This is - /// the same conversion runs per part; callers - /// without a pre-built cache (e.g. a direct, uncached - /// call) use it to get the same conservative, converted-material envelope instead - /// of trusting the part's cached bounding box alone. - /// - public static Geometry.Entity BuildPartPerimeter(Part part) - { - // Null is the conservative answer: the cut-off then excludes the part's whole - // bounding-box span. Only malformed geometry earns it; anything else propagates. - if (part?.Program == null) - return null; - - try - { - var entities = Converters - .ConvertProgram.ToGeometry(part.Program) - .Where(e => SpecialLayers.IsMaterial(e.Layer)) - .ToList(); - - if (entities.Count == 0) - return null; - - // Leads are cutting paths in scrap, not boundary edges. Chaining them - // into the outline can make a closed part appear open and replace its - // recesses with a convex hull. A cut-off may cross a lead, so the open- - // contour hull also leaves them out: hulling a lead's pierce point would - // stop cut-offs far short of the part. - var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin && - e.Layer != SpecialLayers.Leadout).ToList(); - if (outline.Count == 0) - return null; - var shapes = Geometry.ShapeBuilder.GetShapes(outline); - if (shapes.Count == 0) - return null; - var profile = Geometry.ShapeProfile.FromShapes(shapes); - - if (profile.Perimeter.IsClosed()) - { - var perimeter = profile.Perimeter; - perimeter.Offset(part.Location); - return perimeter; - } - - var points = outline.CollectPoints(); - if (points.Count < 3) - return null; - - var hull = Geometry.ConvexHull.Compute(points); - hull.Offset(part.Location); - return hull; - } - catch (Exception e) when (Diagnostics.PlateOverlapAnalyzer.IsGeometryFailure(e)) - { - return null; - } - } - /// /// The number of times to cut the plate. Lowering it drops the /// of the sheets no longer cut. @@ -747,411 +281,5 @@ namespace OpenNest ResolveClampRail(); } } - - /// - /// Rotates the plate clockwise or counter-clockwise along with all parts. - /// - /// - /// - public void Rotate90(RotationType rotationDirection, bool keepSameQuadrant = true) - { - const double oneAndHalfPI = System.Math.PI * 1.5; - - var turned = Outline?.Rotated90(rotationDirection); - Size = new Size(Size.Length, Size.Width); - Outline = turned; - - // After Size swap above, new Size.Width = old Length (old X extent), - // new Size.Length = old Width (old Y extent). - // Convention: Length = X axis, Width = Y axis. - if (rotationDirection == RotationType.CW) - { - Rotate(oneAndHalfPI); - - if (keepSameQuadrant) - { - switch (Quadrant) - { - case 1: - Offset(0, Size.Width); - break; - - case 2: - Offset(-Size.Length, 0); - break; - - case 3: - Offset(0, -Size.Width); - break; - - case 4: - Offset(Size.Length, 0); - break; - - default: - return; - } - } - else - { - Quadrant = Quadrant < 2 ? 4 : Quadrant - 1; - } - } - else - { - Rotate(Angle.HalfPI); - - if (keepSameQuadrant) - { - switch (Quadrant) - { - case 1: - Offset(Size.Length, 0); - break; - - case 2: - Offset(0, Size.Width); - break; - - case 3: - Offset(-Size.Length, 0); - break; - - case 4: - Offset(0, -Size.Width); - break; - - default: - return; - } - } - else - { - Quadrant = Quadrant > 3 ? 1 : Quadrant + 1; - } - } - } - - /// - /// Rotates the plate 180 degrees along with all parts. - /// - /// - public void Rotate180(bool keepSameQuadrant = true) - { - if (keepSameQuadrant) - { - Vector centerpt; - - switch (Quadrant) - { - case 1: - centerpt = new Vector(Size.Length * 0.5, Size.Width * 0.5); - break; - - case 2: - centerpt = new Vector(-Size.Length * 0.5, Size.Width * 0.5); - break; - - case 3: - centerpt = new Vector(-Size.Length * 0.5, -Size.Width * 0.5); - break; - - case 4: - centerpt = new Vector(Size.Length * 0.5, -Size.Width * 0.5); - break; - - default: - return; - } - - Rotate(System.Math.PI, centerpt); - Outline = Outline?.Rotated180(); - } - else - { - Rotate(System.Math.PI); - Outline = Outline?.Rotated180(); - Quadrant = (Quadrant + 2) % 4; - - if (Quadrant == 0) - Quadrant = 4; - } - } - - /// - /// Rotates the parts on the plate. - /// - /// - public void Rotate(double angle) - { - for (var i = Parts.Count - 1; i >= 0; i--) - { - if (Parts[i].BaseDrawing.IsCutOff) - Parts.RemoveAt(i); - } - - for (var i = 0; i < Parts.Count; ++i) - { - var part = Parts[i]; - part.Rotate(angle); - } - - foreach (var cutoff in CutOffs) - cutoff.Position = cutoff.Position.Rotate(angle); - } - - /// - /// Rotates the parts on the plate around the specified origin. - /// - /// - /// - public void Rotate(double angle, Vector origin) - { - for (var i = Parts.Count - 1; i >= 0; i--) - { - if (Parts[i].BaseDrawing.IsCutOff) - Parts.RemoveAt(i); - } - - for (var i = 0; i < Parts.Count; ++i) - { - var part = Parts[i]; - part.Rotate(angle, origin); - } - - foreach (var cutoff in CutOffs) - { - var pos = cutoff.Position - origin; - pos = pos.Rotate(angle); - cutoff.Position = pos + origin; - } - } - - /// - /// Offsets the parts on the plate. - /// - /// - /// - public void Offset(double x, double y) - { - // Remove cut-off parts before transforming - for (var i = Parts.Count - 1; i >= 0; i--) - { - if (Parts[i].BaseDrawing.IsCutOff) - Parts.RemoveAt(i); - } - - for (var i = 0; i < Parts.Count; ++i) - { - var part = Parts[i]; - part.Offset(x, y); - } - - // Transform cut-off positions - foreach (var cutoff in CutOffs) - cutoff.Position = new Vector(cutoff.Position.X + x, cutoff.Position.Y + y); - } - - /// - /// Offsets the parts on the plate. - /// - /// - public void Offset(Vector voffset) - { - for (var i = Parts.Count - 1; i >= 0; i--) - { - if (Parts[i].BaseDrawing.IsCutOff) - Parts.RemoveAt(i); - } - - for (var i = 0; i < Parts.Count; ++i) - { - var part = Parts[i]; - part.Offset(voffset); - } - - foreach (var cutoff in CutOffs) - cutoff.Position = new Vector( - cutoff.Position.X + voffset.X, - cutoff.Position.Y + voffset.Y - ); - } - - /// - /// The smallest box that contains the plate. - /// - /// - /// - public Box BoundingBox(bool includeParts = true) - { - var plateBox = new Box(); - - // Width = Y axis (vertical), Length = X axis (horizontal) - switch (Quadrant) - { - case 1: - plateBox.X = 0; - plateBox.Y = 0; - break; - - case 2: - plateBox.X = (float)-Size.Length; - plateBox.Y = 0; - break; - - case 3: - plateBox.X = (float)-Size.Length; - plateBox.Y = (float)-Size.Width; - break; - - case 4: - plateBox.X = 0; - plateBox.Y = (float)-Size.Width; - break; - - default: - return new Box(); - } - - plateBox.Width = Size.Width; - plateBox.Length = Size.Length; - - if (!includeParts) - return plateBox; - - var boundingBox = new Box(); - var partsBox = Parts.GetBoundingBox(); - - boundingBox.X = partsBox.Left < plateBox.Left ? partsBox.Left : plateBox.Left; - - boundingBox.Y = partsBox.Bottom < plateBox.Bottom ? partsBox.Bottom : plateBox.Bottom; - - boundingBox.Length = - partsBox.Right > plateBox.Right - ? partsBox.Right - boundingBox.X - : plateBox.Right - boundingBox.X; - - boundingBox.Width = - partsBox.Top > plateBox.Top - ? partsBox.Top - boundingBox.Y - : plateBox.Top - boundingBox.Y; - - return boundingBox; - } - - /// - /// The area within the edge spacing. - /// - /// - public Box WorkArea() - { - var box = BoundingBox(false); - - box.X += EdgeSpacing.Left; - box.Y += EdgeSpacing.Bottom; - box.Length -= EdgeSpacing.Left + EdgeSpacing.Right; - box.Width -= EdgeSpacing.Top + EdgeSpacing.Bottom; - - return box; - } - - /// - /// The offset that centers the parts' combined bounds in the , - /// so uneven edge spacing (such as a wider clamp-side margin) shifts them with it. - /// Zero when the plate has no parts. - /// - public Vector OffsetToCenterPartsInWorkArea() => - Parts.Count == 0 ? new Vector() : WorkArea().Center - Parts.GetBoundingBox().Center; - - /// - /// Automatically sizes the plate to fit the parts. - /// - /// The factor to round the actual size up to. - /// - /// AutoSize 9.7 x 10.1 - /// * roundingFactor=1.0 new Size=10 x 11 - /// * roundingFactor=0.5 new Size=10 x 10.5 - /// * roundingFactor=0.25 new Size=9.75 x 10.25 - /// * roundingFactor=0.0 new Size=9.7 x 10.1 - /// - public void AutoSize(double roundingFactor = 1.0) - { - // An outlined sheet is a particular piece of material; it cannot be resized to fit. - if (Parts.Count == 0 || Outline != null) - return; - - var bounds = Parts.GetBoundingBox(); - - // Convention: Length = X axis, Width = Y axis - double xExtent; - double yExtent; - - switch (Quadrant) - { - case 1: - xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right; - yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top; - break; - - case 2: - xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left; - yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top; - break; - - case 3: - xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left; - yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom; - break; - - case 4: - xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right; - yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom; - break; - - default: - return; - } - - Size = new Size( - Rounding.RoundUpToNearest(yExtent, roundingFactor), - Rounding.RoundUpToNearest(xExtent, roundingFactor) - ); - } - - /// - /// Gets the area of the top surface of the plate: the material inside its - /// when it has one, otherwise the whole rectangle. - /// - /// - public double Area() - { - return Outline?.Area ?? Size.Width * Size.Length; - } - - /// - /// Gets the volume of the plate. - /// - public double Volume(double thickness) - { - return Area() * thickness; - } - - /// - /// Gets the weight of the plate. - /// - public double Weight(double thickness, double density) - { - return Volume(thickness) * density; - } - - /// - /// Percentage of the material used. - /// - /// Returns a number between 0.0 and 1.0 - public double Utilization() - { - return Parts.Where(p => !p.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) - / Area(); - } } }