mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 22:01:06 -04:00
feat(planning): cut holes inside a display perimeter that is never cut
Some jobs cut only cutouts into a supplied blank, such as instrument holes in a purchased door that is the whole sheet. Its outline has to be known to the planner (the blank is material that leads, rapids and cut-offs must respect) but must never be pierced, led or cut. A new DisplayPerimeter layer marks that outline. It is material, so nesting, containment, lead clearance and automatic cut-offs all see the blank; a door that fills its sheet leaves cut-offs nothing to cut. Cutting preparation keeps it out of the contours entirely: with no cut perimeter every contour is a hole with internal leads, and the search, best-effort and safest-route paths stop offering a perimeter. Replay refuses any run along the display perimeter and completes once every other ring is cut. A display perimeter must be one whole closed contour enclosing every other contour; a mixed ring, one inside another contour, or a second one is refused with that reason. Saving, drawing and posting it are still to do.
This commit is contained in:
1 parent
c325a1745a
commit
6e3705eb89
12 files changed
+274
-29
No files matched your search
@@ -72,6 +72,8 @@ internal static class ContourProgramVerifier
|
||||
}
|
||||
}
|
||||
if (ringIndex < 0) return false;
|
||||
// A display perimeter is never cut: any run along it is not this plan.
|
||||
if (ringIndex == 0 && material.HasDisplayPerimeter) return false;
|
||||
var tabs = material.Tabs[ringIndex];
|
||||
if (tabs.Any(tab => tab))
|
||||
{
|
||||
@@ -108,7 +110,7 @@ internal static class ContourProgramVerifier
|
||||
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
||||
true, tolerance, token, ref budget)) return false;
|
||||
}
|
||||
return visited.Count == material.Rings.Count;
|
||||
return visited.Count == material.Rings.Count - (material.HasDisplayPerimeter ? 1 : 0);
|
||||
}
|
||||
|
||||
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
|
||||
|
||||
@@ -12,8 +12,9 @@ namespace OpenNest.CNC.CuttingPlanning;
|
||||
public sealed class LeadMaterialSnapshot
|
||||
{
|
||||
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
|
||||
string reason)
|
||||
string reason, bool displayPerimeter = false)
|
||||
{
|
||||
HasDisplayPerimeter = displayPerimeter;
|
||||
Rings = rings;
|
||||
Tabs = tabs;
|
||||
Reason = reason;
|
||||
@@ -41,6 +42,12 @@ public sealed class LeadMaterialSnapshot
|
||||
/// </summary>
|
||||
internal IReadOnlyList<bool[]> Tabs { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the perimeter (<see cref="Rings"/>[0]) is a display perimeter: a supplied blank's
|
||||
/// edge that bounds material but is never cut. Every other ring is a hole the program cuts.
|
||||
/// </summary>
|
||||
internal bool HasDisplayPerimeter { get; }
|
||||
|
||||
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
|
||||
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
|
||||
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
|
||||
@@ -53,6 +60,8 @@ public sealed class LeadMaterialSnapshot
|
||||
var ringTabs = new List<bool[]>();
|
||||
var chain = new List<PostVerificationGeometry.Curve>();
|
||||
var chainTabs = new List<bool>();
|
||||
var ringDisplay = new List<bool>();
|
||||
var chainDisplay = new List<bool>();
|
||||
foreach (var motion in execution.Motions)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -63,7 +72,8 @@ public sealed class LeadMaterialSnapshot
|
||||
continue;
|
||||
}
|
||||
// A tabbed cutout's tabs are never cut but still close its hole.
|
||||
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab))
|
||||
// A display perimeter is never cut but still bounds the blank's material.
|
||||
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter))
|
||||
throw new ArgumentException("Material capture contains an unsupported layer.");
|
||||
var curve = motion.Curve;
|
||||
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
|
||||
@@ -72,6 +82,7 @@ public sealed class LeadMaterialSnapshot
|
||||
throw new ArgumentException("Material contour is discontinuous.");
|
||||
chain.Add(curve);
|
||||
chainTabs.Add(motion.Layer == LayerType.Tab);
|
||||
chainDisplay.Add(motion.Layer == LayerType.DisplayPerimeter);
|
||||
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||
Finish();
|
||||
}
|
||||
@@ -126,13 +137,17 @@ public sealed class LeadMaterialSnapshot
|
||||
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
|
||||
if (ringTabs[outer].Any(tab => tab))
|
||||
throw new ArgumentException("A perimeter cannot have tabs.");
|
||||
if (ringDisplay.Where((_, i) => i != outer).Any(display => display))
|
||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||
if (ringDisplay[outer] && rings.Count == 1)
|
||||
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
||||
var holes = rings.Where((_, i) => i != outer).ToArray();
|
||||
for (var i = 0; i < holes.Length; i++)
|
||||
for (var j = i + 1; j < holes.Length; j++)
|
||||
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
|
||||
throw new ArgumentException("Nested cutouts are not supported material.");
|
||||
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
|
||||
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null);
|
||||
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
|
||||
|
||||
void Finish()
|
||||
{
|
||||
@@ -145,10 +160,14 @@ public sealed class LeadMaterialSnapshot
|
||||
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
|
||||
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
|
||||
}
|
||||
if (chainDisplay.Contains(true) && chainDisplay.Contains(false))
|
||||
throw new ArgumentException("A display perimeter must be one whole closed contour.");
|
||||
rings.Add(chain.ToArray());
|
||||
ringTabs.Add(chainTabs.ToArray());
|
||||
ringDisplay.Add(chainDisplay.Count > 0 && chainDisplay[0]);
|
||||
chain.Clear();
|
||||
chainTabs.Clear();
|
||||
chainDisplay.Clear();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
|
||||
@@ -46,10 +46,20 @@ public sealed class PreparedContours
|
||||
// automatic entries keep to points where the tab steadies the part. World = local + origin.
|
||||
private readonly PartSupport support;
|
||||
private readonly Vector supportOrigin;
|
||||
// False when the outside is a display perimeter: shown and material, never cut. It is not one
|
||||
// of the contours, so every contour is a hole and no emission can reach the outline.
|
||||
private readonly bool hasCutPerimeter;
|
||||
|
||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
||||
CuttingParameters parameters)
|
||||
: this(shapes, scribes, breakCuts, tabbedCutouts, parameters, hasCutPerimeter: true)
|
||||
{
|
||||
}
|
||||
|
||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
||||
CuttingParameters parameters, bool hasCutPerimeter)
|
||||
{
|
||||
this.hasCutPerimeter = hasCutPerimeter;
|
||||
this.shapes = shapes;
|
||||
this.scribes = scribes;
|
||||
this.breakCuts = breakCuts;
|
||||
@@ -60,7 +70,8 @@ public sealed class PreparedContours
|
||||
}
|
||||
|
||||
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
|
||||
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters)
|
||||
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters,
|
||||
unpinned.hasCutPerimeter)
|
||||
{
|
||||
contourParameters = new CuttingParameters[shapes.Length];
|
||||
var rejected = new List<CutPinResolution>();
|
||||
@@ -95,7 +106,8 @@ public sealed class PreparedContours
|
||||
// A copy that tabs the perimeter for support; every pin keeps its entry, now owned here, and
|
||||
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review.
|
||||
private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
|
||||
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters)
|
||||
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
|
||||
source.hasCutPerimeter)
|
||||
{
|
||||
for (var contour = 0; contour < Count; contour++)
|
||||
{
|
||||
@@ -132,6 +144,8 @@ public sealed class PreparedContours
|
||||
PostVerificationGeometry.Validate(location);
|
||||
if (!support.IsComplete || support.BestTabPoint == null)
|
||||
throw new ArgumentException("Support tabs need a complete analysis with a tab point.", nameof(support));
|
||||
if (!hasCutPerimeter)
|
||||
throw new ArgumentException("A display perimeter is never cut, so it cannot hold a support tab.", nameof(support));
|
||||
return new PreparedContours(this, support, location, tab);
|
||||
}
|
||||
|
||||
@@ -145,19 +159,27 @@ public sealed class PreparedContours
|
||||
public Vector SupportOrigin => supportOrigin;
|
||||
|
||||
/// <summary>Whether the emitted perimeter is tabbed: by the plate settings, its pin or support.</summary>
|
||||
internal bool PerimeterTabbed => ParametersFor(PerimeterOrdinal).TabsEnabled;
|
||||
internal bool PerimeterTabbed => hasCutPerimeter && ParametersFor(PerimeterOrdinal).TabsEnabled;
|
||||
|
||||
public int Count => shapes.Length;
|
||||
|
||||
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
|
||||
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
|
||||
public int PerimeterOrdinal => Count - 1;
|
||||
/// <summary>
|
||||
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
|
||||
/// is a display perimeter, so every contour below it is a hole.
|
||||
/// </summary>
|
||||
public int PerimeterOrdinal => hasCutPerimeter ? Count - 1 : Count;
|
||||
|
||||
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
|
||||
public bool HasCutPerimeter => hasCutPerimeter;
|
||||
|
||||
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
|
||||
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
||||
|
||||
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
||||
public bool IsPinned(int contourOrdinal) => GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
|
||||
public bool IsPinned(int contourOrdinal) => !(contourOrdinal == PerimeterOrdinal && !hasCutPerimeter)
|
||||
&& GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
|
||||
|
||||
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
|
||||
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
|
||||
@@ -215,6 +237,7 @@ public sealed class PreparedContours
|
||||
var scribes = new List<Entity>();
|
||||
var breakCuts = new List<Shape>();
|
||||
var tabbedCutouts = new List<Shape>();
|
||||
Shape displayPerimeter = null;
|
||||
Shape breakCut = null;
|
||||
var current = new Shape();
|
||||
foreach (var entity in geometryProgram.ToGeometry())
|
||||
@@ -245,7 +268,8 @@ public sealed class PreparedContours
|
||||
scribes.Add(entity);
|
||||
continue;
|
||||
}
|
||||
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab)
|
||||
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab
|
||||
&& entity.Layer != SpecialLayers.DisplayPerimeter)
|
||||
throw new ArgumentException("Preparation requires clean cut/display contours.");
|
||||
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
||||
throw new ArgumentException("Degenerate contour entity.");
|
||||
@@ -263,6 +287,26 @@ public sealed class PreparedContours
|
||||
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
|
||||
FinishContour();
|
||||
}
|
||||
if (displayPerimeter != null)
|
||||
{
|
||||
if (current.Entities.Count != 0)
|
||||
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
||||
if (contours.Count == 0 && tabbedCutouts.Count == 0 && breakCuts.Count == 0)
|
||||
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
||||
var outline = displayPerimeter.BoundingBox;
|
||||
foreach (var shape in contours.Concat(tabbedCutouts).Concat(breakCuts))
|
||||
{
|
||||
shape.UpdateBounds();
|
||||
var box = shape.BoundingBox;
|
||||
if (box.Left < outline.Left - PostVerificationGeometry.Epsilon || box.Right > outline.Right + PostVerificationGeometry.Epsilon
|
||||
|| box.Bottom < outline.Bottom - PostVerificationGeometry.Epsilon || box.Top > outline.Top + PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||
}
|
||||
foreach (var cut in breakCuts)
|
||||
cut.UpdateBounds();
|
||||
// Never a contour: nothing can choose, emit or lead it. Every contour is a hole.
|
||||
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, hasCutPerimeter: false);
|
||||
}
|
||||
if (current.Entities.Count != 0 || contours.Count == 0)
|
||||
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
||||
var perimeter = 0;
|
||||
@@ -283,7 +327,15 @@ public sealed class PreparedContours
|
||||
void FinishContour()
|
||||
{
|
||||
current.UpdateBounds();
|
||||
if (ContourCuttingStrategy.IsTabbedCutout(current))
|
||||
if (current.Entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
||||
{
|
||||
if (!current.Entities.TrueForAll(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
||||
throw new ArgumentException("A display perimeter must be one whole closed contour.");
|
||||
if (displayPerimeter != null)
|
||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||
displayPerimeter = current;
|
||||
}
|
||||
else if (ContourCuttingStrategy.IsTabbedCutout(current))
|
||||
{
|
||||
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
|
||||
throw new ArgumentException("A tabbed cutout has nothing to cut.");
|
||||
@@ -728,7 +780,7 @@ public sealed class PreparedContours
|
||||
// directly, so the probe cannot drift the preparation or mutate it.
|
||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
|
||||
contourParameters);
|
||||
contourParameters, PerimeterOrdinal);
|
||||
}
|
||||
|
||||
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
|
||||
@@ -738,7 +790,7 @@ public sealed class PreparedContours
|
||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
|
||||
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
|
||||
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList());
|
||||
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal);
|
||||
}
|
||||
|
||||
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
|
||||
@@ -760,6 +812,8 @@ public sealed class PreparedContours
|
||||
token.ThrowIfCancellationRequested();
|
||||
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
|
||||
// Only the outside is tabbed, with its own pin's tab when it is pinned.
|
||||
if (!hasCutPerimeter)
|
||||
return new(this, choices, expected, 0);
|
||||
var perimeter = ParametersFor(PerimeterOrdinal);
|
||||
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
|
||||
}
|
||||
|
||||
@@ -251,14 +251,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
|
||||
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
|
||||
IReadOnlyList<Shape> tabbedCutouts = null)
|
||||
IReadOnlyList<Shape> tabbedCutouts = null, int? perimeterOrdinal = null)
|
||||
{
|
||||
var result = new Program(Mode.Absolute);
|
||||
EmitScribeContours(result, scribes);
|
||||
EmitBreakCuts(result, breakCuts, shapes);
|
||||
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
|
||||
EmitTabbedCutout(result, cutout);
|
||||
EmitChosenContours(result, shapes, choices, contourParameters);
|
||||
EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
@@ -269,20 +269,23 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
// perimeter never degrades to a hole because it was isolated. Output must not be
|
||||
// installed on a Part or accepted as a complete plan.
|
||||
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
|
||||
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null)
|
||||
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null,
|
||||
int? perimeterOrdinal = null)
|
||||
{
|
||||
var result = new Program(Mode.Absolute);
|
||||
EmitScribeContours(result, scribes);
|
||||
EmitChosenContours(result, shapes, new[] { choice }, contourParameters);
|
||||
EmitChosenContours(result, shapes, new[] { choice }, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
|
||||
// One contour per choice in order; the perimeter (last shape) is always External.
|
||||
// One contour per choice in order; the perimeter is always External. Without a cut perimeter
|
||||
// (a display perimeter outside) perimeterOrdinal is past the last shape: every contour is a hole.
|
||||
// Contour emission itself reads only the contour's own geometry and settings, never
|
||||
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
|
||||
private void EmitChosenContours(Program result, Shape[] shapes,
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters)
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters,
|
||||
int perimeterOrdinal)
|
||||
{
|
||||
var shared = Parameters;
|
||||
try
|
||||
@@ -294,7 +297,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
|
||||
var shape = shapes[choice.ContourOrdinal];
|
||||
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
|
||||
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
|
||||
exactCirclePrograms: true, scrapPierce: choice.Pierce);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,14 @@ namespace OpenNest.CNC
|
||||
/// leaves it out, cutting the rest of the contour as open pieces.
|
||||
/// </summary>
|
||||
Tab,
|
||||
|
||||
/// <summary>
|
||||
/// The outside edge of a supplied blank that is only shown, never cut (for example a
|
||||
/// purchased door whose instrument cutouts are the only cuts). It bounds material like
|
||||
/// <see cref="Cut"/>, so nesting, containment, lead clearance and cut-offs all see the
|
||||
/// blank, but it has no pierce, lead or motion in any emitted program. It must be one whole
|
||||
/// closed contour enclosing every other contour.
|
||||
/// </summary>
|
||||
DisplayPerimeter,
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,9 @@ namespace OpenNest.Converters
|
||||
if (string.Equals(name, SpecialLayers.Tab.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.Tab;
|
||||
|
||||
if (string.Equals(name, SpecialLayers.DisplayPerimeter.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.DisplayPerimeter;
|
||||
|
||||
return LayerType.Cut;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +213,9 @@ namespace OpenNest.Converters
|
||||
case LayerType.Tab:
|
||||
return SpecialLayers.Tab;
|
||||
|
||||
case LayerType.DisplayPerimeter:
|
||||
return SpecialLayers.DisplayPerimeter;
|
||||
|
||||
default:
|
||||
return new Layer(layer.ToString());
|
||||
}
|
||||
|
||||
@@ -24,12 +24,16 @@ namespace OpenNest
|
||||
/// <summary>A tabbed cutout's uncut tabs: material boundary that is never cut.</summary>
|
||||
public static readonly Layer Tab = new Layer("TAB") { Color = Color.Orange };
|
||||
|
||||
/// <summary>A supplied blank's shown-only outside edge: material boundary that is never cut.</summary>
|
||||
public static readonly Layer DisplayPerimeter = new Layer("DISPLAY PERIMETER") { Color = Color.DarkCyan };
|
||||
|
||||
/// <summary>
|
||||
/// True when an entity converted from a part program describes part material. Rapids
|
||||
/// and scribe/etch marks are excluded: marks are only on the surface, so they never
|
||||
/// bound material and must not affect nesting, collision, area, or validation. Break
|
||||
/// cuts are excluded too: they run through material that stays attached at tabs. Tabs are
|
||||
/// material: they close a tabbed cutout's boundary.
|
||||
/// material: they close a tabbed cutout's boundary. A display perimeter is material: it is the
|
||||
/// supplied blank's edge.
|
||||
/// </summary>
|
||||
public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe && layer != Break;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ internal static class BestEffortCuttingPlan
|
||||
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
|
||||
var route = CuttingHoleOrder.Plan(holes, CuttingHoleOrder.Centres(prepared, approach, token),
|
||||
approach, null, token);
|
||||
foreach (var contour in route.Append(prepared.PerimeterOrdinal))
|
||||
foreach (var contour in prepared.HasCutPerimeter ? route.Append(prepared.PerimeterOrdinal) : route)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
choices.Add(prepared.ClosestEntry(contour, approach));
|
||||
|
||||
@@ -518,13 +518,16 @@ internal static class JointCuttingPlanSearch
|
||||
continue;
|
||||
}
|
||||
var prepared = source.Prepared;
|
||||
if (contourRecovery && prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles && node.Active == null)
|
||||
// Recovery and endpoint branches are led by the cut perimeter; a display perimeter
|
||||
// is never cut, so its holes take the plain contour-by-contour search below.
|
||||
if (contourRecovery && prepared.HasCutPerimeter && prepared.Count > 1
|
||||
&& prepared.PerimeterOrdinal <= MaximumRecoveryHoles && node.Active == null)
|
||||
{
|
||||
foreach (var edge in RecoverPart(node, sequence, source, attempt))
|
||||
yield return edge;
|
||||
continue;
|
||||
}
|
||||
if (prepared.Count > 1 && node.Active == null)
|
||||
if (prepared.HasCutPerimeter && prepared.Count > 1 && node.Active == null)
|
||||
{
|
||||
// Selecting an endpoint does not cut it. Each endpoint owns a separate
|
||||
// branch, whose holes are still emitted first and checked normally.
|
||||
@@ -547,7 +550,7 @@ internal static class JointCuttingPlanSearch
|
||||
var arrival = node.Active?.Arrival ?? node.Position;
|
||||
var before = node.Active?.Before ?? node.Checker;
|
||||
var boundary = node.Active?.Boundary ?? node;
|
||||
var contours = choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal }
|
||||
var contours = prepared.HasCutPerimeter && choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal }
|
||||
: Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c));
|
||||
// First try one ranked hole chain per endpoint. A later-part failure then
|
||||
// changes the endpoint before replaying all earlier hole combinations.
|
||||
@@ -835,7 +838,7 @@ internal static class JointCuttingPlanSearch
|
||||
{
|
||||
reason = null;
|
||||
var prepared = source.Prepared;
|
||||
if (snapshot.PierceZoneRadius <= 0 || prepared.IsPinned(prepared.PerimeterOrdinal)
|
||||
if (snapshot.PierceZoneRadius <= 0 || !prepared.HasCutPerimeter || prepared.IsPinned(prepared.PerimeterOrdinal)
|
||||
|| source.Material is not { IsComplete: true })
|
||||
return [];
|
||||
CountExpansion(source);
|
||||
@@ -1076,7 +1079,7 @@ internal static class JointCuttingPlanSearch
|
||||
}
|
||||
// Recovery's contour solver first: its first route whose only violations, if any, are
|
||||
// where the arrival rapid crosses cut contours. It passes every check when it can.
|
||||
if (prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles
|
||||
if (prepared.HasCutPerimeter && prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles
|
||||
&& RecoverPart(node, KeptSequence, source, new Attempt(null), arrivalMayCross: true).FirstOrDefault() is { } recovered)
|
||||
{
|
||||
var proposal = recovered.Node.Order[^1];
|
||||
@@ -1100,7 +1103,8 @@ internal static class JointCuttingPlanSearch
|
||||
var local = (execution?.DeparturePoint ?? arrival) - source.Location;
|
||||
var before = severity;
|
||||
ContourChoice chosen = null;
|
||||
foreach (var contour in remaining.Count == 0 ? [prepared.PerimeterOrdinal] : remaining.Take(SafestHoleWindow))
|
||||
foreach (var contour in remaining.Count == 0 ? (prepared.HasCutPerimeter ? [prepared.PerimeterOrdinal] : [])
|
||||
: remaining.Take(SafestHoleWindow))
|
||||
{
|
||||
// Leave each hole toward the next one along the tour, so its exit rapid does
|
||||
// not run back across it; the perimeter faces the next part.
|
||||
|
||||
@@ -24,7 +24,8 @@ internal static class SupportTabCapture
|
||||
CuttingPlanRequest request, CancellationToken token)
|
||||
{
|
||||
var parameters = request.ConfirmedParameters;
|
||||
if (prepared == null || request.SupportBed == null || parameters?.SupportTabs != true
|
||||
// A display perimeter is a supplied blank: it is never cut free, so it needs no tab.
|
||||
if (prepared == null || !prepared.HasCutPerimeter || request.SupportBed == null || parameters?.SupportTabs != true
|
||||
|| parameters.TabConfig == null)
|
||||
return prepared;
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// A supplied blank (a purchased door) whose outline is a display perimeter: shown and treated as
|
||||
/// material, never cut. Only its cutouts are cut, each as a hole.
|
||||
/// </summary>
|
||||
public class DisplayPerimeterTests
|
||||
{
|
||||
// The door fills a 30 x 20 sheet: new Size(width, length) puts Length (30) along X.
|
||||
private static Plate DoorPlate(Program program)
|
||||
{
|
||||
var plate = new Plate(new Size(20, 30));
|
||||
plate.Parts.Add(new Part(new Drawing("door", program), Vector.Zero));
|
||||
return plate;
|
||||
}
|
||||
|
||||
internal static Program Door(LayerType outline = LayerType.DisplayPerimeter, bool cutouts = true,
|
||||
LayerType? lastEdge = null)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(0, 0));
|
||||
program.Codes.Add(new LinearMove(0, 20) { Layer = outline });
|
||||
program.Codes.Add(new LinearMove(30, 20) { Layer = outline });
|
||||
program.Codes.Add(new LinearMove(30, 0) { Layer = outline });
|
||||
program.Codes.Add(new LinearMove(0, 0) { Layer = lastEdge ?? outline });
|
||||
if (cutouts)
|
||||
AddCutouts(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
private static void AddCutouts(Program program)
|
||||
{
|
||||
program.MoveTo(5, 5); program.LineTo(5, 10); program.LineTo(10, 10); program.LineTo(10, 5); program.LineTo(5, 5);
|
||||
program.MoveTo(15, 5); program.LineTo(15, 12); program.LineTo(25, 12); program.LineTo(25, 5); program.LineTo(15, 5);
|
||||
program.MoveTo(9.5, 15);
|
||||
program.Codes.Add(new ArcMove(new Vector(9.5, 15), new Vector(8, 15), RotationType.CW));
|
||||
}
|
||||
|
||||
// Cutout extents, for checking where pierces and cuts land.
|
||||
private static readonly Box[] Cutouts =
|
||||
[
|
||||
new Box(5, 5, 5, 5),
|
||||
new Box(15, 5, 10, 7),
|
||||
new Box(6.5, 13.5, 3, 3),
|
||||
];
|
||||
|
||||
private static bool InsideACutout(Vector point) => Cutouts.Any(box =>
|
||||
point.X >= box.Left - 1e-6 && point.X <= box.Right + 1e-6 && point.Y >= box.Bottom - 1e-6 && point.Y <= box.Top + 1e-6);
|
||||
|
||||
[Fact]
|
||||
public void Door_PlansEveryCutoutAsAHoleAndNeverCutsTheOutline()
|
||||
{
|
||||
var plate = DoorPlate(Door());
|
||||
var part = plate.Parts[0];
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||
|
||||
var result = Assert.Single(proposal.Plates).Result;
|
||||
Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: false).Status);
|
||||
|
||||
var motions = ExecutionMotionReader.Read(part.Program, part.Location, null, default).Motions;
|
||||
Assert.DoesNotContain(motions, m => m.Layer == LayerType.DisplayPerimeter);
|
||||
var pierces = motions.Where((m, i) => m.Layer == LayerType.Leadin && m.Start is { } && (i == 0 || motions[i - 1].Rapid))
|
||||
.Select(m => m.Start!.Value).ToArray();
|
||||
Assert.Equal(3, pierces.Length);
|
||||
Assert.All(pierces, p => Assert.True(InsideACutout(p), $"Pierce ({p.X}, {p.Y}) is on the door."));
|
||||
Assert.All(motions.Where(m => !m.Rapid), m => Assert.True(InsideACutout(m.End), $"Cut to ({m.End.X}, {m.End.Y}) leaves the cutouts."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Door_AutomaticCutOffsAddNoLineAcrossTheBlank()
|
||||
{
|
||||
var plate = DoorPlate(Door());
|
||||
|
||||
var staging = AutomaticCutOffBatch.Stage([plate],
|
||||
new AutomaticCutOffOptions { Spacing = 10, MinimumStripLength = 5 }, new CutOffSettings());
|
||||
|
||||
Assert.False(staging.IsBlocked);
|
||||
Assert.Empty(Assert.Single(staging.Plans).Definitions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("mixed", "one whole closed contour")]
|
||||
[InlineData("hole", "enclose every other contour")]
|
||||
[InlineData("nothing", "noncutting")]
|
||||
public void InvalidDisplayPerimeters_AreRefusedWithTheirReason(string layout, string reason)
|
||||
{
|
||||
var program = layout switch
|
||||
{
|
||||
"mixed" => Door(lastEdge: LayerType.Cut),
|
||||
"nothing" => Door(cutouts: false),
|
||||
_ => HoleOnDisplay(),
|
||||
};
|
||||
var plate = DoorPlate(program);
|
||||
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
||||
confirmedParameters: ExplicitContourTests.Parameters()));
|
||||
|
||||
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.Contains(result.Findings, f => f.Message.Contains(reason, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
static Program HoleOnDisplay()
|
||||
{
|
||||
var program = Door(LayerType.Cut, cutouts: false);
|
||||
program.MoveTo(5, 5);
|
||||
foreach (var (x, y) in new[] { (5.0, 10.0), (10.0, 10.0), (10.0, 5.0), (5.0, 5.0) })
|
||||
program.Codes.Add(new LinearMove(x, y) { Layer = LayerType.DisplayPerimeter });
|
||||
return program;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_RefusesAProgramThatCutsTheDisplayPerimeter()
|
||||
{
|
||||
var material = LeadMaterialSnapshot.Capture(Door(), Vector.Zero);
|
||||
Assert.True(material.IsComplete, material.Reason);
|
||||
|
||||
var holesOnly = new Program();
|
||||
AddCutouts(holesOnly);
|
||||
var cutsOutline = Door(LayerType.Cut);
|
||||
|
||||
Assert.True(ContourProgramVerifier.Verify(
|
||||
ExecutionMotionReader.ReadSupported(holesOnly, Vector.Zero, null, default), material, null, default));
|
||||
Assert.False(ContourProgramVerifier.Verify(
|
||||
ExecutionMotionReader.ReadSupported(cutsOutline, Vector.Zero, null, default), material, null, default));
|
||||
// Same count of cut rings as a valid plan: the outline in place of the circular cutout.
|
||||
var outlineForAHole = Door(LayerType.Cut, cutouts: false);
|
||||
outlineForAHole.MoveTo(5, 5); outlineForAHole.LineTo(5, 10); outlineForAHole.LineTo(10, 10);
|
||||
outlineForAHole.LineTo(10, 5); outlineForAHole.LineTo(5, 5);
|
||||
outlineForAHole.MoveTo(15, 5); outlineForAHole.LineTo(15, 12); outlineForAHole.LineTo(25, 12);
|
||||
outlineForAHole.LineTo(25, 5); outlineForAHole.LineTo(15, 5);
|
||||
Assert.False(ContourProgramVerifier.Verify(
|
||||
ExecutionMotionReader.ReadSupported(outlineForAHole, Vector.Zero, null, default), material, null, default));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user