Files
OpenNest/OpenNest.Core/TabbedCutouts/TabbedCutoutLayout.cs
T
ajandClaude Opus 5.5 ba39c3dc55 feat(cad): add tabbed cutouts with draggable tabs
A hole can now be declared a tabbed cutout in the CAD Converter: pick a
circle or any edge of a closed hole, set the tab count and width, and drag
the tabs around the hole. The definition persists on the drawing and in the
nest file, and stays editable from the source entities.

The builder replaces the hole with open cut pieces and uncut spans on a new
Tab layer. Tabs are material, so area, nesting and overlap checks still see
a closed hole, but they are never cut. Plan Cutting and Assign Lead-ins cut
each piece from the slug with the internal lead-in and no lead-out. A lead
that would reach back over a tab is mirrored, and an arc lead onto an arc
pierces straight in. Planning accounts for each span between tabs and runs
the open-cut proof, so its own tabbed cutouts certify; tabs followed by a
lead-out are still refused. Pre-post review flags programs that still
contain tab motions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:50:11 -04:00

98 lines
4.1 KiB
C#

using System.Collections.Generic;
using System.Linq;
using OpenNest.Breakoffs;
using OpenNest.Geometry;
namespace OpenNest.TabbedCutouts
{
/// <summary>
/// A <see cref="TabbedCutout"/> resolved against a drawing's entities. When <see cref="Error"/>
/// is set nothing else is meaningful and the cutout is not applied.
/// </summary>
public sealed class TabbedCutoutLayout
{
internal TabbedCutoutLayout(string error) => Error = error;
internal TabbedCutoutLayout() { }
public string Error { get; }
public bool IsValid => Error == null;
/// <summary>
/// The hole's contour, counterclockwise from its lowest-left vertex with its joints made
/// exact. It is also the slug's boundary.
/// </summary>
public IReadOnlyList<Entity> Loop { get; internal set; }
public double Length { get; internal set; }
/// <summary>
/// One open cut per span of the contour between tabs, in contour order, travelling
/// counterclockwise (the slug on the left).
/// </summary>
public IReadOnlyList<IReadOnlyList<Entity>> Pieces { get; internal set; }
/// <summary>The uncut tabs on the tab layer, each following the piece before it.</summary>
public IReadOnlyList<IReadOnlyList<Entity>> Tabs { get; internal set; }
/// <summary>Tab centers used, as distances along <see cref="Loop"/>.</summary>
public IReadOnlyList<double> TabCenters { get; internal set; }
public double TabWidth { get; internal set; }
/// <summary>Where each piece is entered (its start) and the unit direction into the slug there.</summary>
public IReadOnlyList<(Vector Entry, Vector SlugDirection)> Entries => Pieces
.Select(piece =>
{
var tangent = BreakoffPath.TangentAt(piece, 0);
return (BreakoffPath.Start(piece[0]), new Vector(-tangent.Y, tangent.X));
})
.ToList();
/// <summary>Entities the pieces and tabs replace: the hole's original contour.</summary>
internal IReadOnlyList<Entity> ReplacedEntities { get; set; }
/// <summary><paramref name="count"/> evenly spaced tab centers, as the builder places them by default.</summary>
public List<double> EvenTabCenters(int count) =>
TabbedCutoutBuilder.EvenTabCenters(Length, count, Loop[0].Length / 2);
/// <summary>The point at <paramref name="distance"/> along the contour, wrapping around it.</summary>
public Vector PointAt(double distance) => BreakoffPath.PointAt(Loop, Wrap(distance));
/// <summary>The distance along the contour closest to <paramref name="point"/>.</summary>
public double DistanceOf(Vector point) => BreakoffPath.DistanceOf(Loop, point);
/// <summary>
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab keeps a
/// gap from its neighbours around the contour and does not pass the contour's start
/// (which would reorder the tabs).
/// </summary>
public double ClampTabCenter(int index, double center)
{
var count = TabCenters.Count;
if (count == 1)
return Wrap(center);
var spacing = TabWidth + MinGap;
var min = index == 0 ? System.Math.Max(0, TabCenters[^1] - Length + spacing) : TabCenters[index - 1] + spacing;
var max = index == count - 1
? System.Math.Min(Length - Tolerance, TabCenters[0] + Length - spacing)
: TabCenters[index + 1] - spacing;
return max < min ? TabCenters[index] : System.Math.Clamp(center, min, max);
}
internal double Wrap(double distance)
{
if (!(Length > 0))
return 0;
var wrapped = distance % Length;
return wrapped < 0 ? wrapped + Length : wrapped;
}
// Wider than the builder's overlap tolerance, so a clamped drag always resolves.
internal double MinGap => System.Math.Max(TabWidth / 2, 2 * TabbedCutoutBuilder.JoinTolerance);
private const double Tolerance = 1e-9;
}
}