mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-08 09:05:44 -05:00
Slice 3 of common-line cutting. On costly plate (N26-295: 3/16 RA310S at about $2,500 a sheet) pairing parts one kerf apart fits the part the spacing cannot: the last plate's 38 parts fit on one 48x120 with Auto Nest only with pairs. - Drawing Properties gains "Allow common line" (Drawing.AllowCommonLine, off by default because customer edge-quality needs vary). The nest's CommonLineUsage (Off / When it adds parts / Whenever available) is set in Auto Nest and seeded from Tools > Nest Defaults; both persist (nest.json, defaults.json). - NestPipeline runs the ordinary solve, then, with a level on, trials where eligible parts are offered as kerf-gap pair compounds. Placed pairs expand into parts plus explicit NestJobCommonLine groups, are validated again, and CommonLineRanking keeps a trial only by the level: count first, then sheets; "when it adds parts" keeps the plain layout on a tie, "whenever" also takes a shorter cut or less material, never at the cost of a part. Engines, FillScore and their comparers are unchanged; Off asks the engine exactly what it did. - NestLayoutCheck exempts members only through a sheet's explicit group list and only when the job allows common lines; materializing and committing create Plate.CommonLines. - Server nesting writes common-line jobs as version 3 and replays the recorded trial answers, so the desktop binds the same groups. - Fill Area honors the level through a PlateFillService overload. - The benchmark gains --common-lines <level> --kerf <k>. Benchmark (synthetic nests + N26-295, --parallel 1): with no flag and with --common-lines off every engine matches the base commit; with a level on no layout becomes invalid and three runs save a sheet at equal nested area. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
8.2 KiB
C#
187 lines
8.2 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Clipper2Lib;
|
|
using OpenNest.CNC;
|
|
using OpenNest.Converters;
|
|
using OpenNest.Engine.Jobs.Adapters;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.Engine.Jobs.CommonLines;
|
|
|
|
/// <summary>
|
|
/// Two copies of one part sharing their longest straight edge one kerf apart: the second copy
|
|
/// is the first turned half a turn, so that edge faces its own copy. Engines place the pair as one
|
|
/// compound part (<see cref="Geometry"/>), and the pipeline expands it back into the two copies
|
|
/// and their common-line group.
|
|
/// </summary>
|
|
internal sealed class CommonLinePair
|
|
{
|
|
/// <summary>Chord tolerance of the compound outline; arcs are circumscribed, so it only
|
|
/// controls vertex count, never makes the compound smaller than its copies.</summary>
|
|
private const double FlattenTolerance = 0.002;
|
|
|
|
/// <summary>How far the bridge over the kerf reaches into each copy's material, so the union
|
|
/// joins the copies into one outline.</summary>
|
|
private const double BridgeOverlap = 1e-4;
|
|
|
|
private CommonLinePair(PartGeometrySnapshot geometry, double secondX, double secondY, double sharedLength)
|
|
{
|
|
Geometry = geometry;
|
|
SecondX = secondX;
|
|
SecondY = secondY;
|
|
SharedLength = sharedLength;
|
|
}
|
|
|
|
/// <summary>The compound: both copies and the kerf between them as one solid outline, in the
|
|
/// first copy's snapshot frame. Holes are not offered (they read as solid).</summary>
|
|
public PartGeometrySnapshot Geometry { get; }
|
|
|
|
/// <summary>Where the second copy sits in the compound's frame; it is turned half a turn.</summary>
|
|
public double SecondX { get; }
|
|
|
|
public double SecondY { get; }
|
|
|
|
/// <summary>Length of the shared span (the cut saved once per pair when cut once).</summary>
|
|
public double SharedLength { get; }
|
|
|
|
/// <summary>The second copy's rotation relative to the compound.</summary>
|
|
public const double SecondRotation = System.Math.PI;
|
|
|
|
/// <summary>
|
|
/// The pair for <paramref name="geometry"/> at <paramref name="kerf"/>, or null when the part
|
|
/// has no straight edge of at least <see cref="CommonLineGeometry.MinSharedLength"/>, or when
|
|
/// the half-turned copy would come closer than the kerf anywhere else.
|
|
/// </summary>
|
|
public static CommonLinePair? Build(PartGeometrySnapshot geometry, double kerf)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(geometry);
|
|
if (!double.IsFinite(kerf) || kerf <= 0)
|
|
return null;
|
|
Drawing drawing;
|
|
Part first;
|
|
IReadOnlyList<CommonLineGeometry.Edge> edges;
|
|
try
|
|
{
|
|
drawing = new Drawing("pair", DrawingJobMapper.ToProgram(geometry));
|
|
first = new Part(drawing);
|
|
edges = CommonLineGeometry.Edges(first, new CommonLineGeometry.ProfileCache());
|
|
}
|
|
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException
|
|
or NotSupportedException or ArithmeticException)
|
|
{
|
|
return null;
|
|
}
|
|
// Longest edges first; equal lengths keep perimeter order, so the choice is deterministic.
|
|
foreach (var edge in edges.OrderByDescending(e => System.Math.Round(e.Length, 9)).Take(4))
|
|
{
|
|
var pair = TryEdge(drawing, first, edge, kerf);
|
|
if (pair != null)
|
|
return pair;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static CommonLinePair? TryEdge(Drawing drawing, Part first, CommonLineGeometry.Edge edge, double kerf)
|
|
{
|
|
// Turning a copy half a turn maps this edge to itself reversed, outward normal opposed;
|
|
// moving it to the edge's mirror across the kerf centerline makes the two face each other
|
|
// over their whole length.
|
|
var middle = new Vector((edge.Start.X + edge.End.X) / 2, (edge.Start.Y + edge.End.Y) / 2);
|
|
var offset = new Vector(2 * middle.X + edge.Outward.X * kerf, 2 * middle.Y + edge.Outward.Y * kerf);
|
|
var second = new Part(drawing);
|
|
second.Rotate(SecondRotation);
|
|
second.Location = offset;
|
|
second.UpdateBounds();
|
|
|
|
var spans = CommonLineGeometry.Spans(first, second, kerf);
|
|
if (spans.Count == 0)
|
|
return null;
|
|
var firstProfile = Profile(first);
|
|
var secondProfile = Profile(second);
|
|
if (firstProfile == null || secondProfile == null)
|
|
return null;
|
|
// Away from the shared edge the copies must still clear one kerf (a concave part's
|
|
// half-turned copy can reach back into it).
|
|
if (NativeContours.Of(firstProfile, first.Location).TooClose(NativeContours.Of(secondProfile, second.Location), kerf))
|
|
return null;
|
|
|
|
var outline = Outline(firstProfile, first.Location, secondProfile, second.Location, edge, kerf);
|
|
if (outline == null)
|
|
return null;
|
|
var geometry = Snapshot(outline);
|
|
// Engines accept only usable closed edges; a compound they would refuse is no pair.
|
|
try
|
|
{
|
|
_ = JobPartGeometry.Read(geometry);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return null;
|
|
}
|
|
return new CommonLinePair(geometry, offset.X, offset.Y, spans.Sum(s => s.Length));
|
|
}
|
|
|
|
private static ShapeProfile? Profile(Part part)
|
|
{
|
|
var entities = ConvertProgram.ToGeometry(part.Program).ForNesting().ToList();
|
|
if (entities.Count == 0)
|
|
return null;
|
|
var profile = new ShapeProfile(entities);
|
|
return profile.Perimeter == null ? null : profile;
|
|
}
|
|
|
|
/// <summary>The union of both perimeters (arcs circumscribed) and the kerf between them, or
|
|
/// null when it is not one outline.</summary>
|
|
private static PathD? Outline(ShapeProfile first, Vector firstAt, ShapeProfile second, Vector secondAt,
|
|
CommonLineGeometry.Edge edge, double kerf)
|
|
{
|
|
var a = ClipperBridge.ToPath(ClipperBridge.Flatten(first.Perimeter, FlattenTolerance, circumscribe: true), firstAt);
|
|
var b = ClipperBridge.ToPath(ClipperBridge.Flatten(second.Perimeter, FlattenTolerance, circumscribe: true), secondAt);
|
|
var n = edge.Outward;
|
|
double inner = -BridgeOverlap, outer = kerf + BridgeOverlap;
|
|
var bridge = new PathD
|
|
{
|
|
new(edge.Start.X + n.X * inner, edge.Start.Y + n.Y * inner),
|
|
new(edge.End.X + n.X * inner, edge.End.Y + n.Y * inner),
|
|
new(edge.End.X + n.X * outer, edge.End.Y + n.Y * outer),
|
|
new(edge.Start.X + n.X * outer, edge.Start.Y + n.Y * outer),
|
|
};
|
|
// Clipper rounds PathsD to its precision; 8 decimals keeps the outline within 5e-9 of the copies.
|
|
// Every piece counter-clockwise, whatever the program's winding, so overlaps add up.
|
|
var pieces = new PathsD { a, b, bridge };
|
|
foreach (var piece in pieces)
|
|
if (!Clipper.IsPositive(piece))
|
|
piece.Reverse();
|
|
var union = Clipper.Union(pieces, new PathsD(), FillRule.NonZero, 8);
|
|
var outers = union.Where(path => Clipper.Area(path) > 0).ToList();
|
|
return outers.Count == 1 ? outers[0] : null;
|
|
}
|
|
|
|
private static PartGeometrySnapshot Snapshot(PathD outline)
|
|
{
|
|
// Clipper can return (nearly) repeated vertices; a zero-length edge is not usable geometry.
|
|
var points = new PathD(outline.Count);
|
|
foreach (var point in outline)
|
|
if (points.Count == 0 || !Same(points[^1], point))
|
|
points.Add(point);
|
|
while (points.Count > 1 && Same(points[^1], points[0]))
|
|
points.RemoveAt(points.Count - 1);
|
|
outline = points;
|
|
var program = new Program(Mode.Absolute);
|
|
program.Codes.Add(new RapidMove(outline[0].x, outline[0].y));
|
|
for (var i = 1; i <= outline.Count; i++)
|
|
{
|
|
var point = outline[i % outline.Count];
|
|
program.Codes.Add(new LinearMove(point.x, point.y) { Layer = LayerType.Cut });
|
|
}
|
|
return PartGeometrySnapshot.FromProgram(program);
|
|
}
|
|
|
|
// Engines refuse edges shorter than 1e-7; dropping a vertex this close moves the outline by
|
|
// far less than the spacing slack.
|
|
private static bool Same(PointD a, PointD b) =>
|
|
System.Math.Abs(a.x - b.x) <= 1e-6 && System.Math.Abs(a.y - b.y) <= 1e-6;
|
|
}
|