mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 13:51:06 -04:00
Stock can arrive up to 1.5 in wider than nominal, so only the origin-side edge is certain. A cut-off with a head-locked overcut cuts past the nominal far edge with the head height locked, covering the oversize. With that lock, Plan Cutting may start the far-edge segment at the far edge: overcut outward, rapid back still locked, unlock, cut toward the origin. Without it the direction stays fixed, as decided in Project Memory a20ea455. - CutOff.HeadLockOvercut / FarEdgeFirst, persisted in .nest; HEAD HEIGHT LOCK/UNLOCK comment markers; far edge is quadrant-aware. - Planner offers the reversed far-edge segment in the joint search, the safest route and segment insertion; Apply records the direction. - Cincinnati CL and CI Fiber posts gain lock/unlock code settings and refuse head-locked cut-offs while either is empty. - Automatic cut-off dialog setting (off by default, 1.5 in), kept between sessions and used for new cut-offs. Lock codes and machine behaviour with the head locked are unconfirmed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
626 lines
28 KiB
C#
626 lines
28 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using OpenNest.CNC;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest
|
|
{
|
|
public enum CutOffAxis
|
|
{
|
|
Horizontal,
|
|
Vertical,
|
|
}
|
|
|
|
public class CutOff
|
|
{
|
|
private const double OffsetTolerance = 0.001;
|
|
|
|
public Vector Position { get; set; }
|
|
public CutOffAxis Axis { get; set; }
|
|
public double? StartLimit { get; set; }
|
|
public double? EndLimit { get; set; }
|
|
public Drawing Drawing { get; private set; }
|
|
|
|
/// <summary>
|
|
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the
|
|
/// segment reaching that edge cuts with the head height locked; zero for none. Stock can
|
|
/// arrive wider than nominal, so only the origin-side edge is certain: the overcut cuts the
|
|
/// oversize off. It replaces <see cref="CutOffSettings.Overtravel"/> at the far edge and
|
|
/// applies only when the line runs to that edge (no limit on that side).
|
|
/// </summary>
|
|
public double HeadLockOvercut { get; set; }
|
|
|
|
/// <summary>
|
|
/// Whether the segment reaching the far edge is cut from it toward the origin: it starts at
|
|
/// the nominal far edge, overcuts outward with the head locked, rapids back with the head
|
|
/// still locked, unlocks and cuts toward the origin. Ignored without a head-locked overcut,
|
|
/// since nothing else may start a cut at an edge whose position is unknown.
|
|
/// </summary>
|
|
public bool FarEdgeFirst { get; set; }
|
|
|
|
public CutOff(Vector position, CutOffAxis axis)
|
|
{
|
|
Position = position;
|
|
Axis = axis;
|
|
Drawing = new Drawing(GetName()) { IsCutOff = true };
|
|
}
|
|
|
|
public void Regenerate(
|
|
Plate plate,
|
|
CutOffSettings settings,
|
|
Dictionary<Part, Entity> cache = null
|
|
)
|
|
{
|
|
var segments = ComputeSegments(plate, settings, cache);
|
|
var program = BuildProgram(segments, settings, LockedFarEdge(plate));
|
|
Drawing.Program = program;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The regenerated program split into its cutting segments, one rapid-and-cut program
|
|
/// each in program order, so the plate can sequence every segment on its own. A rapid with
|
|
/// the head height locked returns along the overcut and stays in its segment.
|
|
/// </summary>
|
|
public IReadOnlyList<Program> SegmentPrograms()
|
|
{
|
|
var program = Drawing.Program;
|
|
var result = new List<Program>();
|
|
if (program == null)
|
|
return result;
|
|
Program current = null;
|
|
var locked = false;
|
|
foreach (var code in program.Codes)
|
|
{
|
|
if (code is RapidMove && !locked || current == null)
|
|
{
|
|
current = new Program(program.Mode);
|
|
result.Add(current);
|
|
}
|
|
if (HeadHeightLock.IsLock(code))
|
|
locked = true;
|
|
else if (HeadHeightLock.IsUnlock(code))
|
|
locked = false;
|
|
current.Codes.Add(code);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether the far edge, along this axis on <paramref name="plate"/>, is the high
|
|
/// coordinate: the sheet runs from the origin toward positive coordinates on that axis.
|
|
/// </summary>
|
|
internal static bool FarEdgeIsHigh(CutOffAxis axis, int quadrant) =>
|
|
axis == CutOffAxis.Vertical ? quadrant is 1 or 2 : quadrant is 1 or 4;
|
|
|
|
/// <summary>
|
|
/// The nominal far edge this cut-off overcuts with the head locked, along its axis, or null
|
|
/// when it has no head-locked overcut or a limit stops it short of that edge.
|
|
/// </summary>
|
|
private (double Edge, bool High)? LockedFarEdge(Plate plate)
|
|
{
|
|
if (!(HeadLockOvercut > 0) || !double.IsFinite(HeadLockOvercut))
|
|
return null;
|
|
var bounds = plate.BoundingBox(includeParts: false);
|
|
var high = FarEdgeIsHigh(Axis, plate.Quadrant);
|
|
if ((high ? EndLimit : StartLimit) != null)
|
|
return null;
|
|
var vertical = Axis == CutOffAxis.Vertical;
|
|
return (high ? (vertical ? bounds.Top : bounds.Right) : (vertical ? bounds.Bottom : bounds.Left), high);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same segment cut the other way at the far edge: a head-locked segment cut toward the
|
|
/// far edge becomes one started there, and back; null when <paramref name="segment"/> is not
|
|
/// one of the two forms <see cref="Regenerate"/> builds for the segment reaching that edge.
|
|
/// </summary>
|
|
public static Program ReverseAtFarEdge(Program segment)
|
|
{
|
|
if (segment?.Codes == null || segment.Mode != Mode.Absolute)
|
|
return null;
|
|
var codes = segment.Codes;
|
|
var result = new Program(segment.Mode);
|
|
// Toward the far edge: rapid near, cut to the edge, lock, overcut, unlock.
|
|
if (codes.Count == 5 && codes[0] is RapidMove { Suppressed: false } near
|
|
&& codes[1] is LinearMove { Suppressed: false, Layer: LayerType.Cut } toEdge
|
|
&& HeadHeightLock.IsLock(codes[2])
|
|
&& codes[3] is LinearMove { Suppressed: false, Layer: LayerType.Cut } overcut
|
|
&& HeadHeightLock.IsUnlock(codes[4]))
|
|
{
|
|
result.Codes.Add(new RapidMove(toEdge.EndPoint));
|
|
result.Codes.Add(new Comment(HeadHeightLock.Lock));
|
|
result.Codes.Add(new LinearMove(overcut.EndPoint));
|
|
result.Codes.Add(new RapidMove(toEdge.EndPoint));
|
|
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
|
|
result.Codes.Add(new LinearMove(near.EndPoint));
|
|
return result;
|
|
}
|
|
// From the far edge: rapid to the edge, lock, overcut, rapid back, unlock, cut away.
|
|
if (codes.Count == 6 && codes[0] is RapidMove { Suppressed: false } edge
|
|
&& HeadHeightLock.IsLock(codes[1])
|
|
&& codes[2] is LinearMove { Suppressed: false, Layer: LayerType.Cut } outward
|
|
&& codes[3] is RapidMove { Suppressed: false } back && back.EndPoint == edge.EndPoint
|
|
&& HeadHeightLock.IsUnlock(codes[4])
|
|
&& codes[5] is LinearMove { Suppressed: false, Layer: LayerType.Cut } away)
|
|
{
|
|
result.Codes.Add(new RapidMove(away.EndPoint));
|
|
result.Codes.Add(new LinearMove(edge.EndPoint));
|
|
result.Codes.Add(new Comment(HeadHeightLock.Lock));
|
|
result.Codes.Add(new LinearMove(outward.EndPoint));
|
|
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
|
|
return result;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Installs Plan Cutting's choice for the far-edge segment: <paramref name="segment"/> (one
|
|
/// of this cut-off's placed parts) takes <paramref name="program"/>, the same segment cut the
|
|
/// other way, and <see cref="FarEdgeFirst"/> and the whole-line program follow it, so
|
|
/// regeneration keeps the direction. The caller has checked that the program is that flip.
|
|
/// </summary>
|
|
internal void InstallFarEdgeSegment(Part segment, Program program)
|
|
{
|
|
var codes = new List<ICode>();
|
|
foreach (var part in SegmentPrograms())
|
|
codes.AddRange((ReverseAtFarEdge(part) != null ? program : part).Codes);
|
|
var whole = new Program(Drawing.Program?.Mode ?? Mode.Absolute);
|
|
whole.Codes.AddRange(codes);
|
|
segment.ReplaceCutOffProgram(program);
|
|
FarEdgeFirst = StartsAtFarEdge(program);
|
|
Drawing.Program = whole;
|
|
}
|
|
|
|
/// <summary>Whether <paramref name="segment"/> is the far-edge segment cut from the far edge.</summary>
|
|
public static bool StartsAtFarEdge(Program segment) =>
|
|
segment?.Codes is { Count: 6 } codes && codes[0] is RapidMove && HeadHeightLock.IsLock(codes[1]);
|
|
|
|
/// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary>
|
|
internal (double Length, int SegmentCount) MeasureSegments(
|
|
Plate plate,
|
|
CutOffSettings settings,
|
|
Dictionary<Part, Entity> cache = null
|
|
)
|
|
{
|
|
var segments = ComputeSegments(plate, settings, cache);
|
|
return (segments.Sum(s => s.End - s.Start), segments.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts
|
|
/// one continuous straight line on the definition's axis across the whole sheet, so it
|
|
/// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
|
|
/// line on the sheet edge, a stale or unrecognised program) does not separate the sheet.
|
|
/// </summary>
|
|
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet)
|
|
{
|
|
if (part?.Program == null || definition == null || sheet == null
|
|
|| part.Program.Mode != Mode.Absolute)
|
|
return false;
|
|
var vertical = definition.Axis == CutOffAxis.Vertical;
|
|
var line = vertical ? definition.Position.X : definition.Position.Y;
|
|
var acrossMin = vertical ? sheet.Left : sheet.Bottom;
|
|
var acrossMax = vertical ? sheet.Right : sheet.Top;
|
|
var alongMin = vertical ? sheet.Bottom : sheet.Left;
|
|
var alongMax = vertical ? sheet.Top : sheet.Right;
|
|
if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon))
|
|
return false;
|
|
|
|
Vector? position = null;
|
|
double? low = null, high = null;
|
|
var cutting = false;
|
|
var locked = false;
|
|
foreach (var code in part.Program.Codes)
|
|
{
|
|
if (HeadHeightLock.IsLock(code))
|
|
locked = true;
|
|
else if (HeadHeightLock.IsUnlock(code))
|
|
locked = false;
|
|
switch (code)
|
|
{
|
|
case RapidMove rapid:
|
|
// A second pierce leaves an uncut gap; a head-locked rapid returns along
|
|
// the overcut to where the cut toward the origin begins.
|
|
if (cutting && !locked)
|
|
return false;
|
|
position = rapid.EndPoint + part.Location;
|
|
break;
|
|
case LinearMove move when position != null:
|
|
var end = move.EndPoint + part.Location;
|
|
if (!OnLine(position.Value) || !OnLine(end))
|
|
return false;
|
|
foreach (var value in new[] { Along(position.Value), Along(end) })
|
|
{
|
|
low = low == null ? value : System.Math.Min(low.Value, value);
|
|
high = high == null ? value : System.Math.Max(high.Value, value);
|
|
}
|
|
position = end;
|
|
cutting = true;
|
|
break;
|
|
case Motion:
|
|
return false;
|
|
}
|
|
}
|
|
return cutting && low <= alongMin + Math.Tolerance.Epsilon && high >= alongMax - Math.Tolerance.Epsilon;
|
|
|
|
bool OnLine(Vector point) =>
|
|
System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon;
|
|
double Along(Vector point) => vertical ? point.Y : point.X;
|
|
}
|
|
|
|
private string GetName()
|
|
{
|
|
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
|
|
var coord = Axis == CutOffAxis.Vertical ? Position.X : Position.Y;
|
|
return $"CutOff-{axisChar}-{coord:F2}";
|
|
}
|
|
|
|
private List<(double Start, double End)> ComputeSegments(
|
|
Plate plate,
|
|
CutOffSettings settings,
|
|
Dictionary<Part, Entity> cache
|
|
)
|
|
{
|
|
var bounds = plate.BoundingBox(includeParts: false);
|
|
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
|
|
var overtravel = LockedFarEdge(plate) is { High: true } ? 0 : settings.Overtravel;
|
|
|
|
double lineStart,
|
|
lineEnd,
|
|
cutPosition;
|
|
|
|
if (Axis == CutOffAxis.Vertical)
|
|
{
|
|
cutPosition = Position.X;
|
|
lineStart = StartLimit ?? bounds.Y;
|
|
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + overtravel);
|
|
}
|
|
else
|
|
{
|
|
cutPosition = Position.Y;
|
|
lineStart = StartLimit ?? bounds.X;
|
|
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
|
|
}
|
|
|
|
var exclusions = new List<(double Start, double End)>();
|
|
|
|
foreach (var part in plate.Parts)
|
|
{
|
|
if (part.BaseDrawing.IsCutOff)
|
|
continue;
|
|
|
|
Entity perimeter;
|
|
if (cache != null)
|
|
{
|
|
cache.TryGetValue(part, out perimeter);
|
|
}
|
|
else
|
|
{
|
|
// No pre-built cache: this is a direct/uncached caller (e.g. an
|
|
// external API consumer calling Regenerate without going through
|
|
// Plate.RegenerateCutOffs or the UI handlers, which always pass a
|
|
// cache). Build the same converted-material envelope on the fly so
|
|
// this path gets the same safety as the cached one, instead of
|
|
// silently trusting the part's bare BoundingBox.
|
|
perimeter = Plate.BuildPartPerimeter(part);
|
|
}
|
|
var partExclusions = GetPartExclusions(
|
|
part,
|
|
perimeter,
|
|
cutPosition,
|
|
lineStart,
|
|
lineEnd,
|
|
settings.PartClearance
|
|
);
|
|
exclusions.AddRange(partExclusions);
|
|
}
|
|
|
|
exclusions.Sort((a, b) => a.Start.CompareTo(b.Start));
|
|
var merged = new List<(double Start, double End)>();
|
|
foreach (var ex in exclusions)
|
|
{
|
|
if (merged.Count > 0 && ex.Start <= merged[^1].End)
|
|
merged[^1] = (merged[^1].Start, System.Math.Max(merged[^1].End, ex.End));
|
|
else
|
|
merged.Add(ex);
|
|
}
|
|
|
|
var segments = new List<(double Start, double End)>();
|
|
var current = lineStart;
|
|
|
|
foreach (var ex in merged)
|
|
{
|
|
var clampedStart = System.Math.Max(ex.Start, lineStart);
|
|
var clampedEnd = System.Math.Min(ex.End, lineEnd);
|
|
|
|
if (clampedStart > current)
|
|
segments.Add((current, clampedStart));
|
|
|
|
current = System.Math.Max(current, clampedEnd);
|
|
}
|
|
|
|
if (current < lineEnd)
|
|
segments.Add((current, lineEnd));
|
|
|
|
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
|
|
|
|
return segments;
|
|
}
|
|
|
|
private static readonly List<(double Start, double End)> EmptyExclusions = new();
|
|
|
|
private List<(double Start, double End)> GetPartExclusions(
|
|
Part part,
|
|
Entity perimeter,
|
|
double cutPosition,
|
|
double lineStart,
|
|
double lineEnd,
|
|
double clearance
|
|
)
|
|
{
|
|
var cached = part.BoundingBox;
|
|
var roundoff = GetBoundsRoundoff(part);
|
|
// Keep the farther of the cached and converted perimeter extents. The
|
|
// perimeter's fine intersection/offset can miss a near-tangent sliver;
|
|
// only a line in that narrow outer band needs the conservative box.
|
|
var bb = cached;
|
|
if (perimeter != null)
|
|
{
|
|
var converted = perimeter.BoundingBox;
|
|
var left = System.Math.Min(cached.Left, converted.Left);
|
|
var right = System.Math.Max(cached.Right, converted.Right);
|
|
var bottom = System.Math.Min(cached.Bottom, converted.Bottom);
|
|
var top = System.Math.Max(cached.Top, converted.Top);
|
|
bb = new Box(left, bottom, right - left, top - bottom);
|
|
}
|
|
var (cachedMin, cachedMax) = AxisBounds(cached, clearance);
|
|
var (partMin, partMax) = AxisBounds(bb, clearance);
|
|
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
|
// Match the planner's representational allowance in BOTH pruning and
|
|
// fallback exclusions. Tolerating it only in validation could cut an edge.
|
|
partMin -= roundoff;
|
|
partMax += roundoff;
|
|
partStart -= roundoff;
|
|
partEnd += roundoff;
|
|
|
|
if (cutPosition < partMin || cutPosition > partMax)
|
|
return EmptyExclusions;
|
|
|
|
if (perimeter != null)
|
|
{
|
|
if (cutPosition <= cachedMin + roundoff || cutPosition >= cachedMax - roundoff)
|
|
return new List<(double Start, double End)> { (partStart, partEnd) };
|
|
var perimeterExclusions = IntersectPerimeter(
|
|
perimeter,
|
|
cutPosition,
|
|
lineStart,
|
|
lineEnd,
|
|
clearance
|
|
);
|
|
if (perimeterExclusions != null)
|
|
return perimeterExclusions;
|
|
}
|
|
|
|
return new List<(double Start, double End)> { (partStart, partEnd) };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bounds reconstructed as (local minimum + placement) + size can differ
|
|
/// from translated material endpoints by a few floating-point steps. This
|
|
/// is not a geometry tolerance: cap it well below epsilon so inconsistent
|
|
/// arcs still require repair. Do not change shared Part bounds or programs.
|
|
/// </summary>
|
|
internal static double GetBoundsRoundoff(Part part)
|
|
{
|
|
var bb = part.BoundingBox;
|
|
var magnitude = System.Math.Max(System.Math.Abs(bb.Left), System.Math.Abs(bb.Right));
|
|
magnitude = System.Math.Max(magnitude,
|
|
System.Math.Max(System.Math.Abs(bb.Bottom), System.Math.Abs(bb.Top)));
|
|
magnitude = System.Math.Max(magnitude, System.Math.Max(bb.Length, bb.Width));
|
|
magnitude = System.Math.Max(magnitude,
|
|
System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y)));
|
|
var step = System.Math.BitIncrement(magnitude) - magnitude;
|
|
// Imported, nearly tangent arcs can amplify endpoint rounding when
|
|
// ConvertProgram fits their centers to a chord. Keep that allowance
|
|
// tiny and apply it to both pruning and fallback exclusions above;
|
|
// larger refits still require an explicit geometry repair. The allowance is
|
|
// absolute: rotated small arcs on ordinary sheets refit by ~5e-11, which a
|
|
// magnitude-scaled step count would reject.
|
|
var arcFitRoundoff = Math.Tolerance.Epsilon / 100000;
|
|
return double.IsFinite(step)
|
|
? System.Math.Min(System.Math.Max(8 * step, arcFitRoundoff), Math.Tolerance.Epsilon / 4)
|
|
: 0;
|
|
}
|
|
|
|
private List<(double Start, double End)> IntersectPerimeter(
|
|
Entity perimeter,
|
|
double cutPosition,
|
|
double lineStart,
|
|
double lineEnd,
|
|
double clearance
|
|
)
|
|
{
|
|
var offset = OffsetOutward(perimeter, clearance);
|
|
if (offset != null && offset.All(target => target is Polygon))
|
|
return SlicePolygons(offset.Cast<Polygon>(), cutPosition);
|
|
|
|
var usedOffset = offset != null;
|
|
var targets = offset ?? new List<Entity> { perimeter };
|
|
var cutLine = new Line(
|
|
MakePoint(cutPosition, lineStart),
|
|
MakePoint(cutPosition, lineEnd)
|
|
);
|
|
|
|
var pts = new List<Vector>();
|
|
|
|
foreach (var target in targets)
|
|
{
|
|
if (target.Intersects(cutLine, out var targetPts))
|
|
pts.AddRange(targetPts);
|
|
}
|
|
|
|
if (pts.Count < 2)
|
|
return null;
|
|
|
|
var coords = pts.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
|
|
.OrderBy(c => c)
|
|
.ToList();
|
|
|
|
if (coords.Count % 2 != 0)
|
|
return null;
|
|
|
|
// Intersects reports both incident edges at a shared vertex. Such hits may be
|
|
// crossings or tangencies, so neither pairing nor deduplicating them preserves
|
|
// inside/outside parity. Fall back to the clearance-expanded part bounds.
|
|
for (var i = 1; i < coords.Count; i++)
|
|
{
|
|
if (coords[i] - coords[i - 1] <= Math.Tolerance.Epsilon)
|
|
return null;
|
|
}
|
|
|
|
var padding = usedOffset ? 0 : clearance;
|
|
var result = new List<(double Start, double End)>();
|
|
for (var i = 0; i < coords.Count; i += 2)
|
|
result.Add((coords[i] - padding, coords[i + 1] + padding));
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exact cross-section of the clearance outline on the cut line. A vertex on the line
|
|
/// counts as below it (half-open edges), so a line through a vertex counts one
|
|
/// crossing when the outline passes through and zero or two when it only touches. Edges
|
|
/// lying on the line are excluded whole. Unlike <see cref="Entity.Intersects"/>, which
|
|
/// reports a shared vertex once per incident edge, this never needs the bounding-box
|
|
/// fallback, so a lattice line through a vertex is trimmed like any other line.
|
|
/// </summary>
|
|
private List<(double Start, double End)> SlicePolygons(IEnumerable<Polygon> polygons, double cutPosition)
|
|
{
|
|
var crossings = new List<double>();
|
|
var result = new List<(double Start, double End)>();
|
|
foreach (var polygon in polygons)
|
|
{
|
|
var vertices = polygon.Vertices;
|
|
if (vertices.Count < 3)
|
|
return null;
|
|
var closed = vertices[0].X == vertices[^1].X && vertices[0].Y == vertices[^1].Y;
|
|
var edges = closed ? vertices.Count - 1 : vertices.Count;
|
|
for (var i = 0; i < edges; i++)
|
|
{
|
|
var from = vertices[i];
|
|
var to = vertices[(i + 1) % vertices.Count];
|
|
var (acrossFrom, alongFrom) = Split(from);
|
|
var (acrossTo, alongTo) = Split(to);
|
|
if (acrossFrom == cutPosition && acrossTo == cutPosition)
|
|
result.Add((System.Math.Min(alongFrom, alongTo), System.Math.Max(alongFrom, alongTo)));
|
|
else if (acrossFrom > cutPosition != acrossTo > cutPosition)
|
|
crossings.Add(acrossFrom == cutPosition ? alongFrom
|
|
: acrossTo == cutPosition ? alongTo
|
|
: alongFrom + (cutPosition - acrossFrom) * (alongTo - alongFrom) / (acrossTo - acrossFrom));
|
|
}
|
|
}
|
|
|
|
if (crossings.Count % 2 != 0)
|
|
return null;
|
|
crossings.Sort();
|
|
for (var i = 0; i < crossings.Count; i += 2)
|
|
result.Add((crossings[i], crossings[i + 1]));
|
|
return result;
|
|
|
|
(double Across, double Along) Split(Vector point) =>
|
|
Axis == CutOffAxis.Vertical ? (point.X, point.Y) : (point.Y, point.X);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Grows the perimeter by the clearance as one Clipper region offset, so slots
|
|
/// narrower than twice the clearance close up instead of leaving a gap the cut
|
|
/// could run into. Holes appear only where the perimeter curls back on itself.
|
|
/// </summary>
|
|
private static List<Entity> OffsetOutward(Entity perimeter, double clearance)
|
|
{
|
|
if (clearance <= 0)
|
|
return null;
|
|
|
|
var offset = perimeter switch
|
|
{
|
|
Shape shape => ClipperBridge.OffsetPerimeter(
|
|
shape,
|
|
clearance,
|
|
OffsetTolerance,
|
|
circumscribe: true
|
|
),
|
|
Polygon polygon => ClipperBridge.OffsetPerimeter(
|
|
polygon,
|
|
clearance,
|
|
OffsetTolerance,
|
|
circumscribe: true
|
|
),
|
|
_ => null,
|
|
};
|
|
|
|
if (offset == null || offset.Outers.Count == 0)
|
|
return null;
|
|
|
|
return offset.Outers.Concat(offset.Holes).Cast<Entity>().ToList();
|
|
}
|
|
|
|
private Vector MakePoint(double cutCoord, double lineCoord) =>
|
|
Axis == CutOffAxis.Vertical
|
|
? new Vector(cutCoord, lineCoord)
|
|
: new Vector(lineCoord, cutCoord);
|
|
|
|
private (double Min, double Max) AxisBounds(Box bb, double clearance) =>
|
|
Axis == CutOffAxis.Vertical
|
|
? (bb.X - clearance, bb.X + bb.Length + clearance)
|
|
: (bb.Y - clearance, bb.Y + bb.Width + clearance);
|
|
|
|
private (double Start, double End) CrossAxisBounds(Box bb, double clearance) =>
|
|
Axis == CutOffAxis.Vertical
|
|
? (bb.Y - clearance, bb.Y + bb.Width + clearance)
|
|
: (bb.X - clearance, bb.X + bb.Length + clearance);
|
|
|
|
private Program BuildProgram(
|
|
List<(double Start, double End)> segments,
|
|
CutOffSettings settings,
|
|
(double Edge, bool High)? farEdge
|
|
)
|
|
{
|
|
var program = new Program();
|
|
|
|
if (segments.Count == 0)
|
|
return program;
|
|
|
|
var toward = settings.CutDirection == CutDirection.TowardOrigin;
|
|
segments = toward
|
|
? segments.OrderByDescending(s => s.Start).ToList()
|
|
: segments.OrderBy(s => s.Start).ToList();
|
|
|
|
var cutPos = Axis == CutOffAxis.Vertical ? Position.X : Position.Y;
|
|
|
|
foreach (var seg in segments)
|
|
{
|
|
if (farEdge is var (edge, high) && (high ? seg.End : seg.Start) == edge)
|
|
{
|
|
// The segment reaching the far edge overcuts it with the head height locked,
|
|
// whichever way the rest of the line runs.
|
|
var near = MakePoint(cutPos, high ? seg.Start : seg.End);
|
|
var atEdge = MakePoint(cutPos, edge);
|
|
var beyond = MakePoint(cutPos, high ? edge + HeadLockOvercut : edge - HeadLockOvercut);
|
|
var towardEdge = new Program();
|
|
towardEdge.Codes.Add(new RapidMove(near));
|
|
towardEdge.Codes.Add(new LinearMove(atEdge));
|
|
towardEdge.Codes.Add(new Comment(HeadHeightLock.Lock));
|
|
towardEdge.Codes.Add(new LinearMove(beyond));
|
|
towardEdge.Codes.Add(new Comment(HeadHeightLock.Unlock));
|
|
program.Codes.AddRange((FarEdgeFirst ? ReverseAtFarEdge(towardEdge) : towardEdge).Codes);
|
|
continue;
|
|
}
|
|
var (from, to) = toward ? (seg.End, seg.Start) : (seg.Start, seg.End);
|
|
program.Codes.Add(new RapidMove(MakePoint(cutPos, from)));
|
|
program.Codes.Add(new LinearMove(MakePoint(cutPos, to)));
|
|
}
|
|
|
|
return program;
|
|
}
|
|
}
|
|
}
|