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