4 Commits
Author SHA1 Message Date
aj 205d066762 docs(posts): describe CI Fiber M50 as Table In, the pallet swap
The pallet-change setting and post doc said M50 moves the cut sheet out,
borrowed from the CL-series manual. Cincinnati's CNC Codes Handbook calls
M50 "Table In" and M51 "Table Out". On the CI Fiber shuttle, Table In brings
the waiting pallet into the laser, which sends the cut one out, so M50 is
still the swap. Say that, note that it expects the waiting pallet to be
loaded, and note that newer Cincinnati samples end without a pallet code.
Default (M50) and posted output are unchanged.
2026-10-21 15:32:14 -04:00
aj 391e57a479 chore(posts): stop naming the CI Fiber customer sample part
The Cincinnati sample used by the CI Fiber tests is a customer production
part. Its geometry and NC were never tracked, but comments named its part
number and fixture file names and quoted its date and a pierce coordinate.
Replace them with generic wording and placeholder values; the fixture stays
outside the repository, configured only in the gitignored test-config.json.
Comment-only change.
2026-10-21 15:32:13 -04:00
aj 41ad75d385 test(posts): post the CI Fiber sample's own lead-ins and match it line for line
The sample regression fixture now carries each part's pierce, lead-in and
cut moves from the Cincinnati machine program as its saved cutting program.
A new test posts those saved programs without reassigning lead-ins and
requires the program body to equal the sample exactly: L2/L4 macro, G41/G42
side, lead-ins and every cut move (21,911 lines).

Mutation checks: dropping the largest-contour perimeter rule from the kerf
fix fails it at the first perimeter (L4 posted as L2), and forcing G41 fails
it at the first perimeter's G42. The existing tests still replace the saved
programs with OpenNest's own lead-ins first; their comment now says so
instead of describing the sample's leads wrongly.
2026-10-21 15:32:13 -04:00
ajandClaude Opus 5.5 122f9a9e47 fix(posts): put the CI Fiber kerf on the scrap side for either winding
The CI Fiber post selected G42 for every perimeter. Cincinnati's CNC Codes
Handbook defines G42 as "product remains on the left", and imported
perimeters run clockwise with the part on the right, so the controller
offset every outside edge into the part: parts came out a kerf undersize.
Cincinnati's LaserNST samples only use G42 because they cut perimeters
counter-clockwise.

Winding is normalized only at DXF import, so the post can't assume a
direction either. It now takes the largest contour as the perimeter (as
ShapeProfile does) and picks G41 or G42 from each contour's actual winding.
That also stops a reversed drawing from swapping the L2/L4 lead-in macros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:32:13 -04:00
620 changed files with 6505 additions and 59862 deletions

No files matched your search

+4 -10
View File
@@ -7,7 +7,7 @@ OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform librari
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`. User guides and their screenshots live in the separate `ajisaacs/opennest-help` repo: update or recapture them when a UI change alters a documented screen, and never rename a `HelpTopics` ID.
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`.
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
@@ -38,18 +38,17 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory.
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
## Geometry and ownership safeguards
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check, the runner's candidate validation and the pre-post part-spacing review all find spacing candidates with Core's `SpacingOutline` and confirm them on `NativeContours` (the outline circumscribes arcs by more than the slack), so they agree; `NestLayoutCheck.Clears` skips the confirmation and stays stricter for engine certification. Do not loosen tolerances to hide failures.
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check and the pre-post part-spacing review share Core's `SpacingOutline` for candidates; the review then confirms each one on finely flattened native contours, so it never warns where Auto Nest's check would pass. Do not loosen tolerances to hide failures.
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
- A cutoff starts only at the origin-side edge: stock can be oversize, so the far edge is unknown. Only a head-locked overcut (`CutOff.HeadLockOvercut`, `HeadHeightLock` markers) may start its far-edge segment there; posts refuse it without lock codes.
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
@@ -58,18 +57,13 @@ Read the relevant contract before changing its behavior:
- [Nest file format](docs/nest-file-format.md)
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
- [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
- [Lead-in placement](docs/geometry/lead-in-placement.md) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
- [Pre-post verification](docs/post-verification.md)
- [Machine cutting defaults](docs/machine-cutting-defaults.md): the nest's machine supplies leads, kerf and cut-off settings for plates without their own parameters
- [Shared settings](docs/shared-settings.md): in Database mode machines, post settings, clamps and nest defaults come from the server once it holds them; offline uses the last copy read, read-only
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install.
-9
View File
@@ -1,9 +0,0 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="OpenNest.Avalonia.App"
RequestedThemeVariant="Light">
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
</Application.Styles>
</Application>
-30
View File
@@ -1,30 +0,0 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace OpenNest.Avalonia
{
public partial class App : Application
{
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var window = new MainWindow();
desktop.MainWindow = window;
var args = desktop.Args ?? System.Array.Empty<string>();
if (args.Length > 0)
window.OpenNest(args[0]);
// --snapshot <png> [plate]: render the window to an image and exit, for checking the drawing.
var snapshot = System.Array.IndexOf(args, "--snapshot");
if (snapshot >= 0 && snapshot + 1 < args.Length)
window.SaveSnapshotAndClose(args[snapshot + 1],
snapshot + 2 < args.Length && int.TryParse(args[snapshot + 2], out var plate) ? plate : 1);
}
base.OnFrameworkInitializationCompleted();
}
}
}
-388
View File
@@ -1,388 +0,0 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering.SceneGraph;
using Avalonia.Skia;
using OpenNest.Avalonia.Rendering;
using OpenNest.CNC;
using OpenNest.Geometry;
using SkiaSharp;
using Control = Avalonia.Controls.Control;
using Vector = OpenNest.Geometry.Vector;
namespace OpenNest.Avalonia.Controls
{
/// <summary>
/// Draws one plate with Skia: pans with a left or middle drag, zooms at the cursor with the
/// wheel, fits on a double click, and highlights the part under the cursor.
/// </summary>
public sealed class PlateCanvas : Control
{
private static readonly SKColor BackgroundColor = new(169, 169, 169);
private static readonly SKColor PlateFillColor = new(245, 245, 245);
private static readonly SKColor PlateOutlineColor = new(128, 128, 128);
private static readonly SKColor EdgeSpacingColor = new(180, 180, 180);
private static readonly SKColor LeadColor = new(255, 0, 0);
private static readonly SKColor EtchColor = new(0, 150, 60);
private static readonly SKColor SelectedColor = new(150, 200, 255, 90 * 255 / 100);
// Paths are built once per program and only read afterwards, so the render thread may draw
// them while the UI thread builds others. Keyed by reference: a rotation replaces the program.
private readonly Dictionary<CNC.Program, ProgramPaths> paths = new();
private Plate plate;
private double scale = 1;
private Point origin;
private Point? dragStart;
private bool fitPending = true;
public Plate Plate
{
get => plate;
set
{
plate = value;
HoveredPart = null;
SelectedPart = null;
fitPending = true;
InvalidateVisual();
}
}
public Part HoveredPart { get; private set; }
public Part SelectedPart { get; private set; }
/// <summary>The cursor in plate coordinates, or null when it is outside the view.</summary>
public Vector? CursorWorld { get; private set; }
public event EventHandler ViewChanged;
public PlateCanvas()
{
ClipToBounds = true;
Focusable = true;
}
public void ClearCache() => paths.Clear();
public void ZoomToFit()
{
if (!Fit())
return;
InvalidateVisual();
ViewChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>Fits the view to the plate and its parts without requesting a redraw.</summary>
private bool Fit()
{
if (plate == null || Bounds.Width <= 0 || Bounds.Height <= 0)
return false;
var extents = PlateRect(plate);
foreach (var part in plate.Parts)
extents = Union(extents, part.BoundingBox);
const double margin = 20;
var sx = (Bounds.Width - 2 * margin) / System.Math.Max(extents.Length, 1e-6);
var sy = (Bounds.Height - 2 * margin) / System.Math.Max(extents.Width, 1e-6);
scale = System.Math.Min(sx, sy);
origin = new Point(
Bounds.Width / 2 - (extents.Left + extents.Length / 2) * scale,
Bounds.Height / 2 + (extents.Bottom + extents.Width / 2) * scale);
fitPending = false;
return true;
}
public double Zoom => scale;
private Vector ToWorld(Point p) => new((p.X - origin.X) / scale, (origin.Y - p.Y) / scale);
private SKMatrix WorldMatrix =>
new((float)scale, 0, (float)origin.X, 0, (float)-scale, (float)origin.Y, 0, 0, 1);
private static Box PlateRect(Plate plate)
{
var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
return new Box(left, bottom, plate.Size.Length, plate.Size.Width);
}
private static Box Union(Box a, Box b)
{
var left = System.Math.Min(a.Left, b.Left);
var bottom = System.Math.Min(a.Bottom, b.Bottom);
return new Box(left, bottom,
System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
}
private ProgramPaths PathsFor(Part part)
{
if (!paths.TryGetValue(part.Program, out var result))
paths[part.Program] = result = ProgramPaths.Build(part.Program);
return result;
}
public override void Render(DrawingContext context)
{
// A redraw may not be requested during a render, so tell listeners afterwards.
if (fitPending && Fit())
global::Avalonia.Threading.Dispatcher.UIThread.Post(() => ViewChanged?.Invoke(this, EventArgs.Empty));
var items = new List<PartItem>();
if (plate != null)
{
for (var i = 0; i < plate.Parts.Count; i++)
{
var part = plate.Parts[i];
items.Add(new PartItem(PathsFor(part), part.Location, part.BoundingBox,
ToSkColor(part.BaseDrawing.Color), (i + 1).ToString(),
ReferenceEquals(part, SelectedPart), ReferenceEquals(part, HoveredPart),
part.BaseDrawing.IsCutOff, part.HasManualLeadIns));
}
}
context.Custom(new PlateDrawOperation(new Rect(Bounds.Size), plate == null ? null : PlateRect(plate),
plate?.EdgeSpacing, WorldMatrix, items));
}
private static SKColor ToSkColor(System.Drawing.Color color) =>
color.IsEmpty ? new SKColor(130, 204, 130) : new SKColor(color.R, color.G, color.B, color.A);
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{
var at = e.GetPosition(this);
var factor = System.Math.Pow(1.2, e.Delta.Y);
// Keep the world point under the cursor fixed.
origin = new Point(at.X - (at.X - origin.X) * factor, at.Y - (at.Y - origin.Y) * factor);
scale *= factor;
InvalidateVisual();
ViewChanged?.Invoke(this, EventArgs.Empty);
e.Handled = true;
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
Focus();
var point = e.GetCurrentPoint(this);
if (e.ClickCount == 2)
{
ZoomToFit();
return;
}
if (point.Properties.IsLeftButtonPressed)
{
SelectedPart = HitTest(ToWorld(point.Position));
InvalidateVisual();
ViewChanged?.Invoke(this, EventArgs.Empty);
}
if (point.Properties.IsLeftButtonPressed || point.Properties.IsMiddleButtonPressed)
{
dragStart = point.Position;
e.Pointer.Capture(this);
}
}
protected override void OnPointerMoved(PointerEventArgs e)
{
var at = e.GetPosition(this);
if (dragStart is { } start)
{
origin += at - start;
dragStart = at;
InvalidateVisual();
}
CursorWorld = ToWorld(at);
var hovered = dragStart == null ? HitTest(CursorWorld.Value) : HoveredPart;
if (!ReferenceEquals(hovered, HoveredPart))
{
HoveredPart = hovered;
InvalidateVisual();
}
ViewChanged?.Invoke(this, EventArgs.Empty);
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
dragStart = null;
e.Pointer.Capture(null);
}
protected override void OnPointerExited(PointerEventArgs e)
{
CursorWorld = null;
if (HoveredPart != null)
{
HoveredPart = null;
InvalidateVisual();
}
ViewChanged?.Invoke(this, EventArgs.Empty);
}
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.Key == Key.F)
{
ZoomToFit();
e.Handled = true;
}
}
protected override void OnSizeChanged(global::Avalonia.Controls.SizeChangedEventArgs e)
{
base.OnSizeChanged(e);
if (fitPending)
InvalidateVisual();
}
/// <summary>The topmost part whose material contains <paramref name="world"/>.</summary>
private Part HitTest(Vector world)
{
if (plate == null)
return null;
for (var i = plate.Parts.Count - 1; i >= 0; i--)
{
var part = plate.Parts[i];
var box = part.BoundingBox;
if (world.X < box.Left || world.X > box.Right || world.Y < box.Bottom || world.Y > box.Top)
continue;
var local = world - part.Location;
if (PathsFor(part).Fill.Contains((float)local.X, (float)local.Y))
return part;
}
return null;
}
private sealed record PartItem(ProgramPaths Paths, Vector Location, Box Bounds, SKColor Color,
string Label, bool Selected, bool Hovered, bool IsCutOff, bool ShowLeads);
private sealed class PlateDrawOperation : ICustomDrawOperation
{
private readonly Box plateRect;
private readonly Spacing? edgeSpacing;
private readonly SKMatrix matrix;
private readonly List<PartItem> items;
public PlateDrawOperation(Rect bounds, Box plateRect, Spacing? edgeSpacing, SKMatrix matrix,
List<PartItem> items)
{
Bounds = bounds;
this.plateRect = plateRect;
this.edgeSpacing = edgeSpacing;
this.matrix = matrix;
this.items = items;
}
public Rect Bounds { get; }
public bool HitTest(Point p) => Bounds.Contains(p);
public bool Equals(ICustomDrawOperation other) => false;
public void Dispose()
{
}
public void Render(ImmediateDrawingContext context)
{
var lease = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
if (lease == null)
return;
using var api = lease.Lease();
var canvas = api.SkCanvas;
canvas.Save();
canvas.ClipRect(new SKRect(0, 0, (float)Bounds.Width, (float)Bounds.Height));
canvas.Clear(BackgroundColor);
if (plateRect != null)
Draw(canvas);
canvas.Restore();
}
private void Draw(SKCanvas canvas)
{
// Hairline strokes (width 0) stay one pixel wide at any zoom.
using var fill = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = true };
using var stroke = new SKPaint { Style = SKPaintStyle.Stroke, StrokeWidth = 0, IsAntialias = true };
canvas.Save();
var world = matrix;
canvas.Concat(ref world);
var plate = new SKRect((float)plateRect.Left, (float)plateRect.Bottom,
(float)plateRect.Right, (float)plateRect.Top);
fill.Color = PlateFillColor;
canvas.DrawRect(plate, fill);
if (edgeSpacing is { } spacing)
{
stroke.Color = EdgeSpacingColor;
canvas.DrawRect(new SKRect(plate.Left + (float)spacing.Left,
plate.Top + (float)spacing.Bottom, plate.Right - (float)spacing.Right,
plate.Bottom - (float)spacing.Top), stroke);
}
stroke.Color = PlateOutlineColor;
canvas.DrawRect(plate, stroke);
var view = canvas.LocalClipBounds;
foreach (var item in items)
{
var b = item.Bounds;
if (b.Right < view.Left || b.Left > view.Right || b.Top < view.Top || b.Bottom > view.Bottom)
continue;
canvas.Save();
canvas.Translate((float)item.Location.X, (float)item.Location.Y);
fill.Color = item.Selected ? SelectedColor
: item.Hovered ? Lighten(item.Color) : item.Color;
canvas.DrawPath(item.Paths.Fill, fill);
stroke.Color = EtchColor;
canvas.DrawPath(item.Paths.Etch, stroke);
stroke.Color = item.Selected ? new SKColor(30, 90, 200) : Darken(item.Color);
canvas.DrawPath(item.Paths.Cut, stroke);
// As on the desktop, only manually placed lead-ins are shown.
if (item.ShowLeads)
{
stroke.Color = LeadColor;
canvas.DrawPath(item.Paths.Lead, stroke);
}
canvas.Restore();
}
canvas.Restore();
DrawLabels(canvas);
}
// Ids are drawn in screen space and only where the part is big enough on screen to hold one.
private void DrawLabels(SKCanvas canvas)
{
using var text = new SKPaint { Color = SKColors.Black, IsAntialias = true, TextSize = 12 };
foreach (var item in items)
{
if (item.IsCutOff)
continue;
var center = matrix.MapPoint((float)item.Bounds.Center.X, (float)item.Bounds.Center.Y);
var width = item.Bounds.Length * matrix.ScaleX;
var height = item.Bounds.Width * -matrix.ScaleY;
var measured = text.MeasureText(item.Label);
if (measured + 4 > width || text.TextSize + 4 > height)
continue;
canvas.DrawText(item.Label, center.X - measured / 2, center.Y + text.TextSize / 3, text);
}
}
private static SKColor Darken(SKColor c) =>
new((byte)(c.Red * 0.55), (byte)(c.Green * 0.55), (byte)(c.Blue * 0.55), 255);
private static SKColor Lighten(SKColor c) =>
new((byte)(c.Red + (255 - c.Red) / 2), (byte)(c.Green + (255 - c.Green) / 2),
(byte)(c.Blue + (255 - c.Blue) / 2), c.Alpha);
}
}
}
-56
View File
@@ -1,56 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:c="using:OpenNest.Avalonia.Controls"
xmlns:local="using:OpenNest.Avalonia"
x:Class="OpenNest.Avalonia.MainWindow"
Title="OpenNest (Avalonia prototype)"
Width="1400" Height="860">
<DockPanel>
<Menu DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
<Separator />
<MenuItem Header="E_xit" Click="OnExit" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
<MenuItem Header="_Next Plate" InputGesture="PageDown" Click="OnNextPlate" />
</MenuItem>
</Menu>
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Content="Open" Click="OnOpen" />
<Button Content="Fit" Click="OnFit" />
<Button Content="Previous" Click="OnPreviousPlate" />
<Button Content="Next" Click="OnNextPlate" />
</StackPanel>
</Border>
<Border DockPanel.Dock="Bottom" Padding="6,3" Background="#F0F0F0">
<TextBlock x:Name="Status" Text="Open a .nest file to begin." />
</Border>
<Grid ColumnDefinitions="260,4,*,4,320">
<DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Plates" />
<ListBox x:Name="PlateList" SelectionChanged="OnPlateSelected" />
</DockPanel>
<GridSplitter Grid.Column="1" ResizeDirection="Columns" />
<c:PlateCanvas Grid.Column="2" x:Name="Canvas" />
<GridSplitter Grid.Column="3" ResizeDirection="Columns" />
<DockPanel Grid.Column="4">
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Drawings on plate" />
<DataGrid x:Name="DrawingGrid" x:DataType="local:DrawingRow" IsReadOnly="True"
AutoGenerateColumns="False" GridLinesVisibility="Horizontal" CanUserSortColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Drawing" Binding="{Binding Name}" Width="*" />
<DataGridTextColumn Header="Qty" Binding="{Binding Count}" />
<DataGridTextColumn Header="Size" Binding="{Binding Size}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Grid>
</DockPanel>
</Window>
-139
View File
@@ -1,139 +0,0 @@
using System;
using System.IO;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using OpenNest.IO;
namespace OpenNest.Avalonia
{
public sealed record DrawingRow(string Name, int Count, string Size);
public partial class MainWindow : Window
{
private Nest nest;
private string fileName;
public MainWindow()
{
InitializeComponent();
Canvas.ViewChanged += (_, _) => UpdateStatus();
KeyBindings.Add(new KeyBinding
{
Gesture = new KeyGesture(Key.O, KeyModifiers.Control),
Command = new Command(() => OnOpen(null, null)),
});
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageUp), Command = new Command(() => StepPlate(-1)) });
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageDown), Command = new Command(() => StepPlate(1)) });
}
public void OpenNest(string path)
{
try
{
nest = new NestReader(path).Read();
}
catch (Exception ex)
{
Status.Text = $"Could not open {Path.GetFileName(path)}: {ex.Message}";
return;
}
fileName = Path.GetFileName(path);
Title = $"{fileName} - OpenNest (Avalonia prototype)";
Canvas.ClearCache();
PlateList.ItemsSource = nest.Plates
.Select((plate, i) => $"Plate {i + 1} {plate.Size.Length:0.###} x {plate.Size.Width:0.###} ({plate.Parts.Count} parts)")
.ToList();
PlateList.SelectedIndex = nest.Plates.Count > 0 ? 0 : -1;
}
public void SaveSnapshotAndClose(string path, int plateNumber)
{
Opened += async (_, _) =>
{
if (nest?.Plates.Count > 0)
PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1);
await System.Threading.Tasks.Task.Delay(500);
var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height);
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
bitmap.Render(this);
bitmap.Save(path);
Close();
};
}
private async void OnOpen(object sender, RoutedEventArgs e)
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open Nest",
AllowMultiple = false,
FileTypeFilter = new[] { new FilePickerFileType("Nest files") { Patterns = new[] { "*.nest" } } },
});
var path = files.FirstOrDefault()?.TryGetLocalPath();
if (path != null)
OpenNest(path);
}
private void OnExit(object sender, RoutedEventArgs e) => Close();
private void OnFit(object sender, RoutedEventArgs e) => Canvas.ZoomToFit();
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
private void StepPlate(int step)
{
if (nest == null || nest.Plates.Count == 0)
return;
PlateList.SelectedIndex = System.Math.Clamp(PlateList.SelectedIndex + step, 0, nest.Plates.Count - 1);
}
private void OnPlateSelected(object sender, SelectionChangedEventArgs e)
{
var index = PlateList.SelectedIndex;
var plate = nest != null && index >= 0 ? nest.Plates[index] : null;
Canvas.Plate = plate;
DrawingGrid.ItemsSource = plate?.Parts
.Where(part => !part.BaseDrawing.IsCutOff)
.GroupBy(part => part.BaseDrawing)
.Select(group => new DrawingRow(group.Key.Name, group.Count(), SizeOf(group.First())))
.OrderByDescending(row => row.Count)
.ToList();
UpdateStatus();
}
private static string SizeOf(Part part) =>
$"{part.BoundingBox.Length:0.###} x {part.BoundingBox.Width:0.###}";
private void UpdateStatus()
{
if (Canvas.Plate == null)
return;
var text = $"{fileName} Plate {PlateList.SelectedIndex + 1} of {nest.Plates.Count} Zoom {Canvas.Zoom:0.##} px/unit";
if (Canvas.CursorWorld is { } at)
text += $" X {at.X:0.000} Y {at.Y:0.000}";
var part = Canvas.HoveredPart ?? Canvas.SelectedPart;
if (part != null)
text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}";
Status.Text = text;
}
private sealed class Command : System.Windows.Input.ICommand
{
private readonly Action action;
public Command(Action action) => this.action = action;
public event EventHandler CanExecuteChanged { add { } remove { } }
public bool CanExecute(object parameter) => true;
public void Execute(object parameter) => action();
}
}
}
@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Avalonia</RootNamespace>
<AssemblyName>OpenNest.Avalonia</AssemblyName>
<Nullable>disable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<PackageReference Include="Avalonia" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.0" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
</ItemGroup>
</Project>
-18
View File
@@ -1,18 +0,0 @@
using System;
using Avalonia;
namespace OpenNest.Avalonia
{
internal static class Program
{
[STAThread]
public static void Main(string[] args) =>
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}
-175
View File
@@ -1,175 +0,0 @@
using System;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using SkiaSharp;
namespace OpenNest.Avalonia.Rendering
{
/// <summary>
/// A program's display geometry as Skia paths in the program frame, before placement.
/// A Skia port of the desktop's GraphicsHelper.GetDisplayPaths, without tab bridging.
/// </summary>
internal sealed class ProgramPaths : IDisposable
{
public SKPath Cut { get; } = new() { FillType = SKPathFillType.EvenOdd };
public SKPath Lead { get; } = new();
public SKPath Etch { get; } = new();
/// <summary>The material outline to fill: cuts and tabs, without open break cuts.</summary>
public SKPath Fill { get; } = new() { FillType = SKPathFillType.EvenOdd };
public SKRect Bounds { get; private set; }
public static ProgramPaths Build(CNC.Program program)
{
var paths = new ProgramPaths();
var position = Vector.Zero;
paths.AddProgram(program, ref position);
var bounds = paths.Cut.Bounds;
if (!paths.Etch.IsEmpty)
bounds = paths.Cut.IsEmpty ? paths.Etch.Bounds : SKRect.Union(bounds, paths.Etch.Bounds);
paths.Bounds = bounds;
return paths;
}
private SKPath TargetFor(LayerType layer) => layer switch
{
LayerType.Leadin or LayerType.Leadout => Lead,
LayerType.Scribe => Etch,
LayerType.Display or LayerType.Tab => null,
_ => Cut,
};
private void AddProgram(CNC.Program program, ref Vector position)
{
// Sub-program offsets are relative to the frame origin at entry, not the tool position.
var frameOrigin = position;
var mode = program.Mode;
SKPath open = null;
for (var i = 0; i < program.Length; ++i)
{
var code = program[i];
switch (code)
{
case LinearMove line:
{
var end = mode == Mode.Incremental ? line.EndPoint + position : line.EndPoint;
var target = line.Suppressed ? null : TargetFor(line.Layer);
if (target != null)
{
Continue(ref open, target, position);
target.LineTo((float)end.X, (float)end.Y);
}
if (!line.Suppressed && line.Layer != LayerType.Break)
AddFillLine(line.Layer, position, end);
position = end;
break;
}
case ArcMove arc:
{
var end = arc.EndPoint;
var center = arc.CenterPoint;
if (mode == Mode.Incremental)
{
end += position;
center += position;
}
var target = arc.Suppressed ? null : TargetFor(arc.Layer);
if (target != null)
{
Continue(ref open, target, position);
AddArc(target, position, end, center, arc.Rotation);
}
if (!arc.Suppressed && arc.Layer != LayerType.Break)
AddFillArc(arc.Layer, position, end, center, arc.Rotation);
position = end;
break;
}
case RapidMove rapid:
{
var end = mode == Mode.Incremental ? rapid.EndPoint + position : rapid.EndPoint;
if (end.DistanceTo(position) > 0.001)
open = null;
position = end;
break;
}
case SubProgramCall call when call.Program != null:
open = null;
position = new Vector(frameOrigin.X + call.Offset.X, frameOrigin.Y + call.Offset.Y);
AddProgram(call.Program, ref position);
open = null;
break;
}
}
}
// Starts a new contour unless the move continues the one already open on the same path.
private SKPath openFill;
private static void Continue(ref SKPath open, SKPath target, Vector from)
{
if (!ReferenceEquals(open, target) || target.LastPoint != new SKPoint((float)from.X, (float)from.Y))
target.MoveTo((float)from.X, (float)from.Y);
open = target;
}
private bool IsMaterial(LayerType layer) =>
layer is not (LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Display);
private void AddFillLine(LayerType layer, Vector from, Vector to)
{
if (!IsMaterial(layer))
return;
Continue(ref openFill, Fill, from);
Fill.LineTo((float)to.X, (float)to.Y);
}
private void AddFillArc(LayerType layer, Vector from, Vector to, Vector center, RotationType rotation)
{
if (!IsMaterial(layer))
return;
Continue(ref openFill, Fill, from);
AddArc(Fill, from, to, center, rotation);
}
private static void AddArc(SKPath path, Vector from, Vector to, Vector center, RotationType rotation)
{
var startAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(from.Y - center.Y, from.X - center.X)));
var endAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(to.Y - center.Y, to.X - center.X)));
var radius = (float)center.DistanceTo(to);
var oval = new SKRect((float)center.X - radius, (float)center.Y - radius,
(float)center.X + radius, (float)center.Y + radius);
if (startAngle.IsEqualTo(endAngle))
{
// A full circle: two half sweeps keep the contour starting at the current point.
var sweep = rotation == RotationType.CCW ? 180f : -180f;
path.ArcTo(oval, (float)startAngle, sweep, false);
path.ArcTo(oval, (float)startAngle + sweep, sweep, false);
return;
}
if (rotation == RotationType.CCW && endAngle < startAngle)
endAngle += 360.0;
else if (rotation == RotationType.CW && startAngle < endAngle)
startAngle += 360.0;
path.ArcTo(oval, (float)startAngle, (float)(endAngle - startAngle), false);
}
public void Dispose()
{
Cut.Dispose();
Lead.Dispose();
Etch.Dispose();
Fill.Dispose();
}
}
}
+9 -33
View File
@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -38,12 +37,6 @@ namespace OpenNest.Benchmark
public double PartSpacing { get; init; }
public int Quadrant { get; init; }
/// <summary>Source plate's machine clamp rail, resolved for each candidate size as Auto Nest does; null when none.</summary>
public ClampRail ClampRail { get; init; }
/// <summary>Source plate's explicit clamp zones when it has no rail, offered unchanged on every size.</summary>
public IReadOnlyList<ClampZone> ClampZones { get; init; } = Array.Empty<ClampZone>();
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
public double SalvageRate { get; init; }
@@ -67,8 +60,7 @@ namespace OpenNest.Benchmark
/// (unlimited quantity - the engine under test decides how many of each
/// size it actually uses, and how demand splits across plates). The
/// engine owns its own multi-plate/size strategy; this harness no
/// longer picks plate sizes on the engine's behalf. Stock comes from
/// the same template builder as desktop Auto Nest, so clamps match it.
/// longer picks plate sizes on the engine's behalf.
/// </summary>
public NestJob BuildNestJob(
int maxPlates,
@@ -79,13 +71,14 @@ namespace OpenNest.Benchmark
var parts = Requests.Select(r =>
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
);
// No options would make the builder offer the template itself; an empty pool stays empty.
var stock = CandidateSizes.Count == 0
? Array.Empty<NestPlateStock>()
: NestStockBuilder.FromTemplate(
StockTemplate(),
CandidateSizes.Select(size => new PlateOption { Width = size.Width, Length = size.Length }).ToList()
);
var stock = CandidateSizes.Select(size => new NestPlateStock(
size.ToString(1),
size,
null,
PartSpacing,
EdgeSpacing,
Quadrant
));
return new NestJob(
parts,
stock,
@@ -97,22 +90,5 @@ namespace OpenNest.Benchmark
)
);
}
/// <summary>A plate carrying this job's spacing, quadrant and clamps, sized to the largest candidate.</summary>
private Plate StockTemplate()
{
var template = new Plate(CandidateSizes.MaxBy(size => size.Width * size.Length))
{
PartSpacing = PartSpacing,
EdgeSpacing = EdgeSpacing,
Quadrant = Quadrant,
};
if (ClampRail != null)
template.SetClampRail(ClampRail);
else
foreach (var zone in ClampZones)
template.ClampZones.Add(zone);
return template;
}
}
}
+8 -1
View File
@@ -126,7 +126,14 @@ namespace OpenNest.Benchmark
.BaselinePlateRuns.Select(
(run, index) =>
{
var stock = DrawingJobMapper.FromPlate($"baseline-{index}", run.Plate, 1);
var stock = new NestPlateStock(
$"baseline-{index}",
run.Plate.Size,
1,
run.Plate.PartSpacing,
run.Plate.EdgeSpacing,
run.Plate.Quadrant
);
var placements = run
.Parts.Select(part =>
{
+5 -29
View File
@@ -74,8 +74,6 @@ namespace OpenNest.Benchmark
EdgeSpacing = template.EdgeSpacing,
PartSpacing = partSpacingOverride ?? template.PartSpacing,
Quadrant = template.Quadrant,
ClampRail = template.ClampRail,
ClampZones = template.ClampZones,
SalvageRate = nest.SalvageRate,
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
Requests = requests,
@@ -184,45 +182,23 @@ namespace OpenNest.Benchmark
plate.EdgeSpacing.Top
),
};
// Clamps are part of the sheet the layout was made on.
if (plate.ClampRail != null)
validationPlate.SetClampRail(plate.ClampRail, plate.RemovedClamps);
else
foreach (var zone in plate.ClampZones)
validationPlate.ClampZones.Add(zone);
for (var copy = 0; copy < plate.Quantity; copy++)
runs.Add((validationPlate, parts));
}
return runs.Count > 0 ? runs : null;
}
private static (
Spacing EdgeSpacing,
double PartSpacing,
int Quadrant,
ClampRail ClampRail,
IReadOnlyList<ClampZone> ClampZones
) ResolvePlateTemplate(Nest nest)
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
Nest nest
)
{
var source = nest.Plates?.FirstOrDefault();
if (source != null)
return (
source.EdgeSpacing,
source.PartSpacing,
source.Quadrant,
source.ClampRail,
source.ClampRail == null ? source.ClampZones.ToList() : Array.Empty<ClampZone>()
);
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
var defaults = nest.PlateDefaults;
return (
defaults.EdgeSpacing,
defaults.PartSpacing,
defaults.Quadrant,
defaults.ClampRail,
Array.Empty<ClampZone>()
);
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
}
private static List<Size> ResolveSheetSizes(Nest nest)
+1 -2
View File
@@ -115,7 +115,7 @@ static class NestConsole
Console.Error.WriteLine(
engine.IsJobEngine
? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as Irregular require --autonest)"
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as StockLadder require --autonest)"
);
return 1;
}
@@ -824,7 +824,6 @@ static class NestConsole
{
CuttingPlanPhase.Reordering => "finding a part order...",
CuttingPlanPhase.KeepingOrder => "keeping the current order...",
CuttingPlanPhase.SafestRoute => "finding the safest route...",
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
_ => value.Phase + "...",
});
+28 -201
View File
@@ -27,14 +27,6 @@ public sealed class AutomaticCutOffOptions
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
/// </summary>
public double? MinimumStripLength { get; set; }
/// <summary>
/// Whether to propose a full-length horizontal separator that keeps the unused band beyond the
/// parts along Y (away from the origin) when that band is a larger piece than the X tail.
/// The band must be at least MinimumTailLength wide. False preserves vertical-only proposals.
/// An existing full-length horizontal cut-off clear of the parts is honored either way.
/// </summary>
public bool KeepHorizontalRemnant { get; set; }
}
public enum AutomaticCutOffDiagnosticCode
@@ -47,8 +39,6 @@ public enum AutomaticCutOffDiagnosticCode
NoSafeTailSeparator,
TailBelowMinimum,
ShiftedCut,
HorizontalRemnant,
NoSafeHorizontalSeparator,
}
public sealed record AutomaticCutOffDiagnostic(
@@ -87,15 +77,6 @@ public sealed class AutomaticCutOffPlan
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
public bool HasSeparatedTail => TailSeparatorX.HasValue;
/// <summary>
/// Signed Y coordinate of the new or existing full-length horizontal separator the vertical
/// lines stop at, keeping the band beyond it; null when the lines cross the full sheet width.
/// </summary>
public double? HorizontalSeparatorY { get; internal set; }
/// <summary>Width along Y of the band kept beyond HorizontalSeparatorY, otherwise zero.</summary>
public double HorizontalRemnantWidth { get; internal set; }
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
public double StripCutLength { get; internal set; }
@@ -109,15 +90,6 @@ public sealed class AutomaticCutOffPlan
public static class AutomaticCutOffPlanner
{
public const double GeometryTolerance = Tolerance.Epsilon;
/// <summary>
/// How far a display perimeter (a supplied blank's never-cut outline) may extend past the
/// physical sheet, in model units. Imported blanks equal their sheet but carry the source CAM's
/// rounding (PEP: a 0.000006 outline skew plus placements rounded to 0.001), a few hundred-
/// thousandths of an inch. Nothing cuts that outline, so it is accepted; anything cut, or a
/// larger overhang that signals a real placement error, is still refused.
/// </summary>
public const double DisplayPerimeterSheetAllowance = 1e-4;
public const double MinimumSpacing = 2 * GeometryTolerance;
public const int MaximumCandidateCount = 10000;
public const int MaximumShiftCandidateCount = 20000;
@@ -143,9 +115,7 @@ public static class AutomaticCutOffPlanner
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
var ySign = plate.Quadrant is 3 or 4 ? -1 : 1;
var occupied = 0.0;
var occupiedY = 0.0;
var hasParts = false;
foreach (var part in plate.Parts)
{
@@ -153,9 +123,7 @@ public static class AutomaticCutOffPlanner
if (part.BaseDrawing.IsCutOff)
continue;
var (partX, partY) = ValidatePart(part, bounds, sign, ySign);
occupied = System.Math.Max(occupied, partX);
occupiedY = System.Math.Max(occupiedY, partY);
occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign));
hasParts = true;
}
if (!hasParts)
@@ -163,62 +131,16 @@ public static class AutomaticCutOffPlanner
// Use distances from the coordinate origin, not the sheet's lower-left corner.
var length = plate.Size.Length;
var width = plate.Size.Width;
occupied = System.Math.Min(occupied, length);
occupiedY = System.Math.Min(occupiedY, width);
var margin = System.Math.Max(plate.PartSpacing, settings.PartClearance) + GeometryTolerance;
var separator = occupied + margin;
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
+ GeometryTolerance;
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
var diagnostics = new List<AutomaticCutOffDiagnostic>();
// A band beyond the parts along Y is kept by a full-length horizontal separator; the
// vertical lines then stop at it instead of slicing the band. A user's line clear of the
// parts is honored; a new one is proposed only when its band beats the X tail.
var horizontalDistance = plate.CutOffs
.Where(c => c?.Axis == CutOffAxis.Horizontal && Finite(c.Position) && FullLengthLimits(c, bounds, settings))
.Select(c => ySign * c.Position.Y)
.Where(distance => distance > occupiedY + GeometryTolerance && distance < width - GeometryTolerance
&& width - distance >= options.MinimumTailLength)
.Select(distance => (double?)distance)
.Min();
var horizontalExists = horizontalDistance.HasValue;
if (!horizontalExists && options.KeepHorizontalRemnant)
{
var proposed = occupiedY + margin;
var tailArea = hasTailCandidate && length - separator >= options.MinimumTailLength
? (length - separator) * width : 0;
if (double.IsFinite(proposed) && proposed < width - GeometryTolerance
&& width - proposed >= options.MinimumTailLength && (width - proposed) * length > tailArea)
horizontalDistance = proposed;
}
if (horizontalDistance.HasValue && !horizontalExists)
{
var y = ySign * horizontalDistance.Value;
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
var clamp = clamps.FirstOrDefault(zone =>
y >= zone.Bounds.Bottom - plate.EdgeSpacing.Bottom && y <= zone.Bounds.Top + plate.EdgeSpacing.Bottom);
if (limited || clamp != null)
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
(clamp != null ? $"A horizontal separator at Y = {y:G8} would cross clamp '{clamp.Id}'"
: $"A same-line manual cut at Y = {y:G8} has different limits")
+ "; vertical lines cross the full sheet width instead."));
horizontalDistance = null;
}
}
// A tail too short to keep stays attached to the skeleton, so the strip grid must
// continue to the sheet end; stopping at the skipped separator would leave a final
// strip longer than the spacing. Beside a kept horizontal band the tail is a corner
// piece, which must also be wide enough to keep.
bool KeepsTail(double? band) => hasTailCandidate && length - separator >= options.MinimumTailLength
&& (band == null || band.Value >= options.MinimumTailLength);
var keepsTail = KeepsTail(horizontalDistance);
// strip longer than the spacing.
var keepsTail = hasTailCandidate && length - separator >= options.MinimumTailLength;
var usedSpan = keepsTail ? separator : length;
// Bound the count for either outcome: the horizontal separator is verified only below.
var candidateCount = System.Math.Ceiling(System.Math.Max(usedSpan, KeepsTail(null) ? separator : length)
/ options.Spacing);
var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
@@ -226,31 +148,7 @@ public static class AutomaticCutOffPlanner
ValidateExisting(plate, bounds, settings);
var cache = Plate.BuildPerimeterCache(plate);
var definitions = new List<CutOff>();
CutOff horizontalSeparator = null;
if (horizontalDistance.HasValue && !horizontalExists)
{
horizontalSeparator = new CutOff(new Vector(0, ySign * horizontalDistance.Value), CutOffAxis.Horizontal);
horizontalSeparator.Regenerate(plate, settings, cache);
if (!HasUsableSegments(horizontalSeparator.Drawing.Program, false) ||
!IsFullSpanProgram(horizontalSeparator.Drawing.Program, false, bounds.Left, bounds.Right))
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
"No full-length horizontal separator survives the current settings; "
+ "vertical lines cross the full sheet width instead."));
horizontalSeparator = null;
horizontalDistance = null;
keepsTail = KeepsTail(null);
usedSpan = keepsTail ? separator : length;
candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
}
}
// Vertical lines run from the origin-side edge up to a kept band's separator.
var horizontalY = horizontalDistance.HasValue ? ySign * horizontalDistance.Value : (double?)null;
var startLimit = ySign < 0 ? horizontalY : null;
var endLimit = ySign > 0 ? horizontalY : null;
var spanLow = startLimit ?? bounds.Bottom;
var spanHigh = endLimit ?? bounds.Top;
var diagnostics = new List<AutomaticCutOffDiagnostic>();
var separated = false;
var separatorX = (double?)null;
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
@@ -264,7 +162,7 @@ public static class AutomaticCutOffPlanner
// Full-span definitions fix the strip boundaries, even when their emitted
// programs are segmented. Partial cuts remain obstacles, not boundaries.
var boundaries = plate.CutOffs
.Where(c => c.Axis == CutOffAxis.Vertical && Spans(c))
.Where(c => c.Axis == CutOffAxis.Vertical && FullSpanLimits(c, bounds, settings))
.Select(c => sign * c.Position.X)
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
.Distinct().OrderBy(distance => distance).Append(usedSpan);
@@ -314,15 +212,6 @@ public static class AutomaticCutOffPlanner
+ "and strip lines continue to the sheet end. Existing cut-offs are unchanged. No retained tail is claimed.",
X: sign * separator));
if (horizontalSeparator != null)
definitions.Add(horizontalSeparator);
if (horizontalY.HasValue)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.HorizontalRemnant,
$"Vertical lines stop at the {(horizontalExists ? "existing" : "new")} horizontal separator at "
+ $"Y = {horizontalY.Value:G8}, keeping a {width - horizontalDistance.Value:G8}-wide band "
+ "along the sheet length."));
if (diagnostics.Any(d => d.IsBlocking))
{
// A partial proposal must not accidentally be accepted around a manual conflict.
@@ -330,24 +219,12 @@ public static class AutomaticCutOffPlanner
stripCutLength = 0;
separated = false;
separatorX = null;
horizontalY = null;
}
// The legacy placement loop stays untouched. Its detached programs are already
// regenerated, so reporting their actual new strip cutting needs no new search.
if (!shifted)
fixedGridCutLength = stripCutLength;
// Lines are planned and measured without the head-locked overcut: it only extends the
// segment reaching the far edge past the sheet, where there is nothing to separate.
if (settings.NewCutOffHeadLockOvercut > 0)
{
foreach (var definition in definitions)
{
definition.HeadLockOvercut = settings.NewCutOffHeadLockOvercut;
definition.Regenerate(plate, settings, cache);
}
}
return new AutomaticCutOffPlan
{
Definitions = definitions.AsReadOnly(),
@@ -359,22 +236,8 @@ public static class AutomaticCutOffPlanner
TailSeparatorX = separatorX,
StripCutLength = stripCutLength,
FixedGridCutLength = fixedGridCutLength,
HorizontalSeparatorY = horizontalY,
HorizontalRemnantWidth = horizontalY.HasValue ? width - horizontalDistance.Value : 0,
};
CutOff NewVertical(double x) => new(new Vector(x, 0), CutOffAxis.Vertical)
{
StartLimit = startLimit,
EndLimit = endLimit,
};
// A full-span line, or one with the limits this plan gives its own vertical lines.
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
horizontalY.HasValue &&
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
void AddShiftedGap(double start, double end, int count, double step)
{
var nominal = new List<double>(count);
@@ -427,7 +290,8 @@ public static class AutomaticCutOffPlanner
RegisterCandidate(distance);
if (!measurements.TryGetValue(distance, out var measured))
{
measured = NewVertical(sign * distance).MeasureSegments(plate, settings, cache);
measured = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical)
.MeasureSegments(plate, settings, cache);
measurements.Add(distance, measured);
}
return measured;
@@ -438,7 +302,7 @@ public static class AutomaticCutOffPlanner
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
Near(c.Position.X, x)).ToList();
if (matches.Any(c => !Spans(c)))
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
@@ -471,7 +335,7 @@ public static class AutomaticCutOffPlanner
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
var existing = matches.FirstOrDefault();
var candidate = existing == null
? NewVertical(x)
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
: new CutOff(existing.Position, existing.Axis)
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
candidate.Regenerate(plate, settings, cache);
@@ -489,7 +353,7 @@ public static class AutomaticCutOffPlanner
if (isSeparator)
{
if (!usable || !IsFullSpanProgram(program, true, spanLow, spanHigh))
if (!usable || !IsFullSpanProgram(program, bounds))
{
WarnNoSeparator(x);
return;
@@ -497,7 +361,7 @@ public static class AutomaticCutOffPlanner
separated = true;
separatorX = candidate.Position.X;
}
else if (usable && !IsFullSpanProgram(program, true, spanLow, spanHigh))
else if (usable && !IsFullSpanProgram(program, bounds))
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.SegmentedCut,
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
@@ -550,14 +414,12 @@ public static class AutomaticCutOffPlanner
"Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate));
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
"A head-locked overcut must be finite and positive.", nameof(settings));
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
Require(plate.Parts != null && plate.CutOffs != null,
"Plate parts and cut-off collections are required.", nameof(plate));
}
private static (double X, double Y) ValidatePart(Part part, Box sheet, int sign, int ySign)
private static double ValidatePart(Part part, Box sheet, int sign)
{
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
"Part pose must be finite.", "plate");
@@ -570,32 +432,19 @@ public static class AutomaticCutOffPlanner
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
"Part geometry has stale bounds; update it before planning.", "plate");
var hasDisplayPerimeter = DisplayPerimeterTail.HasDisplayPerimeterMotion(part.Program);
Require(Inside(box, sheet, hasDisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
"Part geometry extends outside the physical sheet.", "plate");
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
// Checking converted entities catches overflowing incremental moves and curve bounds.
var hasMaterial = false;
var occupied = sign > 0 ? box.Right : -box.Left;
var occupiedY = ySign > 0 ? box.Top : -box.Bottom;
var roundoff = CutOff.GetBoundsRoundoff(part);
var entities = ConvertProgram.ToGeometry(part.Program);
var outline = entities.Where(e => e.Layer == SpecialLayers.DisplayPerimeter).ToList();
foreach (var entity in entities)
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
{
var entityBox = entity.BoundingBox;
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
if (!SpecialLayers.IsMaterial(entity.Layer))
{
// Travel and marks (a scribe is lasered) keep the ordinary tolerance. Only a rapid
// ending on the outline (its first corner, or an applied program's display tail)
// shares the outline's allowance.
Require(Inside(entityBox.Translate(part.Location), sheet)
|| hasDisplayPerimeter && IsRapidOntoOutline(entity, outline, part.Location, sheet),
"Part geometry extends outside the physical sheet.", "plate");
continue;
}
entityBox = entityBox.Translate(part.Location);
// Permit only bounded floating-point roundoff, with the same conservative
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
@@ -603,15 +452,12 @@ public static class AutomaticCutOffPlanner
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
Require(Inside(entityBox, sheet,
entity.Layer == SpecialLayers.DisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
"Part geometry extends outside the physical sheet.", "plate");
Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate");
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
occupiedY = System.Math.Max(occupiedY, ySign > 0 ? entityBox.Top : -entityBox.Bottom);
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
}
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
return (occupied, occupiedY);
return occupied;
}
private static void ValidateProgram(Program program, HashSet<Program> path)
@@ -656,7 +502,7 @@ public static class AutomaticCutOffPlanner
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
private static bool HasUsableSegments(Program program, bool vertical = true)
private static bool HasUsableSegments(Program program)
{
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
return false;
@@ -664,49 +510,30 @@ public static class AutomaticCutOffPlanner
{
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
(vertical ? !Near(from.EndPoint.X, to.EndPoint.X) : !Near(from.EndPoint.Y, to.EndPoint.Y)) ||
(vertical ? System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y)
: System.Math.Abs(from.EndPoint.X - to.EndPoint.X)) <= GeometryTolerance)
!Near(from.EndPoint.X, to.EndPoint.X) ||
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance)
return false;
}
return true;
}
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
private static bool IsFullSpanProgram(Program program, bool vertical, double low, double high)
private static bool IsFullSpanProgram(Program program, Box bounds)
{
// No gaps, bridges, or filtered middle segments can separate the tail.
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
program.Codes[1] is not LinearMove to)
return false;
var start = vertical ? from.EndPoint.Y : from.EndPoint.X;
var end = vertical ? to.EndPoint.Y : to.EndPoint.X;
return System.Math.Min(start, end) <= low + GeometryTolerance &&
System.Math.Max(start, end) >= high - GeometryTolerance;
return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance &&
System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance;
}
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
Nonnegative(box.Length) && Nonnegative(box.Width) &&
double.IsFinite(box.Right) && double.IsFinite(box.Top);
// A rapid that leaves the sheet only at an end lying on the display perimeter, within its allowance.
private static bool IsRapidOntoOutline(Entity entity, List<Entity> outline, Vector location, Box sheet)
{
if (entity.Layer != SpecialLayers.Rapid || entity is not Line rapid
|| !Inside(rapid.BoundingBox.Translate(location), sheet, DisplayPerimeterSheetAllowance))
return false;
bool Allowed(Vector point) =>
Inside(new Box(point.X + location.X, point.Y + location.Y, 0, 0), sheet)
|| outline.Any(e => e.ClosestPointTo(point).DistanceTo(point) <= GeometryTolerance);
return Allowed(rapid.StartPoint) && Allowed(rapid.EndPoint);
}
private static bool Inside(Box box, Box sheet, double tolerance = GeometryTolerance) =>
box.Left >= sheet.Left - tolerance && box.Right <= sheet.Right + tolerance &&
box.Bottom >= sheet.Bottom - tolerance && box.Top <= sheet.Top + tolerance;
private static bool Inside(Box box, Box sheet) =>
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
+3 -11
View File
@@ -21,17 +21,12 @@ namespace OpenNest.BestFit
IReadOnlyList<double> HullAngles
);
/// <summary>
/// The saved best-fit results for one drawing at one plate size and spacing.
/// <paramref name="CanonicalAngle"/> is the drawing's canonical angle (radians) when the
/// set was saved: the frame its pair geometry is in. Null means the frame is unknown.
/// </summary>
/// <summary>The saved best-fit results for one drawing at one plate size and spacing.</summary>
public sealed record SavedPairSet(
double PlateWidth,
double PlateHeight,
double Spacing,
IReadOnlyList<SavedPairResult> Results,
double? CanonicalAngle = null
IReadOnlyList<SavedPairResult> Results
);
/// <summary>
@@ -50,10 +45,7 @@ namespace OpenNest.BestFit
IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys
);
/// <summary>
/// Restores saved sets for <paramref name="drawing"/>. Sets saved in a frame other
/// than the drawing's current canonical frame, or in an unknown one, are stale.
/// </summary>
/// <summary>Restores saved sets for <paramref name="drawing"/>.</summary>
void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets);
}
}
+1 -1
View File
@@ -238,7 +238,7 @@ namespace OpenNest.CNC
builder.PartIndex = i;
var mode = Mode.Absolute;
var pos = part.Location;
builder.AddProgram(DisplayPerimeterTail.WithoutTail(part.Program), part.Location, ref mode, ref pos);
builder.AddProgram(part.Program, part.Location, ref mode, ref pos);
}
var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
@@ -36,35 +36,6 @@ internal enum AutomaticEntryKind
/// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary>
Pinned = 7,
/// <summary>
/// The perimeter point where a tab best keeps the part level on its support bed; only a
/// perimeter tabbed for support has it (see <see cref="PreparedContours.WithSupportTab"/>).
/// </summary>
SupportTab = 8,
/// <summary>
/// A cutout entry pierced where eroding the cutout leaves room (see
/// <see cref="PreparedContours.ErodedPierceEntries"/>): only offered once no configured
/// lead-in of the cutout fits.
/// </summary>
ErodedPierce = 9,
}
/// <summary>
/// Which automatic entries a perimeter tabbed for support offers (see
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalogue.
/// </summary>
internal enum SupportEntries
{
/// <summary>Entries whose tab steadies the part, clear of inside corners, plus its best tab point.</summary>
Steadying,
/// <summary>Every entry clear of inside corners: the fallback when no steadying entry fits.</summary>
ClearOfInsideCorners,
/// <summary>Every entry: the last fallback, when nothing clear of inside corners fits.</summary>
Any,
}
/// <summary>
@@ -55,24 +55,23 @@ internal static class ContourProgramVerifier
var first = run.Curves[0];
var ringIndex = -1;
var entityIndex = -1;
// The admitted slack is far below any extent margin, so only boundaries near the run's
// start can contain it; they come in ring order, as the full scan met them.
foreach (var (r, e) in material.PositionsNear(new(first.Start.X, first.Start.Y, first.Start.X, first.Start.Y)))
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
{
Query(token, ref budget);
var admitted = AdmittedClosureSlack(material.Rings[r]);
var curve = material.Rings[r][e];
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
for (var e = 0; e < material.Rings[r].Length; e++)
{
ringIndex = r;
entityIndex = e;
break;
Query(token, ref budget);
var curve = material.Rings[r][e];
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
{
ringIndex = r;
entityIndex = e;
break;
}
}
}
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))
{
@@ -109,7 +108,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 - (material.HasDisplayPerimeter ? 1 : 0);
return visited.Count == material.Rings.Count;
}
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
@@ -22,39 +22,20 @@ internal sealed class PlannedPartProgram
public CuttingParameters Parameters { get; }
}
/// <summary>
/// An owned cutoff segment program cut the other way at the far sheet edge
/// (<see cref="CutOff.ReverseAtFarEdge"/>); Apply installs it and records the direction on the
/// segment's cutoff definition, so regeneration keeps it.
/// </summary>
internal sealed class PlannedCutOffProgram
{
public PlannedCutOffProgram(Part segment, Program program)
{
Segment = segment;
Program = program;
}
public Part Segment { get; }
public Program Program { get; }
}
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
internal sealed class PlateCuttingPlan
{
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
IEnumerable<PlannedPartProgram> programs = null, IEnumerable<PlannedCutOffProgram> cutOffs = null)
IEnumerable<PlannedPartProgram> programs = null)
{
Expected = expected;
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
Programs = Array.AsReadOnly((programs ?? []).ToArray());
CutOffs = Array.AsReadOnly((cutOffs ?? []).ToArray());
}
public PlateCuttingState Expected { get; }
public IReadOnlyList<Part> Order { get; }
public IReadOnlyList<PlannedPartProgram> Programs { get; }
public IReadOnlyList<PlannedCutOffProgram> CutOffs { get; }
}
public enum CuttingCommitStatus
@@ -180,49 +161,27 @@ internal static class CuttingPlanCommit
}
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
}
var cutOffs = new List<(Part, Program, CutOff)>();
foreach (var planned in plan.CutOffs)
{
if (planned?.Segment == null || !onPlate.Contains(planned.Segment) || !targets.Add(planned.Segment))
return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
var definition = plate.CutOffs.FirstOrDefault(c => ReferenceEquals(c?.Drawing, planned.Segment.BaseDrawing));
// Only the placed far-edge segment, cut exactly the other way, may be installed.
if (definition == null || planned.Program == null || live.Contains(planned.Program)
|| !installed.Add(planned.Program)
|| CutOff.ReverseAtFarEdge(planned.Segment.Program) is not { } expected
|| !ProgramContent.Equal(expected, planned.Program, token))
return Invalid(plate, "A cutoff segment can only be installed cut the other way at the far edge.");
cutOffs.Add((planned.Segment, planned.Program, definition));
}
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
staged.Add(new(plate, order, programs, cutOffs, reordered || programs.Count != 0 || cutOffs.Count != 0));
staged.Add(new(plate, order, programs, reordered || programs.Count != 0));
}
// Last cancellation point. The boundary below has no await, search or geometry work.
if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States,
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States)>();
try
{
foreach (var item in staged)
{
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
item.Programs.Select(p => p.Part).Concat(item.CutOffs.Select(c => c.Segment))
.Select(part => (part, part.CaptureCuttingState())).ToArray(),
item.CutOffs.Select(c => (c.CutOff, c.CutOff.FarEdgeFirst, c.CutOff.Drawing.Program)).ToArray()));
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray()));
item.Plate.Parts.SetOrder(item.Order);
foreach (var (part, program, bounds, parameters) in item.Programs)
{
beforeInstall?.Invoke(item.Plate, part);
part.InstallPlannedProgram(program, bounds, parameters);
}
foreach (var (segment, program, cutOff) in item.CutOffs)
{
beforeInstall?.Invoke(item.Plate, segment);
cutOff.InstallFarEdgeSegment(segment, program);
}
}
}
catch (Exception ex)
@@ -231,11 +190,6 @@ internal static class CuttingPlanCommit
{
foreach (var (part, state) in undo[i].States)
part.RestoreCuttingState(state);
foreach (var (cutOff, farEdgeFirst, program) in undo[i].CutOffs)
{
cutOff.FarEdgeFirst = farEdgeFirst;
cutOff.Drawing.Program = program;
}
undo[i].Plate.Parts.SetOrder(undo[i].Order);
}
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
@@ -254,6 +208,5 @@ internal static class CuttingPlanCommit
new(CuttingCommitStatus.InvalidInput, message, plate);
private sealed record Staged(Plate Plate, Part[] Order,
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs,
List<(Part Segment, Program Program, CutOff CutOff)> CutOffs, bool Changed);
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs, bool Changed);
}
@@ -48,15 +48,6 @@ public static class ExecutionMotionReader
throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
// A display perimeter's tail is never executed: an applied program ends at its marker.
// Only a well-formed tail at the end of the part's own program may end the read.
if (code is Comment { Value: DisplayPerimeterTail.Marker })
{
if (!ReferenceEquals(current, program))
throw new ArgumentException("A display perimeter tail can only end the part's own program, not a sub-program.");
DisplayPerimeterTail.IndexOf(current);
break;
}
if (requireSupportedTypes)
{
var type = code.GetType();
@@ -49,8 +49,8 @@ internal static class IncrementalClosureDrift
// The chains material capture closes: cut, display and tab moves between rapids and other layers.
private static bool IsContour(Motion motion) => !motion.Suppressed
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter }
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter };
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab }
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab };
private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
{
@@ -12,9 +12,8 @@ namespace OpenNest.CNC.CuttingPlanning;
public sealed class LeadMaterialSnapshot
{
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
string reason, bool displayPerimeter = false)
string reason)
{
HasDisplayPerimeter = displayPerimeter;
Rings = rings;
Tabs = tabs;
Reason = reason;
@@ -42,12 +41,6 @@ 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,
@@ -60,8 +53,6 @@ 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();
@@ -72,8 +63,7 @@ public sealed class LeadMaterialSnapshot
continue;
}
// A tabbed cutout's tabs are never cut but still close its hole.
// 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))
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab))
throw new ArgumentException("Material capture contains an unsupported layer.");
var curve = motion.Curve;
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
@@ -82,7 +72,6 @@ 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();
}
@@ -91,36 +80,37 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("Material has no closed contour.");
var budget = 1000000;
// Certify simple rings and mutually disjoint boundaries before containment.
foreach (var (r, i, s, j) in ContactCandidates(rings, token))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Material validation exceeds the native query limit.");
var a = rings[r][i];
var b = rings[s][j];
var contacts = a.Contacts(b, out var overlap);
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
// Native arc queries pad angles by 1e-5 radians, admitting
// intersections on an extension (distance grows with radius).
// At a shared arc joint require both actual finite spans;
// overlap and genuine second intersections still refuse.
if (arcJoint)
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
// Native contacts at rounded arc/line joints can drift from the
// authored endpoint. Use the shared geometry tolerance only to
// recognize that joint; overlaps and other contacts still refuse.
if (overlap || contacts.Any(p => !adjacent
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s])
? "A display perimeter must enclose every other contour."
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
}
for (var r = 0; r < rings.Count; r++)
for (var s = r; s < rings.Count; s++)
for (var i = 0; i < rings[r].Length; i++)
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Material validation exceeds the native query limit.");
var a = rings[r][i];
var b = rings[s][j];
var contacts = a.Contacts(b, out var overlap);
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
// Native arc queries pad angles by 1e-5 radians, admitting
// intersections on an extension (distance grows with radius).
// At a shared arc joint require both actual finite spans;
// overlap and genuine second intersections still refuse.
if (arcJoint)
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
// Native contacts at rounded arc/line joints can drift from the
// authored endpoint. Use the shared geometry tolerance only to
// recognize that joint; overlaps and other contacts still refuse.
if (overlap || contacts.Any(p => !adjacent
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
}
var outer = -1;
for (var i = 0; i < rings.Count; i++)
{
@@ -133,22 +123,16 @@ public sealed class LeadMaterialSnapshot
}
}
if (outer < 0)
throw new ArgumentException(ringDisplay.Contains(true)
? "A display perimeter must enclose every other contour."
: "Material must have exactly one enclosing perimeter.");
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, ringDisplay[outer]);
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null);
void Finish()
{
@@ -161,14 +145,10 @@ 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)
@@ -177,182 +157,18 @@ public sealed class LeadMaterialSnapshot
}
}
/// <summary>
/// The boundary pairs certification queries natively, in the order of the full pairwise scan
/// (ring, other ring, curve, other curve). A pair whose extents are clear by the lead checks'
/// margin (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>) can report no
/// native contact, so it is left out; a sweep over X finds the rest without visiting every pair.
/// Material that is not well conditioned keeps every pair.
/// </summary>
private static List<(int R, int I, int S, int J)> ContactCandidates(
IReadOnlyList<PostVerificationGeometry.Curve[]> rings, CancellationToken token)
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token)
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token));
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token)
{
var curves = new List<(int Ring, int Index, PostVerificationGeometry.Extent Extent)>();
var whole = PostVerificationGeometry.Extent.None;
for (var r = 0; r < rings.Count; r++)
for (var i = 0; i < rings[r].Length; i++)
{
curves.Add((r, i, rings[r][i].Extent));
whole = whole.Union(rings[r][i].Extent);
}
var pairs = new List<(int R, int I, int S, int J)>();
if (!whole.IsWellConditioned)
var inside = false;
foreach (var curve in ring)
{
for (var a = 0; a < curves.Count; a++)
for (var b = a + 1; b < curves.Count; b++)
pairs.Add((curves[a].Ring, curves[a].Index, curves[b].Ring, curves[b].Index));
token.ThrowIfCancellationRequested();
if (curve.CrossesRay(point))
inside = !inside;
}
else
{
// No pair spans more than the whole material, so this bounds every pair's margin.
var reach = PostVerificationGeometry.Extent.ClearMargin + PostVerificationGeometry.Extent.SpanRounding
* System.Math.Max(whole.MaxX - whole.MinX, whole.MaxY - whole.MinY);
var byMinX = curves.OrderBy(c => c.Extent.MinX).ToArray();
for (var a = 0; a < byMinX.Length; a++)
{
token.ThrowIfCancellationRequested();
var first = byMinX[a];
for (var b = a + 1; b < byMinX.Length && byMinX[b].Extent.MinX <= first.Extent.MaxX + reach; b++)
{
var second = byMinX[b];
if (first.Extent.IsClearOfQueriesFrom(second.Extent))
continue;
pairs.Add((first.Ring, first.Index).CompareTo((second.Ring, second.Index)) < 0
? (first.Ring, first.Index, second.Ring, second.Index)
: (second.Ring, second.Index, first.Ring, first.Index));
}
}
}
pairs.Sort((x, y) => (x.R, x.S, x.I, x.J).CompareTo((y.R, y.S, y.I, y.J)));
return pairs;
return inside;
}
/// <summary>Material with fewer boundaries is scanned whole, exactly as before the index.</summary>
private const int IndexedBoundaries = 64;
private BoundaryIndex index;
/// <summary>
/// The boundaries a native query from <paramref name="query"/> can report a contact with, in
/// ring order: every boundary but those whose extents are clear of it by the lead checks'
/// margin plus <paramref name="reach"/> (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>).
/// Small material returns every boundary.
/// </summary>
internal IEnumerable<PostVerificationGeometry.Curve> BoundariesNear(PostVerificationGeometry.Extent query,
double reach = 0) => PositionsNear(query, reach).Select(p => Rings[p.Ring][p.Index]);
/// <summary>As <see cref="BoundariesNear"/>, each boundary as its ring and its index in that ring.</summary>
internal IEnumerable<(int Ring, int Index)> PositionsNear(PostVerificationGeometry.Extent query, double reach = 0)
{
if (BoundaryCount < IndexedBoundaries)
return Rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i)));
return LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).Near(query, reach);
}
internal bool ContainsMaterial(Vector point, CancellationToken token)
{
if (BoundaryCount < IndexedBoundaries)
return Inside(point, Rings[0], token) && !Rings.Skip(1).Any(hole => Inside(point, hole, token));
var holes = LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).HolesOnRow(point.Y);
return Inside(point, Rings[0], token) && !holes.Any(hole => Inside(point, Rings[hole], token));
}
/// <summary>
/// A uniform grid of boundary extents over the material, for material with many holes. Read
/// only once built, so concurrent searches may share it.
/// </summary>
private sealed class BoundaryIndex
{
private readonly (int Ring, int Index)[] positions;
private readonly PostVerificationGeometry.Extent[] extents;
private readonly List<int>[] cells;
private readonly List<int>[] bands;
private readonly PostVerificationGeometry.Extent[] ringExtents;
private readonly int ringCount;
private readonly PostVerificationGeometry.Extent whole;
private readonly int columns;
private readonly int rows;
private readonly double width;
private readonly double height;
internal BoundaryIndex(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, PostVerificationGeometry.Extent whole)
{
positions = rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i))).ToArray();
extents = rings.SelectMany(r => r).Select(b => b.Extent).ToArray();
ringCount = rings.Count;
ringExtents = new PostVerificationGeometry.Extent[rings.Count];
this.whole = whole;
var side = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Sqrt(positions.Length)));
columns = side;
rows = side;
width = System.Math.Max(whole.MaxX - whole.MinX, double.Epsilon) / columns;
height = System.Math.Max(whole.MaxY - whole.MinY, double.Epsilon) / rows;
cells = new List<int>[columns * rows];
bands = new List<int>[rows];
if (!whole.IsWellConditioned)
return;
for (var ring = 1; ring < rings.Count; ring++)
{
var extent = rings[ring].Aggregate(PostVerificationGeometry.Extent.None, (e, curve) => e.Union(curve.Extent));
ringExtents[ring] = extent;
for (var band = Row(extent.MinY - PostVerificationGeometry.Extent.ClearMargin);
band <= Row(extent.MaxY + PostVerificationGeometry.Extent.ClearMargin); band++)
(bands[band] ??= new List<int>()).Add(ring);
}
for (var b = 0; b < positions.Length; b++)
{
var (x0, y0, x1, y1) = Cells(extents[b], 0);
for (var y = y0; y <= y1; y++)
for (var x = x0; x <= x1; x++)
(cells[y * columns + x] ??= new List<int>()).Add(b);
}
}
/// <summary>
/// The holes, in ring order, whose curves a ray along row <paramref name="y"/> can cross:
/// every hole but those whose extents are clear of the row (see
/// <see cref="PostVerificationGeometry.Extent.IsClearOfRow"/>).
/// </summary>
internal IEnumerable<int> HolesOnRow(double y)
{
if (!whole.IsWellConditioned || !(System.Math.Abs(y) <= PostVerificationGeometry.Extent.WellConditionedLimit))
return Enumerable.Range(1, ringCount - 1);
return (bands[Row(y)] ?? []).Where(ring => !ringExtents[ring].IsClearOfRow(y));
}
internal IEnumerable<(int Ring, int Index)> Near(PostVerificationGeometry.Extent query, double reach)
{
// The margin covers every pair's span, and each skip is then decided for the pair alone.
// Material or a query that is not well conditioned (whose cells were never built, for
// the material) keeps every boundary.
var union = whole.Union(query);
var margin = PostVerificationGeometry.Extent.ClearMargin + reach
+ PostVerificationGeometry.Extent.SpanRounding * System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY);
if (!union.IsWellConditioned || !double.IsFinite(margin))
return positions;
var (x0, y0, x1, y1) = Cells(query, margin);
var found = new List<int>();
for (var y = y0; y <= y1; y++)
for (var x = x0; x <= x1; x++)
if (cells[y * columns + x] is { } cell)
found.AddRange(cell);
found.Sort();
var near = new List<(int Ring, int Index)>();
for (var i = 0; i < found.Count; i++)
if ((i == 0 || found[i] != found[i - 1]) && !query.IsClearOfQueriesFrom(extents[found[i]], reach))
near.Add(positions[found[i]]);
return near;
}
// The cells an extent widened by margin covers, clamped to the grid.
private (int X0, int Y0, int X1, int Y1) Cells(PostVerificationGeometry.Extent extent, double margin) => (
Column(extent.MinX - margin), Row(extent.MinY - margin), Column(extent.MaxX + margin), Row(extent.MaxY + margin));
private int Column(double x) => (int)System.Math.Clamp(System.Math.Floor((x - whole.MinX) / width), 0, columns - 1);
private int Row(double y) => (int)System.Math.Clamp(System.Math.Floor((y - whole.MinY) / height), 0, rows - 1);
}
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token) =>
PostVerificationGeometry.Inside(point, ring, token);
}
@@ -125,8 +125,7 @@ public static class LeadPathValidator
var edge = execution.Motions[groupEdge];
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
// A boundary clear of the contour's first motion contains none of its points.
foreach (var boundary in target.BoundariesNear(adjacent.Curve.Extent))
foreach (var boundary in target.Rings.SelectMany(r => r))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
@@ -191,7 +190,10 @@ public static class LeadPathValidator
// Every boundary of material this far away is farther than the zone.
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
.IsClearOfQueriesFrom(material.Extent, pierceRadius))
{
Charge(material.BoundaryCount);
continue;
}
if (Clearance(material) is { } distance)
return (Short(distance, "another placed part"), other);
}
@@ -230,11 +232,11 @@ public static class LeadPathValidator
}
// Material too far from the lead for any native boundary query to report a contact
// (see IsClearOfQueriesFrom): the result the full scan gives. Only queries that run
// are charged, so many far parts or holes do not exhaust the budget. Ray parity is
// still asked natively unless the lead lies beside the material's rows.
// (see IsClearOfQueriesFrom): the result the full scan gives, charged as if it ran.
// Ray parity is still asked natively unless the lead lies beside the material's rows.
bool FarInside(LeadMaterialSnapshot material)
{
Charge(material.BoundaryCount);
var midpoint = move.Curve.Midpoint;
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
}
@@ -287,8 +289,7 @@ public static class LeadPathValidator
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
{
// Boundaries no native query from the curve can reach report no contact; only the others run.
foreach (var boundary in material.BoundariesNear(curve.Extent))
foreach (var boundary in material.Rings.SelectMany(r => r))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
@@ -54,7 +54,7 @@ internal static class NestedPartContainment
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary>
internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter && m.Curve != null)
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
/// <summary>Whether a host's bounds admit an inner part's bounds as a containment candidate.</summary>
@@ -33,18 +33,18 @@ internal static class OwnedCuttingParameters
PierceDiameter = Dimension(source.PierceDiameter),
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
RoundLeadInAngles = source.RoundLeadInAngles,
LeadInAngleIncrement = source.LeadInAngleIncrement,
AutoTabMinSize = Dimension(source.AutoTabMinSize),
AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
TabConfig = source.TabConfig == null
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
: CopyTab(source.TabConfig),
TabsEnabled = source.TabsEnabled,
SupportTabs = source.SupportTabs,
NoBedTabMinWidth = Dimension(source.NoBedTabMinWidth),
NoBedTabMaxWidth = Dimension(source.NoBedTabMaxWidth),
TabHeldMaxLength = Dimension(source.TabHeldMaxLength),
Sequencing = new SequenceParameters
{
Method = sequence.Method,
@@ -71,8 +71,7 @@ internal static class OwnedCuttingParameters
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn
{ Radius = Dimension(n.Radius), SweepAngle = Sweep(n.SweepAngle) },
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn { Radius = Dimension(n.Radius) },
LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
@@ -138,7 +137,4 @@ internal static class OwnedCuttingParameters
// Approach angles are degrees, not lengths. Signed and periodic angles are valid.
private static double Angle(double value) => double.IsFinite(value)
? value : throw new ArgumentException("Angles must be finite.");
private static double Sweep(double value) => double.IsFinite(value) && value > 0 && value <= 180
? value : throw new ArgumentException("Arc sweep must be in (0, 180] degrees.");
}
@@ -82,8 +82,7 @@ public sealed class PlateCuttingState
}
var cutOffs = plate.CutOffs.Select(c => c == null
? throw new ArgumentException("Plate contains a missing cutoff definition.")
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit,
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit)).ToArray();
return new(plate, records.ToArray(), cutOffs, drawings, settings);
void Fingerprint(CuttingParameters parameters)
@@ -156,8 +155,7 @@ public sealed class PlateCuttingState
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|| !Bits(cutOff.Position.Y, record.Position.Y)
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)
|| !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit))
return $"Cutoff {i + 1} definition changed.";
}
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
@@ -206,5 +204,5 @@ public sealed class PlateCuttingState
List<ContourCutPin> Pins, string PinsFingerprint);
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
double? StartLimit, double? EndLimit, double HeadLockOvercut, bool FarEdgeFirst);
double? StartLimit, double? EndLimit);
}
@@ -5,7 +5,6 @@ using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using OpenNest.Support;
namespace OpenNest.CNC.CuttingPlanning;
@@ -15,11 +14,9 @@ public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector
internal PreparedContours Owner { get; init; }
/// <summary>
/// A pierce the planner placed off the contour: a perimeter's in open scrap for the pierce
/// zone (see <see cref="PreparedContours.ScrapPierceEntry"/>), or a cutout's where eroding it
/// leaves room (see <see cref="PreparedContours.ErodedPierceEntries"/>). The lead-in runs
/// straight from here to <see cref="Point"/> instead of the configured lead-in; a cutout so
/// pierced has no lead-out. Null for every other choice.
/// A perimeter pierce the planner placed in open scrap for the pierce zone (see
/// <see cref="PreparedContours.ScrapPierceEntry"/>): the lead-in runs straight from here to
/// <see cref="Point"/> instead of the configured lead-in. Null for every other choice.
/// </summary>
public Vector? Pierce { get; internal init; }
}
@@ -44,26 +41,10 @@ public sealed class PreparedContours
private readonly ContourChoice[] pinned;
private readonly CuttingParameters[] contourParameters;
private readonly int[] pinNumbers;
// Set when the support bed cannot hold the part level: the perimeter is tabbed and its
// automatic entries keep to points where the tab steadies the part. World = local + origin.
private readonly PartSupport support;
private readonly Vector supportOrigin;
// The outside when it is a display perimeter: shown and material, never cut. It is not one of
// the contours, so every contour is a hole; a complete emission only appends it as the
// never-executed DisplayPerimeterTail. Null for an ordinary cut perimeter.
private readonly Shape displayOutline;
private bool hasCutPerimeter => displayOutline == null;
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
CuttingParameters parameters)
: this(shapes, scribes, breakCuts, tabbedCutouts, parameters, displayOutline: null)
{
}
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
CuttingParameters parameters, Shape displayOutline)
{
this.displayOutline = displayOutline;
this.shapes = shapes;
this.scribes = scribes;
this.breakCuts = breakCuts;
@@ -74,8 +55,7 @@ public sealed class PreparedContours
}
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters,
unpinned.displayOutline)
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters)
{
contourParameters = new CuttingParameters[shapes.Length];
var rejected = new List<CutPinResolution>();
@@ -98,9 +78,7 @@ public sealed class PreparedContours
rejected.Add(pin);
continue;
}
var choice = new ContourChoice(contour, pin.EntityOrdinal,
SnapToVertex(shapes[contour], pin.EntityOrdinal, pin.Point))
{ Owner = this };
var choice = new ContourChoice(contour, pin.EntityOrdinal, pin.Point) { Owner = this };
ValidateChoice(choice);
pinned[contour] = choice;
pinNumbers[contour] = pin.PinIndex + 1;
@@ -109,83 +87,17 @@ public sealed class PreparedContours
RejectedPins = rejected.AsReadOnly();
}
// 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,
source.displayOutline)
{
for (var contour = 0; contour < Count; contour++)
{
pinned[contour] = source.pinned[contour] is { } pin ? pin with { Owner = this } : null;
pinNumbers[contour] = source.pinNumbers[contour];
}
contourParameters = source.contourParameters == null
? new CuttingParameters[Count]
: (CuttingParameters[])source.contourParameters.Clone();
if (pinned[PerimeterOrdinal] == null)
{
var perimeter = OwnedCuttingParameters.Copy(source.ParametersFor(PerimeterOrdinal));
perimeter.TabsEnabled = true;
perimeter.TabConfig = OwnedCuttingParameters.CopyTab(tab);
contourParameters[PerimeterOrdinal] = perimeter;
}
RejectedPins = source.RejectedPins;
this.support = support;
supportOrigin = location;
}
/// <summary>
/// A copy whose perimeter is tabbed with <paramref name="tab"/> where it steadies the part
/// on its support bed: <paramref name="support"/> analyzed this part's material placed at
/// <paramref name="location"/>, and the perimeter's automatic entries keep to points it
/// accepts (<see cref="PartSupport.AcceptsTabAt"/>) plus its best tab point. A pinned
/// perimeter keeps its pin's entry, leads and tab (the drawing wins); the copy only keeps
/// the analysis, so replay can report a pin that leaves the part unsteady.
/// </summary>
public PreparedContours WithSupportTab(PartSupport support, Vector location, Tab tab)
{
ArgumentNullException.ThrowIfNull(support);
ArgumentNullException.ThrowIfNull(tab);
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);
}
/// <summary>
/// The analysis of a part its support bed cannot hold level (see <see cref="WithSupportTab"/>),
/// or null; <see cref="SupportOrigin"/> places its local contours in its world frame.
/// </summary>
public PartSupport Support => support;
/// <summary>The part's location: world = local + this, for <see cref="Support"/>.</summary>
public Vector SupportOrigin => supportOrigin;
/// <summary>Whether the emitted perimeter is tabbed: by the plate settings, its pin or support.</summary>
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();
/// <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;
public int PerimeterOrdinal => Count - 1;
/// <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) => !(contourOrdinal == PerimeterOrdinal && !hasCutPerimeter)
&& GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
public bool IsPinned(int contourOrdinal) => 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;
@@ -210,15 +122,17 @@ public sealed class PreparedContours
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
}
// The pin's leads replace both lead sets, since the type the emitter detects is
// The pin's leads replace every contour type's leads, since the type the emitter detects is
// not the caller's concern; angle rounding is off because a pinned entry never moves.
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
{
var owned = OwnedCuttingParameters.Copy(shared);
owned.ExternalLeadIn = pin.LeadIn;
owned.InternalLeadIn = pin.LeadIn;
owned.ArcCircleLeadIn = pin.LeadIn;
owned.ExternalLeadOut = pin.LeadOut;
owned.InternalLeadOut = pin.LeadOut;
owned.ArcCircleLeadOut = pin.LeadOut;
owned.RoundLeadInAngles = false;
owned.TabsEnabled = pin.HasTab;
if (pin.HasTab)
@@ -241,7 +155,6 @@ 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())
@@ -272,8 +185,7 @@ public sealed class PreparedContours
scribes.Add(entity);
continue;
}
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab
&& entity.Layer != SpecialLayers.DisplayPerimeter)
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab)
throw new ArgumentException("Preparation requires clean cut/display contours.");
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
throw new ArgumentException("Degenerate contour entity.");
@@ -291,28 +203,6 @@ 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.");
// Every cut must lie on the blank: a non-rectangular blank's box is not enough.
// Each cut is sampled along its arcs and must not cross or touch the outline anywhere.
var outline = displayPerimeter.ToPolygon();
foreach (var shape in contours.Concat(tabbedCutouts).Concat(breakCuts))
if (shape.ToPolygon().Vertices.Any(vertex => !outline.ContainsPoint(vertex))
|| displayPerimeter.Intersects(shape))
throw new ArgumentException("A display perimeter must enclose every other contour.");
// The search plans a part through its closed contours; tabbed cutouts and break cuts
// alone leave it nothing to choose.
if (contours.Count == 0)
throw new ArgumentException("A display perimeter needs at least one closed cutout without tabs.");
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, displayPerimeter);
}
if (current.Entities.Count != 0 || contours.Count == 0)
throw new ArgumentException("Preparation requires nonempty closed contours.");
var perimeter = 0;
@@ -333,15 +223,7 @@ public sealed class PreparedContours
void FinishContour()
{
current.UpdateBounds();
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 (ContourCuttingStrategy.IsTabbedCutout(current))
{
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
throw new ArgumentException("A tabbed cutout has nothing to cut.");
@@ -403,12 +285,8 @@ public sealed class PreparedContours
void Add(int entityOrdinal, Vector point)
{
token.ThrowIfCancellationRequested();
if (entries.Count >= maxEntries)
return;
// Compare the entry as emitted: snapping can bring two raw points together.
var entry = Entry(contourOrdinal, entityOrdinal, point);
if (!entries.Any(e => e.Point.DistanceTo(entry.Point) <= PostVerificationGeometry.Epsilon))
entries.Add(entry);
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
entries.Add(Entry(contourOrdinal, entityOrdinal, point));
}
}
@@ -433,7 +311,6 @@ public sealed class PreparedContours
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
{
point = SnapToVertex(GetShape(contourOrdinal), entityOrdinal, point);
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
ValidateChoice(choice);
return choice;
@@ -446,61 +323,12 @@ public sealed class PreparedContours
/// emission reads only this choice and the perimeter, and the lead validator checks the
/// lead as usual. A pinned perimeter keeps its pin's entry.
/// </summary>
internal ContourChoice ScrapPierceEntry(Vector pierce) => PiercedEntry(PerimeterOrdinal, pierce);
/// <summary>
/// Entries of cutout <paramref name="contourOrdinal"/> pierced where eroding it leaves room
/// (see <see cref="ErodedPierce"/>): the planner's fallback when no configured lead-in fits the
/// cutout. For each of the first <paramref name="max"/> <paramref name="targets"/> (its own
/// entries, usually ranked) the pierce is the eroded point nearest it, and the lead-in runs
/// straight from there to the target itself, then, as an alternative, to the cutout's native
/// point closest to the pierce. Neither has a lead-out. Nothing for the perimeter or a pinned
/// cutout; the lead validator still checks each one.
/// </summary>
internal IReadOnlyList<ContourChoice> ErodedPierceEntries(int contourOrdinal, IReadOnlyList<ContourChoice> targets,
int max, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal);
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
return Array.Empty<ContourChoice>();
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
var found = ErodedPierce.Find(shape, ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
targets.Select(t => t.Point).ToArray(), max);
var entries = new List<ContourChoice>();
for (var i = 0; i < found.Pierces.Count; i++)
{
token.ThrowIfCancellationRequested();
var pierce = found.Pierces[i];
Add(() => targets[i] with { Pierce = pierce });
Add(() => PiercedEntry(contourOrdinal, pierce));
}
return entries;
void Add(Func<ContourChoice> entry)
{
try
{
var choice = entry();
ValidateChoice(choice);
if (!entries.Any(e => e.Point == choice.Point && e.Pierce == choice.Pierce))
entries.Add(choice);
}
catch (ArgumentException)
{
// A pierce on the wall itself has no lead; the others still stand.
}
}
}
// A contour entry at the closest native point to a pierce off it, along a straight lead-in.
private ContourChoice PiercedEntry(int contourOrdinal, Vector pierce)
internal ContourChoice ScrapPierceEntry(Vector pierce)
{
PostVerificationGeometry.Validate(pierce);
var shape = GetShape(contourOrdinal);
var shape = GetShape(PerimeterOrdinal);
var point = shape.ClosestPointTo(pierce, out var entity);
point = SnapToVertex(shape, shape.Entities.IndexOf(entity), point);
var choice = new ContourChoice(contourOrdinal, shape.Entities.IndexOf(entity), point)
var choice = new ContourChoice(PerimeterOrdinal, shape.Entities.IndexOf(entity), point)
{
Owner = this,
Pierce = pierce,
@@ -527,7 +355,7 @@ public sealed class PreparedContours
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
if (IsSingleCircle(shape))
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
}
/// <summary>
@@ -544,8 +372,7 @@ public sealed class PreparedContours
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
int contourOrdinal,
Vector? lookAhead = null,
CancellationToken token = default,
SupportEntries supportEntries = SupportEntries.Steadying)
CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal);
@@ -555,45 +382,7 @@ public sealed class PreparedContours
? new List<ContourEntryCandidate>()
: PreferredCandidates(shape, contourOrdinal, token);
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
return SupportFiltered(contourOrdinal, all, supportEntries);
}
/// <summary>
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
/// its automatic catalogue then keeps to steadying points unless asked for a fallback.
/// </summary>
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
/// <summary>
/// The support margin with this perimeter's tab at <paramref name="choice"/> (see
/// <see cref="PartSupport.MarginWith"/>); negative infinity when it is not tabbed for support.
/// </summary>
internal double SupportMarginAt(ContourChoice choice) =>
support?.MarginWith(choice.Point + supportOrigin) ?? double.NegativeInfinity;
/// <summary>Whether a support tab at <paramref name="choice"/> holds the part level (see <see cref="PartSupport.HoldsLevelWithTabAt"/>).</summary>
internal bool HoldsLevelAt(ContourChoice choice) =>
support != null && support.HoldsLevelWithTabAt(choice.Point + supportOrigin);
/// <summary>Whether a support tab at <paramref name="choice"/> keeps the inside-corner clearance.</summary>
internal bool IsClearOfInsideCorners(ContourChoice choice) =>
support == null || support.IsClearOfInsideCorners(choice.Point + supportOrigin);
// A perimeter tabbed for support keeps the candidates whose tab steadies the part, and gains
// the outline's best tab point. The search's fallbacks, when none of them has a lead-in that
// fits, keep every candidate clear of inside corners and then every candidate, still tabbed.
private IReadOnlyList<ContourEntryCandidate> SupportFiltered(int contourOrdinal,
List<ContourEntryCandidate> candidates, SupportEntries entries)
{
if (support == null || contourOrdinal != PerimeterOrdinal || entries == SupportEntries.Any)
return MergeByGeometry(candidates);
if (entries == SupportEntries.ClearOfInsideCorners)
return MergeByGeometry(candidates.Where(c => IsClearOfInsideCorners(c.Choice)).ToList());
var kept = candidates.Where(c => support.AcceptsTabAt(c.Choice.Point + supportOrigin)).ToList();
var best = ClosestEntry(PerimeterOrdinal, support.BestTabPoint!.Value - supportOrigin);
if (!IsForbiddenVertex(GetShape(PerimeterOrdinal), best.Point))
kept.Add(new ContourEntryCandidate(best, AutomaticEntryKind.SupportTab));
return MergeByGeometry(kept);
return MergeByGeometry(all);
}
private static bool IsSingleCircle(Shape shape) =>
@@ -686,10 +475,9 @@ public sealed class PreparedContours
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
// Strictly inside the edge by projection parameter (distance alone loses the
// sign when a short edge is overshoot): never the corner, never the far
// endpoint (where a reflex vertex might sit). Short edges omit the point. The
// margin is the vertex snap distance, so Entry cannot snap it onto either end.
// endpoint (where a reflex vertex might sit). Short edges omit the point.
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
if (t <= OpenNest.Math.Tolerance.Epsilon || t >= line.Length - OpenNest.Math.Tolerance.Epsilon)
if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
return;
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
}
@@ -821,7 +609,7 @@ public sealed class PreparedContours
public Program Emit(IReadOnlyList<ContourChoice> choices)
{
Validate(choices, true);
return EmitOwned(choices, complete: true);
return EmitPrefix(choices);
}
/// <summary>
@@ -838,28 +626,21 @@ public sealed class PreparedContours
throw new ArgumentException("Foreign contour choice.");
ValidateChoice(choice);
// Same owned clones as a real emission; the source shapes/settings are never used
// directly, so the probe cannot drift the preparation or mutate it. Isolated emission
// reads only the chosen contour and the contour count, so only that contour is cloned.
var owned = new Shape[shapes.Length];
owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
// directly, so the probe cannot drift the preparation or mutate it.
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal);
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
contourParameters);
}
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
{
Validate(choices, false);
return EmitOwned(choices, complete: false);
}
// Only a complete emission carries a display perimeter's tail: prefixes are search probes.
private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) =>
new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
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(), PerimeterOrdinal,
complete ? (Shape)displayOutline?.Clone() : null);
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList());
}
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
public int BreakCutCount => breakCuts.Count;
@@ -880,8 +661,6 @@ 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);
}
@@ -916,8 +695,10 @@ public sealed class PreparedContours
if (choice.Pierce is { } pierce)
{
PostVerificationGeometry.Validate(pierce);
if (choice.ContourOrdinal != PerimeterOrdinal)
throw new ArgumentException("Only a perimeter entry can be pierced in scrap.");
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
throw new ArgumentException("A planner-placed pierce must lie off its contour.");
throw new ArgumentException("A scrap pierce must lie off its contour.");
}
}
@@ -941,9 +722,6 @@ public sealed class PreparedContours
return centres;
}
/// <summary>The bounding box of contour <paramref name="contourOrdinal"/>, local. A routing hint only.</summary>
internal Box ContourBounds(int contourOrdinal) => GetShape(contourOrdinal).BoundingBox;
/// <summary>
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
@@ -955,33 +733,6 @@ public sealed class PreparedContours
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
}
/// <summary>
/// Emission splits a contour at its entry with <see cref="Arc.SplitAt"/>/<see cref="Line.SplitAt"/>,
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
/// stored centre sits beside the chord's midpoint) is never cut.
/// </summary>
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
{
if (entityOrdinal < 0 || entityOrdinal >= shape.Entities.Count
|| shape.Entities[entityOrdinal] is not (Line or Arc))
return point;
var entity = shape.Entities[entityOrdinal];
// Within the verifier's epsilon the entry already cuts the contour whole: leave it, so
// programs emitted before this snap (saved pinned entries) re-emit unchanged.
bool Snaps(Vector vertex)
{
var distance = point.DistanceTo(vertex);
return distance > PostVerificationGeometry.Epsilon && distance < OpenNest.Math.Tolerance.Epsilon;
}
var start = Start(entity);
if (point.DistanceTo(start) < OpenNest.Math.Tolerance.Epsilon)
return Snaps(start) ? start : point;
var end = End(entity);
return Snaps(end) ? end : point;
}
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
@@ -12,11 +12,6 @@ public sealed class ReleasedContourState
{
private readonly List<Obstacle> obstacles = new();
private readonly bool reportMissingLeadIns;
// How the last analysed part ended: the contours it numbered, and whether nothing it left
// (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
// motions appended to it are judged. See Continue.
private int partContours;
private bool partClosed;
public ReleasedContourState() : this(true) { }
@@ -29,11 +24,7 @@ public sealed class ReleasedContourState
public ReleasedContourState Copy()
{
var copy = new ReleasedContourState(reportMissingLeadIns)
{
partContours = partContours,
partClosed = partClosed,
};
var copy = new ReleasedContourState(reportMissingLeadIns);
copy.obstacles.AddRange(obstacles);
return copy;
}
@@ -71,33 +62,10 @@ public sealed class ReleasedContourState
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
}
/// <summary>
/// Checks <paramref name="execution"/> as the continuation of the part the last check ended
/// in, as if that part's whole execution had been checked with these motions appended: the
/// findings are those the appended motions would add, and the state advances the same way.
/// It needs that part to have ended with every cut closed and nothing deferred, and the
/// continuation to open with a rapid and end the same way; otherwise the result is null and
/// this state must be discarded, and the caller checks the whole execution instead. Fixed
/// emissions and cutoffs are not continued.
/// </summary>
internal IReadOnlyList<PostVerificationFinding> Continue(OwnedExecution execution, Vector arrival,
int partNumber, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(execution);
PostVerificationGeometry.Validate(arrival);
if (!partClosed || execution.Motions.Count == 0 || !execution.Motions[0].Rapid)
return null;
var moves = execution.Motions.ToArray();
moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, false, findings, 1, partNumber, token, contoursBefore: partContours);
return partClosed ? findings.AsReadOnly() : null;
}
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
bool proveOpenCuts = false, int contoursBefore = 0)
bool proveOpenCuts = false)
{
var current = new CutGroup();
var unfinished = new List<CutGroup>();
@@ -110,7 +78,7 @@ public sealed class ReleasedContourState
CutGroup previous = null;
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
var hasLead = false;
var contourNumber = contoursBefore;
var contourNumber = 0;
var inBreakCut = false;
var reportedTab = false;
for (var index = 0; index < moves.Count; index++)
@@ -127,7 +95,7 @@ public sealed class ReleasedContourState
Finish();
if (reportMissingLeadIns && !cutoff && !hasLead)
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
"A break cut is missing lead-in geometry.")
"A break cut has no nonzero placed lead-in motion.")
{ Location = move.Curve.Start }, owned);
inBreakCut = true;
hasLead = false;
@@ -159,14 +127,7 @@ public sealed class ReleasedContourState
// A rapid well clear of a contour's extent can neither cross nor touch it.
if (reach.IsClearOf(obstacle.Extent))
continue;
// A rapid leaving an overcut departs from its retraced tail, which lies on the first
// cut only within the seam tolerance its arcs were matched at. Contact within that
// much of the departure is the departure itself.
var from = start;
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
if (PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}.")
@@ -174,7 +135,7 @@ public sealed class ReleasedContourState
Location = (start + move.End) * 0.5,
RapidStart = start,
RapidEnd = move.End,
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token)
}, owned);
}
}
@@ -218,7 +179,7 @@ public sealed class ReleasedContourState
contourNumber++;
if (reportMissingLeadIns && !cutoff && !hasLead)
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} is missing lead-in geometry.")
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
{ Location = move.Curve.Start }, owned);
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
@@ -240,12 +201,6 @@ public sealed class ReleasedContourState
// A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(current.Curves))
Finish();
else if (OvercutClosure(current.Curves) is { } closing)
{
current.OvercutEnd = current.Curves[^1].End;
current.Curves[^1] = closing;
Finish();
}
}
}
Finish();
@@ -267,9 +222,6 @@ public sealed class ReleasedContourState
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
"they cannot be assumed to be retained by tabs. Review rapid travel manually."),
unfinished.Any(chain => chain.Fixed));
partContours = contourNumber;
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
&& !inBreakCut && loose.Leads.Count == 0;
void Add(PostVerificationFinding finding, bool fixedEmission)
{
@@ -292,9 +244,9 @@ public sealed class ReleasedContourState
return;
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
// no flag or tab configuration is used as evidence of retention.
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
if (!cutoff && PostVerificationGeometry.Closed(current.Curves))
{
obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd));
obstacles.Add(new(part, contourNumber, current.Curves.ToArray()));
closed.Add(current);
}
else if (!cutoff)
@@ -303,31 +255,6 @@ public sealed class ReleasedContourState
}
}
/// <summary>
/// The closing piece of a contour cut on past its own start along its first cut, as a source
/// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
/// cut passes back through the start and its tail retraces the first cut. The tail encloses
/// nothing, so the contour is closed by the last cut up to its start. Null for any other shape.
/// Arcs of one circle may carry centers derived from their own rounded endpoints, so the seam
/// is matched at <see cref="PostVerificationGeometry.RaySeamTolerance"/>, the closure seam
/// ray parity already admits; the obstacle keeps that seam, never the retraced tail.
/// </summary>
private static PostVerificationGeometry.Curve OvercutClosure(IReadOnlyList<PostVerificationGeometry.Curve> curves)
{
if (curves.Count < 2)
return null;
const double tolerance = PostVerificationGeometry.RaySeamTolerance;
var first = curves[0];
var last = curves[^1];
if (!last.SameDirection(first, tolerance) || !last.Contains(first.Start, tolerance)
|| !first.Contains(last.End, tolerance))
return null;
var seam = last.ClosestPoint(first.Start);
var along = last.DistanceAlong(seam);
var tail = last.Length - along;
return along > tolerance && tail > tolerance && tail <= first.Length + tolerance ? last.Until(seam) : null;
}
/// <summary>
/// Whether one part's open cuts, with their leads, can release no material. A closed
/// contour with its own leads already encloses whatever it releases and counts as one node.
@@ -469,9 +396,6 @@ public sealed class ReleasedContourState
internal List<PostVerificationGeometry.Curve> Curves { get; } = new();
internal List<PostVerificationGeometry.Curve> Leads { get; } = new();
internal bool Fixed { get; set; }
// Where the cut really ended when OvercutClosure closed it: the retraced tail's end, on the
// first cut. Its last curve now ends at the seam instead.
internal Vector? OvercutEnd { get; set; }
}
private sealed class DisjointSet(int count)
@@ -488,8 +412,7 @@ public sealed class ReleasedContourState
internal void Union(int a, int b) => parents[Find(a)] = Find(b);
}
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves,
Vector? OvercutEnd)
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves)
{
internal PostVerificationGeometry.Extent Extent { get; } =
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
@@ -72,8 +72,8 @@ internal static class SeamOvercut
// Incremental coordinates only need a consistent origin within one contour.
var reference = incremental ? position ?? new Vector() : new Vector();
var end = reference + motion.EndPoint;
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter }
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter };
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display }
or ArcMove { Layer: LayerType.Cut or LayerType.Display };
if (!cut || position == null)
{
Finish();
@@ -83,16 +83,16 @@ internal static class StateFingerprint
Out(p.ExternalLeadOut);
Lead(p.InternalLeadIn);
Out(p.InternalLeadOut);
Lead(p.ArcCircleLeadIn);
Out(p.ArcCircleLeadOut);
Number(p.PierceClearance);
Number(p.PierceDiameter);
Flag(p.RoundLeadInAngles);
Number(p.LeadInAngleIncrement);
Number(p.AutoTabMinSize);
Number(p.AutoTabMaxSize);
TabSettings(p.TabConfig);
Flag(p.TabsEnabled);
Flag(p.SupportTabs);
Number(p.NoBedTabMinWidth);
Number(p.NoBedTabMaxWidth);
Number(p.TabHeldMaxLength);
Sequence(p.Sequencing);
Assignment(p.Assignment);
text.Append('}');
@@ -141,7 +141,6 @@ internal static class StateFingerprint
break;
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
Number(l.Radius);
Number(l.SweepAngle);
break;
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
Number(l.LineLength);
@@ -20,7 +20,6 @@ namespace OpenNest.CNC.CuttingStrategy
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
{
var entities = partProgram.ToGeometry();
RefuseDisplayPerimeter(entities);
var breakCuts = TakeBreakCuts(entities);
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
@@ -108,14 +107,6 @@ namespace OpenNest.CNC.CuttingStrategy
return new CuttingResult { Program = result, LastCutPoint = perimeterPt };
}
// These tools would lead the outline or mistake the largest cutout for it (an External lead
// pierced on the blank). Only the cutting planner knows every contour of such a part is a hole.
private static void RefuseDisplayPerimeter(List<Entity> entities)
{
if (entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
throw new InvalidOperationException(DisplayPerimeterTail.LeadInRefusal);
}
public CuttingResult ApplySingle(
Program partProgram,
Vector point,
@@ -124,7 +115,6 @@ namespace OpenNest.CNC.CuttingStrategy
)
{
var entities = partProgram.ToGeometry();
RefuseDisplayPerimeter(entities);
var breakCuts = TakeBreakCuts(entities);
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
@@ -261,20 +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, int? perimeterOrdinal = null, Shape displayTail = null)
IReadOnlyList<Shape> tabbedCutouts = 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, perimeterOrdinal ?? shapes.Length - 1);
if (displayTail != null)
{
// Display only, after every cut: executed reads and posts stop at its marker.
var start = GetShapeStartPoint(displayTail);
DisplayPerimeterTail.Append(result, ConvertShapeToMoves(displayTail, start, LayerType.DisplayPerimeter), start);
}
EmitChosenContours(result, shapes, choices, contourParameters);
result.Mode = Mode.Incremental;
return result;
}
@@ -285,23 +269,20 @@ 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,
int? perimeterOrdinal = null)
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null)
{
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes);
EmitChosenContours(result, shapes, new[] { choice }, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
EmitChosenContours(result, shapes, new[] { choice }, contourParameters);
result.Mode = Mode.Incremental;
return result;
}
// 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.
// One contour per choice in order; the perimeter (last shape) is always External.
// 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,
int perimeterOrdinal)
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters)
{
var shared = Parameters;
try
@@ -313,7 +294,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 == perimeterOrdinal ? ContourType.External : null,
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
exactCirclePrograms: true, scrapPierce: choice.Pierce);
}
}
@@ -449,9 +430,6 @@ namespace OpenNest.CNC.CuttingStrategy
var leadIn = SelectLeadIn(contourType);
var leadOut = SelectLeadOut(contourType);
// A cutout the planner pierced by erosion is too small for its configured leads.
if (scrapPierce != null && contourType != ContourType.External)
leadOut = new NoLeadOut();
if (contourType == ContourType.ArcCircle && entity is Circle circle)
{
@@ -468,14 +446,9 @@ namespace OpenNest.CNC.CuttingStrategy
);
}
if (scrapPierce is { } holePierce)
leadIn = StraightLeadIn(point, holePierce, out normal);
else
{
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
Parameters.GetEffectivePierceClearance());
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius(), winding);
}
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
Parameters.GetEffectivePierceClearance());
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius());
// Build hole sub-program relative to (0,0)
var holeCenter = circle.Center;
@@ -514,7 +487,6 @@ namespace OpenNest.CNC.CuttingStrategy
return;
}
var turn = LeadTurn(contourType, winding);
double leadInNormal;
// A planner-placed scrap pierce replaces the configured lead-in with a straight one.
if (scrapPierce is { } pierce)
@@ -524,9 +496,9 @@ namespace OpenNest.CNC.CuttingStrategy
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
Parameters.GetEffectivePierceClearance(), out leadInNormal);
if (contourType == ContourType.Internal)
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius(), turn);
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius());
}
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
var reindexedShape = shape.ReindexAt(point, entity);
@@ -557,20 +529,9 @@ namespace OpenNest.CNC.CuttingStrategy
}
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, turn));
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, winding));
}
/// <summary>
/// The direction a lead arc turns to meet a contour of <paramref name="winding"/>
/// tangentially, in the cut direction. Normals point away from the material, so the arc
/// center lies outside an outside perimeter (turning against its winding) and inside a
/// cutout (turning with it).
/// </summary>
public static RotationType LeadTurn(ContourType contourType, RotationType winding) =>
contourType == ContourType.External
? winding == RotationType.CW ? RotationType.CCW : RotationType.CW
: winding;
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
{
if (scribeEntities.Count == 0)
@@ -655,10 +616,8 @@ namespace OpenNest.CNC.CuttingStrategy
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
/// reflex corner it bisects the notch. The result does not depend on which of
/// the two edges meeting at the corner was picked. A curved lead-in at a convex
/// perimeter corner uses the normal of the edge cut first, so its arc meets that
/// edge tangentially. Other styles and contour types keep
/// <see cref="ComputeLeadInNormal"/>.
/// the two edges meeting at the corner was picked. Other styles and contour
/// types keep <see cref="ComputeLeadInNormal"/>.
/// </summary>
public static LeadIn ResolveLeadIn(
Shape shape,
@@ -672,19 +631,8 @@ namespace OpenNest.CNC.CuttingStrategy
)
{
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
return leadIn;
if (leadIn is ArcLeadIn or LineArcLeadIn or CleanHoleLeadIn)
{
// The arc meets the edge cut first tangentially; its center sits off that edge,
// which at a convex corner keeps the whole arc clear of the part.
if (ClassifyCorner(corner, winding) is CornerKind.Convex or CornerKind.Smooth)
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
return leadIn;
}
if (leadIn is not LineLeadIn line)
if (contourType != ContourType.External || leadIn is not LineLeadIn line
|| !TryGetCorner(shape, point, entity, out var corner))
return leadIn;
switch (ClassifyCorner(corner, winding))
@@ -714,8 +662,7 @@ namespace OpenNest.CNC.CuttingStrategy
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
/// bisects the notch. An <see cref="ArcLeadOut"/> at a convex corner uses the normal of
/// the edge cut last. Other styles and contour types keep the entity normal.
/// bisects the notch. Other styles and contour types keep the entity normal.
/// </summary>
public static LeadOut ResolveLeadOut(
Shape shape,
@@ -729,18 +676,8 @@ namespace OpenNest.CNC.CuttingStrategy
)
{
normal = ComputeNormal(point, entity, contourType, winding);
if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
return leadOut;
if (leadOut is ArcLeadOut)
{
// The arc leaves the edge cut last tangentially.
if (ClassifyCorner(corner, winding) is CornerKind.Convex or CornerKind.Smooth)
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
return leadOut;
}
if (leadOut is not LineLeadOut line)
if (contourType != ContourType.External || leadOut is not LineLeadOut line
|| !TryGetCorner(shape, point, entity, out var corner))
return leadOut;
switch (ClassifyCorner(corner, winding))
@@ -1008,7 +945,7 @@ namespace OpenNest.CNC.CuttingStrategy
if (leadIn is NoLeadIn || clearance <= 0)
return leadIn;
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle, circle.Rotation);
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle);
var maxRadius = circle.Radius - clearance;
if (maxRadius <= 0)
return leadIn;
@@ -1057,11 +994,10 @@ namespace OpenNest.CNC.CuttingStrategy
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and
/// pre-post review still report the shortfall. Reads only the hole's own geometry, so it
/// knows nothing of parts nested inside the hole. Shared by program generation and the
/// manual placement preview. <paramref name="turn"/> is the hole's
/// <see cref="LeadTurn"/>, which places a curved lead's pierce.
/// manual placement preview.
/// </summary>
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal,
double pierceRadius, RotationType turn)
double pierceRadius)
{
if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|| !hole.IsClosed())
@@ -1071,7 +1007,7 @@ namespace OpenNest.CNC.CuttingStrategy
var polygon = circle == null
? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance))
: null;
if (Clear(leadIn.GetPiercePoint(point, normal, turn)))
if (Clear(leadIn.GetPiercePoint(point, normal)))
return leadIn;
var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
@@ -1131,7 +1067,8 @@ namespace OpenNest.CNC.CuttingStrategy
{
return contourType switch
{
ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadIn,
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn,
ContourType.Internal => Parameters.InternalLeadIn,
_ => Parameters.ExternalLeadIn,
};
}
@@ -1140,7 +1077,8 @@ namespace OpenNest.CNC.CuttingStrategy
{
return contourType switch
{
ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadOut,
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut,
ContourType.Internal => Parameters.InternalLeadOut,
_ => Parameters.ExternalLeadOut,
};
}
@@ -1413,7 +1351,7 @@ namespace OpenNest.CNC.CuttingStrategy
return mirrored;
}
// Reaching both ways, or tangent to an arc: pierce straight in from the same distance.
var pierce = leadIn.GetPiercePoint(entry, normal, winding);
var pierce = leadIn.GetPiercePoint(entry, normal);
return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
}
@@ -15,14 +15,13 @@ namespace OpenNest.CNC.CuttingStrategy
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
/// <summary>
/// The lead-in of every cutout, full circles included; circles still get their own
/// geometry handling (radius clamp, angle rounding) when the lead is emitted.
/// </summary>
public LeadIn InternalLeadIn { get; set; } =
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut();
public double PierceClearance { get; set; } = 0.0625;
/// <summary>
@@ -41,55 +40,12 @@ namespace OpenNest.CNC.CuttingStrategy
public bool RoundLeadInAngles { get; set; }
public double LeadInAngleIncrement { get; set; } = 5.0;
public double AutoTabMinSize { get; set; }
public double AutoTabMaxSize { get; set; }
public Tab TabConfig { get; set; }
public bool TabsEnabled { get; set; }
/// <summary>
/// Tabs each part its plate's support bed cannot hold level once it is cut free, at the
/// perimeter point that best keeps its center of gravity over the bed's contacts (see
/// docs/support-tabs.md). Needs <see cref="TabConfig"/> for the tab size.
/// </summary>
public bool SupportTabs { get; set; }
/// <summary>
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the narrowest part
/// width that is tabbed instead (see <see cref="TabsWithoutBedAt"/>).
/// </summary>
public double NoBedTabMinWidth { get; set; }
/// <summary>
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the widest part
/// width that is tabbed instead; 0 turns the rule off, so nothing is tabbed without a bed.
/// </summary>
public double NoBedTabMaxWidth { get; set; }
/// <summary>Whether <see cref="NoBedTabMinWidth"/> to <see cref="NoBedTabMaxWidth"/> is a usable range.</summary>
public bool HasNoBedTabRange() =>
NoBedTabMaxWidth > 0 && double.IsFinite(NoBedTabMaxWidth)
&& NoBedTabMinWidth >= 0 && NoBedTabMinWidth <= NoBedTabMaxWidth;
/// <summary>
/// Whether a part <paramref name="width"/> wide (the short side of its minimum-area
/// bounding rectangle) is tabbed when its plate has no support bed.
/// </summary>
public bool TabsWithoutBedAt(double width) =>
HasNoBedTabRange()
&& width >= NoBedTabMinWidth - Math.Tolerance.Epsilon
&& width <= NoBedTabMaxWidth + Math.Tolerance.Epsilon;
/// <summary>
/// With <see cref="SupportTabs"/> on a plate with a support bed, the longest part (the
/// long side of its outline's minimum-area bounding rectangle) its tab alone holds: such a
/// part the bed cannot hold level is tabbed, and any tab clear of inside corners steadies
/// it. 0 turns the rule off.
/// </summary>
public double TabHeldMaxLength { get; set; }
/// <summary>Whether a part <paramref name="length"/> long is held by its tab alone (see <see cref="TabHeldMaxLength"/>).</summary>
public bool TabHoldsAt(double length) =>
TabHeldMaxLength > 0 && double.IsFinite(TabHeldMaxLength)
&& length <= TabHeldMaxLength + Math.Tolerance.Epsilon;
public SequenceParameters Sequencing { get; set; } = new SequenceParameters();
public AssignmentParameters Assignment { get; set; } = new AssignmentParameters();
}
@@ -1,141 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// Pierces found by eroding a cutout: the part of it that keeps a clearance from its wall
/// (see <see cref="ErodedPierce.Find"/>).
/// </summary>
/// <param name="Pierces">The pierce for each target in turn, up to the maximum asked for.</param>
/// <param name="Clearance">The least any pierce keeps from the wall, at the erosion level that found it.</param>
public sealed record ErodedPierces(IReadOnlyList<Vector> Pierces, double Clearance)
{
public static ErodedPierces None { get; } = new(System.Array.Empty<Vector>(), 0);
}
/// <summary>
/// The pierce search for cutouts too small for their configured lead-in (text, slots, small
/// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
/// The erosion goes as deep as the pierce clearance when the cutout has room for it, else
/// almost to its pole of inaccessibility. Each target, an entry the planner ranked, gets the
/// nearest point of the deepest erosion that still reaches the wall beside it, stepping down to
/// half that depth: a letter stroke is pierced on its own centre line next to the entry, and
/// only a target with no stroke beside it falls back to the deepest place. The lead then runs
/// straight from the pierce to the wall.
/// </summary>
public static class ErodedPierce
{
/// <summary>
/// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
/// not fit: deep enough to stay near the centre line, with room for every place the
/// cutout is about as wide as its widest.
/// </summary>
public const double DepthFraction = 0.9;
private const int PoleSearchCells = 4096;
// Shallower erosion levels a target may fall back to, each a tenth of the deepest less.
private const int LevelCount = 6;
private const double LevelStep = 0.1;
/// <summary>
/// A pierce in the closed cutout <paramref name="hole"/> for each of the first
/// <paramref name="max"/> <paramref name="targets"/>: the eroded point nearest it. A cutout
/// too small to erode is pierced at its pole for every target. Nothing for an open or
/// degenerate contour.
/// </summary>
public static ErodedPierces Find(Shape hole, double clearance, IReadOnlyList<Vector> targets, int max)
{
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|| !double.IsFinite(clearance))
return ErodedPierces.None;
// The inscribed radius, from the true wall: the deepest any erosion can go.
var coarse = ClipperBridge.Flatten(hole, Tolerance(hole.BoundingBox.Width, hole.BoundingBox.Length), false);
if (coarse.Vertices.Count < 3)
return ErodedPierces.None;
var pole = PolyLabel.Find(coarse, null, Tolerance(coarse.BoundingBox.Width, coarse.BoundingBox.Length) / 10,
PoleSearchCells);
if (!double.IsFinite(pole.X) || !double.IsFinite(pole.Y) || !coarse.ContainsPoint(pole))
return ErodedPierces.None;
var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
if (!(inscribed > 0))
return ErodedPierces.None;
// Inscribed flattening can stand off a concave arc, and round joins cut their arcs,
// each by the tolerance; Clipper rounds to its precision. Erode past both.
var depth = System.Math.Min(System.Math.Max(clearance, 0), inscribed * DepthFraction);
var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision));
var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
if (depth + margin >= inscribed - tolerance)
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
var polygon = ClipperBridge.Flatten(hole, tolerance, circumscribe: false);
var path = new PathsD { ClipperBridge.ToPath(polygon, positive: true) };
var levels = new List<PathD>[LevelCount];
List<PathD> Level(int level) => levels[level] ??= Clipper.InflatePaths(path,
-(Depth(level) + margin), JoinType.Round, EndType.Polygon, 2.0, ClipperBridge.Precision, tolerance)
.Where(p => p.Count >= 3).ToList();
double Depth(int level) => depth * (1 - level * LevelStep);
if (Level(0).Count == 0)
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
// Each target gets the deepest level that reaches the wall beside it: a stroke
// narrower than the cutout's widest place is still pierced on its own centre line, near
// the entry, rather than only at the widest place.
var pierces = new List<Vector>();
var shallowest = depth;
foreach (var target in targets)
{
var pierce = Nearest(Level(0), target);
var reached = 0;
for (var level = 0; level < LevelCount; level++)
{
var region = Level(level);
if (region.Count == 0)
continue;
var nearest = Nearest(region, target);
if (nearest.DistanceTo(target) <= 2 * (Depth(level) + margin))
{
pierce = nearest;
reached = level;
break;
}
}
pierces.Add(pierce);
shallowest = System.Math.Min(shallowest, Depth(reached));
if (pierces.Count == max)
break;
}
return new(pierces, shallowest);
}
// A hundredth of the smaller side: fine enough to find a thin cutout's pole.
private static double Tolerance(double width, double length) =>
System.Math.Max(System.Math.Min(width, length) / 100, System.Math.Pow(10, -ClipperBridge.Precision));
// The closest point of the region's boundary; targets lie on the wall, outside it.
private static Vector Nearest(List<PathD> paths, Vector target)
{
var best = default(Vector);
var bestDistance = double.PositiveInfinity;
foreach (var path in paths)
for (var i = 0; i < path.Count; i++)
{
var a = new Vector(path[i].x, path[i].y);
var b = new Vector(path[(i + 1) % path.Count].x, path[(i + 1) % path.Count].y);
var point = new Line(a, b).ClosestPointTo(target);
var distance = point.DistanceTo(target);
if (distance < bestDistance)
{
bestDistance = distance;
best = point;
}
}
return best;
}
}
}
@@ -1,62 +1,43 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// A single arc that ends tangent to the cut. Its center lies <see cref="Radius"/> along the
/// contour normal and it turns <see cref="SweepAngle"/> degrees in the lead's turn direction:
/// 90 is a quarter circle, 180 (the default) a half circle starting two radii out.
/// </summary>
public class ArcLeadIn : LeadIn
{
public const double DefaultSweepAngle = 180.0;
public double Radius { get; set; }
/// <summary>Degrees of arc, in (0, 180].</summary>
public double SweepAngle { get; set; } = DefaultSweepAngle;
public override List<ICode> Generate(
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var arcCenter = new Vector(
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
);
return new List<ICode>
{
new RapidMove(GetPiercePoint(contourStartPoint, contourNormalAngle, winding)),
new ArcMove(contourStartPoint, ArcCenter(contourStartPoint, contourNormalAngle), winding)
{
Layer = LayerType.Leadin,
},
new RapidMove(piercePoint),
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
};
}
public override Vector GetPiercePoint(
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{
// The contour point sits opposite the normal from the center; the pierce is the
// sweep back from there, against the turn. A half circle pierces on the normal.
var shortfall = Angle.ToRadians(DefaultSweepAngle - SweepAngle);
var angle = contourNormalAngle + (winding == RotationType.CCW ? shortfall : -shortfall);
var center = ArcCenter(contourStartPoint, contourNormalAngle);
var arcCenterX = contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
return new Vector(
center.X + Radius * System.Math.Cos(angle),
center.Y + Radius * System.Math.Sin(angle)
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
);
}
public override LeadIn Scale(double factor) =>
new ArcLeadIn { Radius = Radius * factor, SweepAngle = SweepAngle };
private Vector ArcCenter(Vector contourStartPoint, double contourNormalAngle) => new Vector(
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
);
public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor };
}
}
@@ -1,12 +1,11 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy
{
public class CleanHoleLeadIn : LeadIn
{
private const double ApproachAngle = 135.0;
public double LineLength { get; set; }
public double ArcRadius { get; set; }
public double Kerf { get; set; }
@@ -15,11 +14,42 @@ namespace OpenNest.CNC.CuttingStrategy
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW) =>
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var arcCenter = new Vector(arcCenterX, arcCenterY);
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)
);
}
public override LeadIn Scale(double factor) =>
new CleanHoleLeadIn
@@ -28,8 +58,5 @@ namespace OpenNest.CNC.CuttingStrategy
ArcRadius = ArcRadius * factor,
Kerf = Kerf,
};
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
}
}
@@ -3,11 +3,6 @@ using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// A lead into a contour point. <c>winding</c> is the direction a lead arc turns to meet the
/// cut tangentially: the cut's own direction around the arc center, which lies along the
/// normal (see <see cref="ContourCuttingStrategy.LeadTurn"/>). Straight styles ignore it.
/// </summary>
public abstract class LeadIn
{
public abstract List<ICode> Generate(
@@ -16,8 +11,7 @@ namespace OpenNest.CNC.CuttingStrategy
RotationType winding = RotationType.CW
);
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW);
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
public virtual LeadIn Scale(double factor) => this;
}
@@ -1,58 +0,0 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// A straight line into an arc that ends on the contour start, shared by
/// <see cref="LineArcLeadIn"/> and <see cref="CleanHoleLeadIn"/>. The arc's center lies
/// the arc radius along the contour normal; the line meets the arc at the approach angle
/// (degrees from the normal, on the side the arc turns from) and starts at the pierce point.
/// </summary>
internal readonly struct LineArcApproach
{
private LineArcApproach(Vector arcCenter, Vector arcStart, Vector piercePoint)
{
ArcCenter = arcCenter;
ArcStart = arcStart;
PiercePoint = piercePoint;
}
public Vector ArcCenter { get; }
public Vector ArcStart { get; }
public Vector PiercePoint { get; }
public static LineArcApproach Create(Vector contourStartPoint, double contourNormalAngle,
double arcRadius, double approachAngle, double lineLength, RotationType winding)
{
var arcCenterX = contourStartPoint.X + arcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + arcRadius * System.Math.Sin(contourNormalAngle);
// The approach angle is measured back from the normal against the turn, so the arc
// runs the short way round to the contour point whichever way it turns.
var approach = Angle.ToRadians(approachAngle);
var lineAngle = contourNormalAngle + (winding == RotationType.CCW ? approach : -approach);
var arcStartX = arcCenterX + arcRadius * System.Math.Cos(lineAngle);
var arcStartY = arcCenterY + arcRadius * System.Math.Sin(lineAngle);
return new LineArcApproach(
new Vector(arcCenterX, arcCenterY),
new Vector(arcStartX, arcStartY),
new Vector(
arcStartX + lineLength * System.Math.Cos(lineAngle),
arcStartY + lineLength * System.Math.Sin(lineAngle)));
}
/// <summary>Rapid to the pierce point, then the line and arc on the lead-in layer.</summary>
public List<ICode> ToCodes(Vector contourStartPoint, RotationType winding) =>
new List<ICode>
{
new RapidMove(PiercePoint),
new LinearMove(ArcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, ArcCenter, winding) { Layer = LayerType.Leadin },
};
}
}
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -13,11 +14,42 @@ namespace OpenNest.CNC.CuttingStrategy
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW) =>
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var arcCenter = new Vector(arcCenterX, arcCenterY);
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)
);
}
public override LeadIn Scale(double factor) =>
new LineArcLeadIn
@@ -26,8 +58,5 @@ namespace OpenNest.CNC.CuttingStrategy
ArcRadius = ArcRadius * factor,
ApproachAngle = ApproachAngle,
};
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
}
}
@@ -24,8 +24,7 @@ namespace OpenNest.CNC.CuttingStrategy
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
return new Vector(
@@ -33,8 +33,7 @@ namespace OpenNest.CNC.CuttingStrategy
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
@@ -14,8 +14,7 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ICode> { new RapidMove(contourStartPoint) };
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{
return contourStartPoint;
}
@@ -3,10 +3,6 @@ using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// A quarter circle leaving the cut tangentially, turning <c>winding</c> about a center
/// <see cref="Radius"/> along the contour normal.
/// </summary>
public class ArcLeadOut : LeadOut
{
public double Radius { get; set; }
@@ -21,12 +17,9 @@ namespace OpenNest.CNC.CuttingStrategy
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
var arcCenter = new Vector(arcCenterX, arcCenterY);
// The contour point sits opposite the normal from the center; turn a quarter on.
var endAngle = contourNormalAngle
+ (winding == RotationType.CCW ? -System.Math.PI / 2 : System.Math.PI / 2);
var endPoint = new Vector(
arcCenterX + Radius * System.Math.Cos(endAngle),
arcCenterY + Radius * System.Math.Sin(endAngle)
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)
);
return new List<ICode>
-158
View File
@@ -1,158 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
/// <summary>
/// The never-cut outline of a supplied blank, kept at the end of an applied cutting program.
/// An applied program for a blank with a <see cref="LayerType.DisplayPerimeter"/> outline cuts
/// only its cutouts; the outline follows them after <see cref="Marker"/> so the part keeps the
/// blank's extent for bounds, overlap and cut-offs. Nothing that executes or posts a program may
/// run the tail: executed reads stop at the marker and posts take <see cref="ForPosting"/>.
/// </summary>
public static class DisplayPerimeterTail
{
/// <summary>The comment that starts the tail; every code after it is display only.</summary>
public const string Marker = "DISPLAY PERIMETER NEVER CUT";
/// <summary>Why the manual and legacy lead-in tools leave a display-perimeter part alone.</summary>
public const string LeadInRefusal =
"This part's outline is a display perimeter: a supplied blank that is never cut. "
+ "Lead-ins for its cutouts come from Plan Cutting, which keeps every pierce inside a cutout.";
/// <summary>
/// The index of the tail's marker in <paramref name="program"/>'s codes, or -1. A marker that is
/// repeated, sits in a sub-program, or is followed by anything but one rapid and moves on the
/// display perimeter layer is a malformed program (<see cref="ArgumentException"/>): cuts after
/// it must never be dropped silently.
/// </summary>
public static int IndexOf(Program program)
{
ArgumentNullException.ThrowIfNull(program);
var marker = -1;
for (var i = 0; i < program.Codes.Count; i++)
{
if (program.Codes[i] is Comment { Value: Marker })
{
if (marker >= 0)
throw new ArgumentException("A program has more than one display perimeter tail.");
marker = i;
}
else if (program.Codes[i] is SubProgramCall call
&& HasMarker(call.Program, new HashSet<Program>(ReferenceEqualityComparer.Instance)))
throw new ArgumentException("A display perimeter tail can only end the part's own program, not a sub-program.");
}
if (marker < 0)
return -1;
var tail = program.Codes.Count - marker - 1;
if (tail < 2 || program.Codes[marker + 1] is not RapidMove
|| !program.Codes.Skip(marker + 2).All(code =>
code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter }))
throw new ArgumentException(
"A display perimeter tail must be one rapid followed only by the outline on the display perimeter layer.");
return marker;
}
private static bool HasMarker(Program program, HashSet<Program> visited)
{
if (program?.Codes == null || !visited.Add(program))
return false;
foreach (var code in program.Codes)
if (code is Comment { Value: Marker } || code is SubProgramCall call && HasMarker(call.Program, visited))
return true;
return false;
}
public static bool HasTail(Program program) => IndexOf(program) >= 0;
/// <summary>
/// Appends the outline after the marker: a rapid to its start, then its entities on the
/// display perimeter layer. <paramref name="program"/> must still be in absolute mode.
/// </summary>
internal static void Append(Program program, IReadOnlyList<ICode> outline, Vector start)
{
if (program.Mode != Mode.Absolute)
throw new InvalidOperationException("Append the display tail before switching to incremental mode.");
program.Codes.Add(new Comment(Marker));
program.Codes.Add(new RapidMove(start));
program.Codes.AddRange(outline);
}
/// <summary>
/// The executed program for estimates and drawing: <paramref name="program"/> itself when it
/// has no well-formed tail, otherwise a shallow copy of the codes before the marker. Posts
/// and verification use the strict <see cref="ForPosting"/> and executed reads instead.
/// </summary>
public static Program WithoutTail(Program program)
{
try
{
var marker = IndexOf(program);
return marker < 0 ? program : program.HeadCopy(marker);
}
catch (ArgumentException)
{
return program;
}
}
/// <summary>Whether <paramref name="program"/> or any sub-program it calls moves on the display perimeter layer.</summary>
public static bool HasDisplayPerimeterMotion(Program program) =>
HasDisplayPerimeterMotion(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
private static bool HasDisplayPerimeterMotion(Program program, HashSet<Program> visited)
{
if (program?.Codes == null || !visited.Add(program))
return false;
foreach (var code in program.Codes)
{
if (code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter })
return true;
if (code is SubProgramCall call && HasDisplayPerimeterMotion(call.Program, visited))
return true;
}
return false;
}
/// <summary>
/// The program a post may write for a part: its tail removed. A display perimeter anywhere
/// else means the part was never planned, and posting it would cut the blank's outline.
/// </summary>
public static Program ForPosting(Program program, string partName)
{
int marker;
try
{
marker = IndexOf(program);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{partName} cannot be posted: {ex.Message}", ex);
}
var executed = marker < 0 ? program : program.HeadCopy(marker);
if (HasDisplayPerimeterMotion(executed))
throw new InvalidOperationException(
$"{partName} has a display perimeter (a supplied blank's outline that is never cut) but no "
+ "cutting plan. Run Plan Cutting before posting so only its cutouts are cut.");
return executed;
}
/// <summary>The name posts give a part in a refusal.</summary>
public static string PartName(Part part) => $"Part '{part.BaseDrawing?.Name}'";
/// <summary>
/// Refuses a nest a post cannot write, before any output is created: every part's program
/// must pass <see cref="ForPosting"/>, so a refusal never leaves an empty or partial file.
/// </summary>
public static void EnsurePostable(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
foreach (var plate in nest.Plates)
foreach (var part in plate.Parts)
if (part?.Program != null && part.BaseDrawing?.IsCutOff != true)
ForPosting(part.Program, PartName(part));
}
}
}
-103
View File
@@ -1,103 +0,0 @@
using System;
using System.Collections.Generic;
namespace OpenNest.CNC
{
/// <summary>
/// The head-height lock around a cut-off's overcut past the nominal far sheet edge. Stock can
/// arrive wider than nominal, so a cut-off overcuts past the far edge to cut the oversize off;
/// past the real edge there is no material to sense, so the head height is locked first. A
/// cut-off program marks the locked stretch with a <see cref="Lock"/> comment and ends it with an
/// <see cref="Unlock"/> comment; a rapid between them keeps the head locked and is not a head
/// raise. Posts write each marker as their machine's lock or unlock code and refuse to post a
/// marked program when no code is configured, because the overcut is unsafe without the lock.
/// See docs/automatic-scrap-cutoffs.md.
/// </summary>
public static class HeadHeightLock
{
/// <summary>The comment before the first motion made with the head height locked.</summary>
public const string Lock = "HEAD HEIGHT LOCK";
/// <summary>The comment after the last motion made with the head height locked.</summary>
public const string Unlock = "HEAD HEIGHT UNLOCK";
public static bool IsLock(ICode code) => code is Comment { Value: Lock };
public static bool IsUnlock(ICode code) => code is Comment { Value: Unlock };
/// <summary>Whether <paramref name="program"/>'s own codes hold a lock marker.</summary>
public static bool IsMarked(Program program)
{
if (program?.Codes == null)
return false;
foreach (var code in program.Codes)
if (IsLock(code))
return true;
return false;
}
/// <summary>
/// For each of <paramref name="codes"/>, whether the head height is locked while it runs: the
/// codes after a lock marker up to its unlock marker. Markers must alternate, starting with a
/// lock and ending unlocked; anything else is a malformed program (<see cref="ArgumentException"/>),
/// since a post that lost track of the lock could cut off the sheet unlocked or never unlock.
/// </summary>
public static bool[] LockedMask(IReadOnlyList<ICode> codes)
{
ArgumentNullException.ThrowIfNull(codes);
var mask = new bool[codes.Count];
var locked = false;
for (var i = 0; i < codes.Count; i++)
{
if (IsLock(codes[i]))
{
if (locked)
throw new ArgumentException("A head-height lock is set twice without an unlock.");
locked = true;
}
else if (IsUnlock(codes[i]))
{
if (!locked)
throw new ArgumentException("A head-height unlock has no lock before it.");
locked = false;
}
else
mask[i] = locked;
}
if (locked)
throw new ArgumentException("A head-height lock is never released.");
return mask;
}
/// <summary>
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked
/// overcut needs the post's lock and unlock codes, and its markers must be well formed.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCodes, string postName)
{
ArgumentNullException.ThrowIfNull(nest);
foreach (var plate in nest.Plates)
{
foreach (var part in plate.Parts)
{
if (part?.Program == null || !IsMarked(part.Program))
continue;
var name = DisplayPerimeterTail.PartName(part);
if (!hasCodes)
throw new InvalidOperationException(
$"{name} overcuts past the sheet edge with the head height locked, but the {postName} post "
+ "has no head-height lock and unlock codes. Enter them in the post's configuration, "
+ "or delete the cut-off and add it again with the head-height lock off.");
try
{
LockedMask(part.Program.Codes);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
}
}
}
}
}
}
-9
View File
@@ -21,14 +21,5 @@ 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,
}
}
-15
View File
@@ -508,21 +508,6 @@ namespace OpenNest.CNC
return true;
}
/// <summary>
/// A shallow copy holding only the first <paramref name="count"/> codes: the same
/// instruction, variable and sub-program instances, mode and rotation.
/// </summary>
internal Program HeadCopy(int count)
{
var head = new Program() { mode = this.mode, Rotation = this.Rotation };
head.Codes.AddRange(Codes.GetRange(0, count));
foreach (var kvp in Variables)
head.Variables[kvp.Key] = kvp.Value;
foreach (var kvp in SubPrograms)
head.SubPrograms[kvp.Key] = kvp.Value;
return head;
}
public object Clone()
{
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
-2
View File
@@ -31,8 +31,6 @@ namespace OpenNest.CNC
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
{
// The head never travels to a display perimeter's tail.
pgm = DisplayPerimeterTail.WithoutTail(pgm);
// Draw the approach to the first rapid destination, or to the program
// origin when it starts cutting without a rapid. Only an initial rapid
// (or call arrival) is already represented by this approach; a later
@@ -183,9 +183,6 @@ 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,9 +213,6 @@ namespace OpenNest.Converters
case LayerType.Tab:
return SpecialLayers.Tab;
case LayerType.DisplayPerimeter:
return SpecialLayers.DisplayPerimeter;
default:
return new Layer(layer.ToString());
}
+6 -167
View File
@@ -21,23 +21,6 @@ namespace OpenNest
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;
@@ -52,128 +35,10 @@ namespace OpenNest
)
{
var segments = ComputeSegments(plate, settings, cache);
var program = BuildProgram(segments, settings, LockedFarEdge(plate));
var program = BuildProgram(segments, settings);
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,
@@ -208,20 +73,13 @@ namespace OpenNest
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;
if (cutting)
return false; // A second pierce leaves an uncut gap.
position = rapid.EndPoint + part.Location;
break;
case LinearMove move when position != null:
@@ -261,8 +119,6 @@ namespace OpenNest
)
{
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,
@@ -272,13 +128,13 @@ namespace OpenNest
{
cutPosition = Position.X;
lineStart = StartLimit ?? bounds.Y;
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + overtravel);
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel);
}
else
{
cutPosition = Position.Y;
lineStart = StartLimit ?? bounds.X;
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.Overtravel);
}
var exclusions = new List<(double Start, double End)>();
@@ -580,8 +436,7 @@ namespace OpenNest
private Program BuildProgram(
List<(double Start, double End)> segments,
CutOffSettings settings,
(double Edge, bool High)? farEdge
CutOffSettings settings
)
{
var program = new Program();
@@ -598,22 +453,6 @@ namespace OpenNest
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)));
-98
View File
@@ -1,98 +0,0 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// One placed cutting segment of a cut-off: the part that cuts it, where its cut runs along
/// the cut-off's axis, and the span of the nominal line it stands for, from the end of the
/// segment before it (or the line's start) to the start of the one after it (or the line's
/// end). The parts the nominal line passes through lie in the gaps between segments, so each
/// is inside the span of the segment on either side of it; a segment is cut before exactly
/// those parts.
/// </summary>
public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd);
/// <summary>
/// A cut-off's trimmed segments are materialized as one part each, so every segment has its
/// own place in the cut sequence. This finds those parts and the span each stands for.
/// </summary>
public static class CutOffSegments
{
/// <summary>The definition's placed segment parts in ascending order along its axis.</summary>
public static IReadOnlyList<CutOffSegment> Of(Plate plate, CutOff definition) =>
Of(definition, plate.Parts, plate.BoundingBox(includeParts: false));
/// <summary>
/// The definition's segment parts among <paramref name="parts"/> (those placed from its
/// drawing) in ascending order along its axis, with spans measured against the nominal
/// line's limits on <paramref name="sheet"/>. A part whose cut cannot be measured stands
/// for the whole line.
/// </summary>
public static IReadOnlyList<CutOffSegment> Of(CutOff definition, IEnumerable<Part> parts, Box sheet)
{
var vertical = definition.Axis == CutOffAxis.Vertical;
var lineStart = definition.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left);
var lineEnd = definition.EndLimit ?? (vertical ? sheet.Top : sheet.Right);
var low = System.Math.Min(lineStart, lineEnd);
var high = System.Math.Max(lineStart, lineEnd);
var measured = new List<(Part Part, double Start, double End)>();
var whole = new List<CutOffSegment>();
foreach (var part in parts)
{
if (!ReferenceEquals(part.BaseDrawing, definition.Drawing))
continue;
if (Extent(part, vertical) is { } extent)
measured.Add((part, extent.Start, extent.End));
else
whole.Add(new CutOffSegment(part, low, high, low, high));
}
measured.Sort((a, b) => a.Start != b.Start ? a.Start.CompareTo(b.Start) : a.End.CompareTo(b.End));
var result = new List<CutOffSegment>(measured.Count + whole.Count);
for (var i = 0; i < measured.Count; i++)
{
var (part, start, end) = measured[i];
var spanStart = i == 0 ? System.Math.Min(low, start) : measured[i - 1].End;
var spanEnd = i == measured.Count - 1 ? System.Math.Max(high, end) : measured[i + 1].Start;
result.Add(new CutOffSegment(part, start, end, spanStart, spanEnd));
}
result.AddRange(whole);
return result;
}
/// <summary>
/// The extent along the cut-off's axis of the part's cutting moves, or null when the
/// part cuts nothing or its program is not the absolute rapid-and-cut form cut-offs use.
/// </summary>
public static (double Start, double End)? Extent(Part part, bool vertical)
{
if (part?.Program == null || part.Program.Mode != Mode.Absolute)
return null;
double? min = null, max = null;
Vector? position = null;
foreach (var code in part.Program.Codes)
{
switch (code)
{
case RapidMove rapid:
position = rapid.EndPoint + part.Location;
break;
case Motion motion when position != null:
var end = motion.EndPoint + part.Location;
foreach (var point in new[] { position.Value, end })
{
var along = vertical ? point.Y : point.X;
min = min == null ? along : System.Math.Min(min.Value, along);
max = max == null ? along : System.Math.Max(max.Value, along);
}
position = end;
break;
case Motion:
return null;
}
}
return min == null ? null : (min.Value, max.Value);
}
}
}
-13
View File
@@ -12,18 +12,5 @@ namespace OpenNest
public double Overtravel { get; set; }
public double MinSegmentLength { get; set; } = 0.05;
public CutDirection CutDirection { get; set; } = CutDirection.AwayFromOrigin;
/// <summary>
/// Whether new cut-offs lock the head height and overcut past the nominal far sheet edge
/// by <see cref="HeadLockOvercut"/> (see <see cref="CutOff.HeadLockOvercut"/>). Off until
/// the machine's post has head-height lock codes.
/// </summary>
public bool LockHeadHeight { get; set; }
/// <summary>How far past the nominal far edge a head-locked cut-off cuts, in model units.</summary>
public double HeadLockOvercut { get; set; } = 1.5;
/// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary>
public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0;
}
}
@@ -28,17 +28,12 @@ public static class ClampCutDiagnostics
var cut = plate.CutOffs[index];
if (cut == null || !Enum.IsDefined(cut.Axis))
throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate));
// A head-locked overcut runs on past the far edge, where an off-sheet clamp can be.
var overcut = cut.HeadLockOvercut > 0 && double.IsFinite(cut.HeadLockOvercut) ? cut.HeadLockOvercut : 0;
var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
var low = farHigh ? 0 : overcut;
var high = farHigh ? overcut : 0;
var start = cut.Axis == CutOffAxis.Vertical
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom - low)
: new Vector(cut.StartLimit ?? sheet.Left - low, cut.Position.Y);
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom)
: new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y);
var end = cut.Axis == CutOffAxis.Vertical
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top + high)
: new Vector(cut.EndLimit ?? sheet.Right + high, cut.Position.Y);
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top)
: new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y);
foreach (var zone in zones)
if (Intersects(start, end, zone.Bounds, out var location))
findings.Add(new CutOffClampFinding(index, zone.Id, location));
@@ -12,7 +12,7 @@ public sealed class OverlapGeometryStamp
private readonly Plate plate;
private readonly Entry[] entries;
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End, long Overcut)[] cutoffs;
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End)[] cutoffs;
private readonly (long Width, long Length, int Quadrant) sheet;
private OverlapGeometryStamp(Plate plate)
@@ -25,7 +25,7 @@ public sealed class OverlapGeometryStamp
zones = new (string, long, long, long, long)[plate.ClampZones.Count];
for (var i = 0; i < zones.Length; i++)
zones[i] = ZoneState(plate.ClampZones[i]);
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?, long)[plate.CutOffs.Count];
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?)[plate.CutOffs.Count];
for (var i = 0; i < cutoffs.Length; i++)
cutoffs[i] = CutOffState(plate.CutOffs[i]);
}
@@ -36,12 +36,11 @@ public sealed class OverlapGeometryStamp
zone?.Bounds is not { } box ? default
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
private static (CutOff, CutOffAxis, long, long, long?, long?, long) CutOffState(CutOff cut) =>
private static (CutOff, CutOffAxis, long, long, long?, long?) CutOffState(CutOff cut) =>
cut == null ? default
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null,
Bits(cut.HeadLockOvercut));
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null);
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
+80 -9
View File
@@ -15,16 +15,20 @@ namespace OpenNest.Diagnostics;
/// Nest's layout check finds them: each part's clean nesting material
/// (<see cref="NestingMaterial.ForNesting"/>, leads excluded) outlined by <see cref="SpacingOutline"/>,
/// the leftmost of a pair inflated by <see cref="SpacingOutline.InflationFor"/>. That outline
/// circumscribes arcs by up to its chord tolerance, more than the slack, and interactive fills
/// place native arcs exactly at the spacing. So each candidate is confirmed on
/// <see cref="NativeContours"/>, as the layout check confirms it, and reported only when that gap
/// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays
/// valid.
/// circumscribes arcs by up to its chord tolerance, more than the slack, and engines add a margin
/// for it; interactive fills place native arcs exactly at the spacing. So each candidate is
/// confirmed on the native contours flattened at <see cref="FineTolerance"/> and reported only when
/// that gap is short of the spacing by more than the slack. Zero spacing enforces nothing, so
/// contact stays valid.
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
/// that check. The sheet edge and clamps are not spacing-checked here.
/// </summary>
internal static class PartSpacingReview
{
/// <summary>Chord tolerance of the confirming measurement. Circumscribed on both sides, it
/// understates a gap by at most twice this, a tenth of <see cref="SpacingOutline.SpacingSlack"/>.</summary>
internal const double FineTolerance = SpacingOutline.SpacingSlack / 20;
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
CancellationToken cancellationToken)
@@ -45,7 +49,7 @@ internal static class PartSpacingReview
var overlapping = overlap.Pairs.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
var profiles = new Dictionary<(Program, long), ShapeProfile>();
var shapes = new ShapeProfile[parts.Count];
var fine = new NativeContours[parts.Count];
var fine = new FineRings[parts.Count];
var raw = new SpacingOutline[parts.Count];
var inflated = new SpacingOutline[parts.Count];
for (var i = 0; i < parts.Count; i++)
@@ -95,9 +99,9 @@ internal static class PartSpacingReview
Vector marker;
try
{
fine[i] ??= NativeContours.Of(shapes[i], parts[i].Location);
fine[j] ??= NativeContours.Of(shapes[j], parts[j].Location);
gap = fine[i].GapTo(fine[j], spacing, out marker);
fine[i] ??= FineRings.Of(shapes[i], parts[i].Location);
fine[j] ??= FineRings.Of(shapes[j], parts[j].Location);
gap = Gap(fine[i], fine[j], spacing, out marker);
}
catch (Exception exception) when (IsInvalid(exception))
{
@@ -144,6 +148,73 @@ internal static class PartSpacingReview
return profile;
}
/// <summary>Closest boundary approach between two parts, including a part inside the other's
/// cutout; <paramref name="marker"/> is the middle of that gap. Only edges within
/// <paramref name="reach"/> of the other part's bounds can be closer than it, so the rest are
/// skipped. Infinity when no edges are that near.</summary>
private static double Gap(FineRings a, FineRings b, double reach, out Vector marker)
{
var best = double.PositiveInfinity;
var middle = a.Bounds.Center;
var perimeterA = Near(a.Perimeter, b.Bounds, reach);
var perimeterB = Near(b.Perimeter, a.Bounds, reach);
Measure(perimeterA, perimeterB);
foreach (var hole in a.Holes)
Measure(Near(hole, b.Bounds, reach), perimeterB);
foreach (var hole in b.Holes)
Measure(perimeterA, Near(hole, a.Bounds, reach));
marker = middle;
return best;
void Measure(List<Line> ringA, List<Line> ringB)
{
if (ringA.Count == 0 || ringB.Count == 0)
return;
var distance = Clearance.ClosestPoints(ringA, ringB, out var onA, out var onB);
if (distance < best)
{
best = distance;
middle = (onA + onB) * 0.5;
}
}
}
private static List<Line> Near(List<Line> lines, Box other, double reach) =>
lines.Where(line =>
System.Math.Min(line.StartPoint.X, line.EndPoint.X) <= other.Right + reach
&& System.Math.Max(line.StartPoint.X, line.EndPoint.X) >= other.Left - reach
&& System.Math.Min(line.StartPoint.Y, line.EndPoint.Y) <= other.Top + reach
&& System.Math.Max(line.StartPoint.Y, line.EndPoint.Y) >= other.Bottom - reach).ToList();
/// <summary>A part's native contours in world space, flattened at <see cref="FineTolerance"/>.
/// Circumscribed, so the measured gap never exceeds the true one by more than rounding.</summary>
private sealed class FineRings
{
public List<Line> Perimeter { get; private init; }
public List<List<Line>> Holes { get; private init; }
public Box Bounds { get; private init; }
public static FineRings Of(ShapeProfile profile, Vector location)
{
var perimeter = World(profile.Perimeter, location, out var bounds);
return new FineRings
{
Perimeter = perimeter.ToLines(),
Holes = profile.Cutouts.Select(cutout => World(cutout, location, out _).ToLines()).ToList(),
Bounds = bounds,
};
}
private static Polygon World(Shape shape, Vector location, out Box bounds)
{
var polygon = ClipperBridge.Flatten(shape, FineTolerance, true);
polygon.Offset(location);
polygon.UpdateBounds();
bounds = polygon.BoundingBox;
return polygon;
}
}
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
private static string Name(Part part) => part.BaseDrawing.Name;
@@ -1,132 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
using OpenNest.Support;
namespace OpenNest.Diagnostics;
/// <summary>
/// Pre-post review of part support (docs/support-tabs.md). Every part whose cutting parameters
/// ask for support tabs and that needs one (<see cref="SupportTabSettings.Analyze"/>: its plate's
/// support bed cannot hold it level or, without a bed, its width is in the no-bed range) must be
/// cut with a tab that keeps it level, however its program was made (planned, placed by hand or
/// locked); without a bed any tab will do. The tab is the gap the outside cut leaves between its end and its start. Reports
/// only; a part whose material or program cannot be read is reported as not checked.
/// </summary>
internal static class PartSupportReview
{
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
CancellationToken token)
{
var bed = plate.SupportBed;
for (var index = 0; index < plate.Parts.Count; index++)
{
token.ThrowIfCancellationRequested();
var part = plate.Parts[index];
var parameters = part?.CuttingParameters ?? plate.CuttingParameters;
if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff || parameters?.SupportTabs != true
|| (bed == null && !parameters.HasNoBedTabRange()))
continue;
var partNumber = index + 1;
try
{
var support = SupportTabSettings.Analyze(parameters, bed, plate.Quadrant, SupportMaterial.FromPart(part));
if (support == null)
continue;
if (!support.IsComplete)
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null,
"Support check: " + support.Reason));
continue;
}
if (support.IsSupported)
continue;
var tab = OutsideTab(ExecutionMotionReader.Read(part.Program, part.Location, null, token));
if (tab is { } point && support.IsSteadiedByTabAt(point))
continue;
findings.Add(new(PostVerificationKind.PartSupport, plateNumber, partNumber, null,
Message(support, tab))
{ Location = support.CenterOfGravity });
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null,
"Support check: " + exception.Message));
}
}
}
/// <summary>
/// The middle of the gap the outside cut leaves open, or null when it closes: the outside cut
/// is the chain of cutting motions with the largest extent.
/// </summary>
internal static Vector? OutsideTab(OwnedExecution execution)
{
Vector? start = null, end = null, bestStart = null, bestEnd = null;
var minX = double.PositiveInfinity; var minY = double.PositiveInfinity;
var maxX = double.NegativeInfinity; var maxY = double.NegativeInfinity;
var bestArea = -1.0;
foreach (var motion in execution.Motions.Append(null))
{
if (motion is { Rapid: false, Layer: LayerType.Cut or LayerType.Display })
{
start ??= motion.Start ?? motion.End;
end = motion.End;
foreach (var p in new[] { motion.Start ?? motion.End, motion.End, motion.Curve?.Midpoint ?? motion.End })
{
minX = System.Math.Min(minX, p.X); minY = System.Math.Min(minY, p.Y);
maxX = System.Math.Max(maxX, p.X); maxY = System.Math.Max(maxY, p.Y);
}
continue;
}
// Anything else ends a chain: a rapid, a lead, a scribe or a break cut.
if (start != null && (maxX - minX) * (maxY - minY) > bestArea)
{
bestArea = (maxX - minX) * (maxY - minY);
bestStart = start;
bestEnd = end;
}
start = end = null;
minX = minY = double.PositiveInfinity;
maxX = maxY = double.NegativeInfinity;
}
if (bestStart is not { } first || bestEnd is not { } last
|| first.DistanceTo(last) <= PostVerificationGeometry.Epsilon)
return null;
return (first + last) * 0.5;
}
private static string Message(PartSupport support, Vector? tab)
{
// Without a bed, or for a part its tab alone holds, any tab steadies the part, so the only
// failure is no tab at all.
if (!support.HasBed || support.IsHeldByTab)
return (support.HasBed
? FormattableString.Invariant(
$"The part is {support.Length:0.####} long, short enough for its tab alone to hold, ")
: FormattableString.Invariant(
$"The part is {support.Width:0.####} wide, in the width range tabbed without a support bed, "))
+ "and it has no tab; it may tip once cut free."
+ (support.BestTabPoint is { } suggested
? FormattableString.Invariant($" Tab it near ({suggested.X:0.####}, {suggested.Y:0.####}).")
: "");
var held = support.Margin == double.NegativeInfinity
? "rests on no support contact"
: support.Margin < 0
? FormattableString.Invariant($"has its center of gravity {-support.Margin:0.####} outside its support contacts")
: FormattableString.Invariant($"has its center of gravity only {support.Margin:0.####} inside its support contacts");
var tabText = tab is { } point
? FormattableString.Invariant($"its tab at ({point.X:0.####}, {point.Y:0.####}) does not steady it")
: "it has no tab";
var best = support.BestTabPoint is { } bestPoint && support.TabCanSupport
? FormattableString.Invariant($" A tab near ({bestPoint.X:0.####}, {bestPoint.Y:0.####}) would.")
: " No single tab can steady it.";
return FormattableString.Invariant($"The part {held} (needs {support.RequiredMargin:0.####}) and {tabText}; ")
+ "it may tip once cut free." + best;
}
}
@@ -135,11 +135,6 @@ public static class PostVerificationAnalyzer
if (part == null || !double.IsFinite(part.Rotation))
throw new ArgumentException("Missing part or invalid rotation.");
var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions;
// Executed reads stop at an applied program's display tail, so any display
// perimeter left is an unplanned blank whose outline a post would refuse.
if (moves.Any(move => move.Layer == LayerType.DisplayPerimeter))
Incomplete("This part has a display perimeter (a supplied blank's outline that is never cut) "
+ "but no cutting plan; posts refuse it. Run Plan Cutting so only its cutouts are cut.");
if (expectsCuts && !moves.Any(move => !move.Rapid
&& move.Layer is LayerType.Cut or LayerType.Display
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
@@ -224,8 +219,6 @@ public static class PostVerificationAnalyzer
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
// Parts that do not overlap may still be closer than the plate's part spacing.
PartSpacingReview.Add(plate, plateNumber, materialParts, indices, overlap, findings, cancellationToken);
// Parts the support bed cannot hold level need a tab that keeps them level.
PartSupportReview.Add(plate, plateNumber, findings, cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
return new PostVerificationReport(findings);
@@ -78,7 +78,7 @@ internal static class PostVerificationGeometry
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
// (including the empty extent's) fail this test too.
internal bool IsWellConditioned =>
private bool IsWellConditioned =>
System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)),
System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit;
}
@@ -110,56 +110,17 @@ internal static class PostVerificationGeometry
// If there are no contacts except possibly departure, all open-segment points
// have the same inside/outside status. A midpoint catches travel entirely inside
// and departure into the interior, without flagging start-only outward contact.
return Inside(start + delta * 0.5, curves, token);
}
/// <summary>
/// Ray parity of <paramref name="point"/> against closed <paramref name="curves"/>, cast at
/// <see cref="RayY"/> so no vertex of them lies on the ray.
/// </summary>
internal static bool Inside(Vector point, IReadOnlyList<Curve> curves, CancellationToken token)
{
var rayY = RayY(point, curves, token);
var midpoint = start + delta * 0.5;
var inside = false;
foreach (var curve in curves)
{
token.ThrowIfCancellationRequested();
if (curve.CrossesRay(point, rayY))
if (curve.CrossesRay(midpoint))
inside = !inside;
}
return inside;
}
/// <summary>
/// The height a parity ray from <paramref name="point"/> runs at: just above it, by about
/// <see cref="RaySeamTolerance"/>, and clear of every vertex height of <paramref name="curves"/>.
/// The two copies of a seam vertex can differ in the last bits; a ray running between them
/// counts one edge and not the other, so a point that happens to sit a tolerance below another
/// contour's vertex (text baselines a micro-inch apart) would read as inside it.
/// </summary>
internal static double RayY(Vector point, IReadOnlyList<Curve> curves, CancellationToken token)
{
const int steps = 8;
const double band = RaySeamTolerance / (4 * steps);
for (var step = 0; step < steps; step++)
{
var rayY = point.Y + RaySeamTolerance * (1 + (double)step / steps);
var clear = true;
foreach (var curve in curves)
{
token.ThrowIfCancellationRequested();
if (curve.HasVertexNear(rayY, band))
{
clear = false;
break;
}
}
if (clear)
return rayY;
}
return point.Y + RaySeamTolerance;
}
internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end,
IReadOnlyList<Curve> curves, CancellationToken token)
{
@@ -318,18 +279,6 @@ internal static class PostVerificationGeometry
/// <summary>Exact native distance from <paramref name="point"/> to this finite curve.</summary>
internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point);
/// <summary>The native point of this finite curve closest to <paramref name="point"/>.</summary>
internal Vector ClosestPoint(Vector point) => nativeEntity.Value.ClosestPointTo(point);
/// <summary>
/// This curve from its start up to <paramref name="point"/>, which must be one of its own
/// interior points (see <see cref="ClosestPoint"/>); an arc keeps its center and radius.
/// </summary>
internal Curve Until(Vector point) => Center is { } center
? new Curve(Start, point, center, Radius,
System.Math.Sign(Sweep) * Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)))
: new Curve(Start, point, null, 0, 0);
internal bool Contains(Vector point) => Contains(point, Epsilon);
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
@@ -506,20 +455,7 @@ internal static class PostVerificationGeometry
}
}
/// <summary>
/// Whether an endpoint, or the top or bottom of an arc's circle, lies within
/// <paramref name="band"/> of height <paramref name="y"/>.
/// </summary>
internal bool HasVertexNear(double y, double band) =>
System.Math.Abs(Start.Y - y) <= band || System.Math.Abs(End.Y - y) <= band
|| Center is { } center && (System.Math.Abs(center.Y + Radius - y) <= band
|| System.Math.Abs(center.Y - Radius - y) <= band);
/// <summary>
/// Whether a ray running in +X from <paramref name="point"/>, at height
/// <paramref name="rayY"/> (see <see cref="RayY"/>), crosses this curve.
/// </summary>
internal bool CrossesRay(Vector point, double rayY)
internal bool CrossesRay(Vector point)
{
// Ray parity needs both edges meeting at a shared vertex to agree which side of the ray
// that vertex is on. Rings are only nominally closed: capture admits closure seams and
@@ -530,6 +466,7 @@ internal static class PostVerificationGeometry
// edges; real features (notches, tabs, arc spans) are far larger than the tolerance and
// still count. Containment of a point that itself lies within the tolerance of a
// boundary edge stays ambiguous by design, exactly as it was before.
var rayY = point.Y + RaySeamTolerance;
if (Center is not { } center)
return (Start.Y > rayY) != (End.Y > rayY)
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
@@ -34,13 +34,7 @@ public enum PostVerificationKind
/// Two parts are closer than the plate's part spacing without sharing material, judged as
/// Auto Nest's layout check judges them.
/// </summary>
PartSpacing,
/// <summary>
/// A part its plate's support bed cannot hold level has no tab that keeps it level, so it may
/// tip once cut free (see docs/support-tabs.md).
/// </summary>
PartSupport
PartSpacing
}
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
@@ -90,8 +84,6 @@ public sealed class PostVerificationReport
Summary(PostVerificationKind.PierceZone, "Pierce zones");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSpacing))
Summary(PostVerificationKind.PartSpacing, "Part spacing");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSupport))
Summary(PostVerificationKind.PartSupport, "Part support");
foreach (var finding in Findings)
{
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
-107
View File
@@ -1,107 +0,0 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Geometry
{
/// <summary>
/// A part's native contours in world space, flattened at <see cref="ChordTolerance"/>, to
/// confirm a part-spacing candidate that <see cref="SpacingOutline"/> found. The outline
/// circumscribes arcs by up to its chord tolerance, more than <see cref="SpacingOutline.SpacingSlack"/>,
/// so two convex arcs facing each other exactly at the spacing can overlap there; these
/// contours measure that gap to within a tenth of the slack. Every spacing verdict (Auto
/// Nest's layout check, the whole-job runner's candidate validation and the pre-post review)
/// confirms with them, so none can disagree.
/// </summary>
public sealed class NativeContours
{
/// <summary>Chord tolerance of the confirming measurement. Circumscribed on both sides, it
/// understates a gap by at most twice this, a tenth of <see cref="SpacingOutline.SpacingSlack"/>.</summary>
public const double ChordTolerance = SpacingOutline.SpacingSlack / 20;
private Polygon perimeter;
private List<Polygon> holes;
private List<Line> perimeterLines;
private List<List<Line>> holeLines;
private NativeContours() { }
public Box Bounds { get; private set; }
/// <summary>Flattens <paramref name="profile"/> (already rotated) at <paramref name="location"/>.</summary>
public static NativeContours Of(ShapeProfile profile, Vector location)
{
var perimeter = World(profile.Perimeter, location);
var holes = profile.Cutouts.Select(cutout => World(cutout, location)).ToList();
return new NativeContours
{
perimeter = perimeter,
holes = holes,
perimeterLines = perimeter.ToLines(),
holeLines = holes.Select(hole => hole.ToLines()).ToList(),
Bounds = perimeter.BoundingBox,
};
}
/// <summary>
/// True when the two parts are closer than <paramref name="spacing"/> less
/// <see cref="SpacingOutline.SpacingSlack"/>, or share material: a boundary within that
/// gap, or one part wholly inside the other's material rather than in a cutout.
/// </summary>
public bool TooClose(NativeContours other, double spacing)
{
if (GapTo(other, spacing, out _) < spacing - SpacingOutline.SpacingSlack)
return true;
// No boundaries meet, so either part is wholly inside or outside the other's material.
return other.InMaterial(perimeter.Vertices[0]) || InMaterial(other.perimeter.Vertices[0]);
}
/// <summary>Closest boundary approach between two parts, including a part inside the other's
/// cutout; <paramref name="marker"/> is the middle of that gap. Only edges within
/// <paramref name="reach"/> of the other part's bounds can be closer than it, so the rest are
/// skipped. Infinity when no edges are that near.</summary>
public double GapTo(NativeContours other, double reach, out Vector marker)
{
var best = double.PositiveInfinity;
var middle = Bounds.Center;
var perimeterA = Near(perimeterLines, other.Bounds, reach);
var perimeterB = Near(other.perimeterLines, Bounds, reach);
Measure(perimeterA, perimeterB);
foreach (var hole in holeLines)
Measure(Near(hole, other.Bounds, reach), perimeterB);
foreach (var hole in other.holeLines)
Measure(perimeterA, Near(hole, Bounds, reach));
marker = middle;
return best;
void Measure(List<Line> ringA, List<Line> ringB)
{
if (ringA.Count == 0 || ringB.Count == 0)
return;
var distance = Clearance.ClosestPoints(ringA, ringB, out var onA, out var onB);
if (distance < best)
{
best = distance;
middle = (onA + onB) * 0.5;
}
}
}
private bool InMaterial(Vector point) =>
perimeter.ContainsPoint(point) && !holes.Any(hole => hole.ContainsPoint(point));
private static List<Line> Near(List<Line> lines, Box other, double reach) =>
lines.Where(line =>
System.Math.Min(line.StartPoint.X, line.EndPoint.X) <= other.Right + reach
&& System.Math.Max(line.StartPoint.X, line.EndPoint.X) >= other.Left - reach
&& System.Math.Min(line.StartPoint.Y, line.EndPoint.Y) <= other.Top + reach
&& System.Math.Max(line.StartPoint.Y, line.EndPoint.Y) >= other.Bottom - reach).ToList();
private static Polygon World(Shape shape, Vector location)
{
var polygon = ClipperBridge.Flatten(shape, ChordTolerance, true);
polygon.Offset(location);
polygon.UpdateBounds();
return polygon;
}
}
}
+4 -21
View File
@@ -519,33 +519,16 @@ namespace OpenNest.Geometry
get { return EntityType.Polygon; }
}
/// <summary>
/// Drops each vertex equal to the one before it in the original sequence. Compacts in
/// place in one pass: removing duplicates one at a time from the middle of the list was
/// O(duplicates x vertices), which dominated loading large drawings whose arcs are
/// flattened to many points.
/// </summary>
internal void Cleanup()
{
if (Vertices.Count < 2)
return;
// Compare against the original predecessor, not the last kept vertex, so a run of
// tolerance-equal points collapses exactly as the old back-to-front RemoveAt loop did.
var previous = Vertices[0];
var kept = 1;
for (int i = 1; i < Vertices.Count; i++)
for (int i = Vertices.Count - 1; i > 0; i--)
{
var vertex = Vertices[i];
var nextVertex = Vertices[i - 1];
if (vertex != previous)
Vertices[kept++] = vertex;
previous = vertex;
if (vertex == nextVertex)
Vertices.RemoveAt(i);
}
Vertices.RemoveRange(kept, Vertices.Count - kept);
}
public BoundingRectangleResult FindBestRotation()
+6 -9
View File
@@ -47,16 +47,14 @@ namespace OpenNest.Geometry
return new BoundingRectangleResult(angle, length, 0);
}
// Every edge is a candidate: opposite edges only give the same rectangle
// when the hull is centrally symmetric. For those hulls (rectangles,
// best-fit pairs) the second half repeats the first turned 180°, so a
// second-half edge wins only when smaller beyond rounding; that keeps
// their orientation from flipping on floating-point noise.
var half = (n + 1) / 2;
// Only need half the edges — opposite edges produce the same
// bounding rectangle (width/height swapped), so the minimum
// area repeats every 180°.
var edgeCount = (n + 1) / 2;
BoundingRectangleResult best = null;
for (int i = 0; i < n; i++)
for (int i = 0; i < edgeCount; i++)
{
int next = (i + 1) % n;
var edgeX = vertices[next].X - vertices[i].X;
@@ -96,9 +94,8 @@ namespace OpenNest.Geometry
var width = maxU - minU;
var height = maxV - minV;
var area = width * height;
var margin = i < half || best == null ? 0 : Tolerance.Epsilon * best.Area;
if (best == null || area < best.Area - margin)
if (best == null || area < best.Area)
{
var edgeAngle = System.Math.Atan2(edgeY, edgeX);
best = new BoundingRectangleResult(edgeAngle, width, height);
+3 -17
View File
@@ -264,24 +264,10 @@ namespace OpenNest.Geometry
}
/// <summary>
/// Maximum chord error, in drawing units, when <see cref="ToPolygon()"/> flattens arcs
/// and circles. Ten times tighter than the spacing/overlap checks' 0.001 because callers
/// use this polygon for area, and an inscribed polygon always reads low.
/// Converts the shape to a polygon.
/// </summary>
public const double DefaultChordTolerance = 0.0001;
/// <summary>
/// Converts the shape to a polygon, flattening arcs to within
/// <see cref="DefaultChordTolerance"/>. A fixed segment count per arc (formerly 1000)
/// made large arcs no more accurate and small ones needlessly dense: arc-heavy drawings
/// produced hundreds of thousands of vertices every time their area was computed.
/// </summary>
public Polygon ToPolygon() => ToPolygonWithTolerance(DefaultChordTolerance);
/// <summary>
/// Converts the shape to a polygon with a fixed number of segments per arc and circle.
/// </summary>
public Polygon ToPolygon(int arcSegments)
/// <returns></returns>
public Polygon ToPolygon(int arcSegments = 1000)
{
var polygon = new Polygon();
+2 -7
View File
@@ -36,11 +36,6 @@ namespace OpenNest.Geometry
// tangential classification can admit, so keep it far below spacing tolerances.
internal const double IncidenceTolerance = 1e-7;
// Drawing data fixes arc centers to finite precision, so a tangent joint's arc end
// can sit a little past its neighbour's end. A contact this close to a joint takes
// the joint's tangents; otherwise the neighbour reads as a second boundary there.
internal const double JointTolerance = 1e-6;
private readonly List<Entity> entities;
private readonly int[] loopOf;
private readonly int[] previous;
@@ -259,11 +254,11 @@ namespace OpenNest.Geometry
private bool StartsNear(int index, Vector point) =>
TryEndpoints(entities[index], out var start, out _)
&& start.DistanceTo(point) <= JointTolerance;
&& start.DistanceTo(point) <= IncidenceTolerance;
private bool EndsNear(int index, Vector point) =>
TryEndpoints(entities[index], out _, out var end)
&& end.DistanceTo(point) <= JointTolerance;
&& end.DistanceTo(point) <= IncidenceTolerance;
private bool InRun(int index, int first, int last)
{
+2 -2
View File
@@ -64,8 +64,8 @@ namespace OpenNest.Geometry
public static SpacingOutline Build(ShapeProfile profile, Vector location, double inflateBy)
{
ArgumentNullException.ThrowIfNull(profile);
// Flatten by chord tolerance, not a fixed segment count: arc-heavy real parts
// otherwise produce thousands of vertices.
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000 segments per
// arc): arc-heavy real parts otherwise produce thousands of vertices.
var region = ClipperBridge.OffsetForValidation(
profile,
inflateBy > Tolerance.Epsilon ? inflateBy : 0,
-19
View File
@@ -1,19 +0,0 @@
namespace OpenNest
{
/// <summary>
/// A configurable post whose settings travel as JSON, so a host can keep them somewhere other
/// than the post's own file (the nest server's shared copy, for one).
/// </summary>
public interface IJsonConfigPostProcessor : IConfigurablePostProcessor
{
/// <summary>The settings as the post's own file holds them.</summary>
string SerializeConfig();
/// <summary>
/// Replaces the settings with <paramref name="json"/>, in the post's file format; the
/// file itself is not written. Throws <see cref="System.Text.Json.JsonException"/> and
/// keeps the current settings when the JSON is not usable.
/// </summary>
void LoadConfig(string json);
}
}
+2 -80
View File
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using OpenNest.Collections;
using OpenNest.Geometry;
using OpenNest.Support;
namespace OpenNest
{
@@ -19,7 +18,6 @@ namespace OpenNest
Name = name;
Plates = new ObservableList<Plate>();
Plates.ItemRemoved += Plates_PlateRemoved;
Plates.ItemAdded += Plates_PlateAdded;
Drawings = new DrawingCollection();
PlateDefaults = new PlateSettings();
Material = new Material();
@@ -32,13 +30,6 @@ namespace OpenNest
e.Item.Parts.Clear();
}
// Every sheet of the nest rests on the same machine bed.
private void Plates_PlateAdded(object sender, ItemAddedEventArgs<Plate> e)
{
if (e.Item != null)
e.Item.SupportBed = SupportBed;
}
public string Name { get; set; }
public string Customer { get; set; }
@@ -51,57 +42,12 @@ namespace OpenNest
public string MadeBy { get; set; } = "";
/// <summary>The stock thickness. A different value clears every plate's <see cref="Plate.StockItemCodes"/>.</summary>
public double Thickness
{
get { return thickness; }
set
{
if (value != thickness)
ClearStockItemCodes();
thickness = value;
}
}
public double Thickness { get; set; }
private double thickness;
/// <summary>
/// The stock material. A different name or grade (ignoring case and spacing) clears every
/// plate's <see cref="Plate.StockItemCodes"/>: the coded items are another material.
/// </summary>
public Material Material
{
get { return material; }
set
{
if (!SameStock(material, value))
ClearStockItemCodes();
material = value;
}
}
private Material material;
private static bool SameStock(Material a, Material b) =>
NameKey(a?.Name) == NameKey(b?.Name) && NameKey(a?.Grade) == NameKey(b?.Grade);
private static string NameKey(string name) =>
name == null ? "" : string.Join(' ', name.Split((char[])null, StringSplitOptions.RemoveEmptyEntries)).ToUpperInvariant();
private void ClearStockItemCodes()
{
// Plates is a public field a caller may have replaced.
if (Plates == null)
return;
foreach (var plate in Plates)
plate.StockItemCodes = Array.Empty<string>();
}
public Material Material { get; set; }
public Units Units { get; set; }
/// <summary>The machine this nest is cut on, or null when none is assigned.</summary>
public NestMachine Machine { get; set; }
public DateTime DateCreated { get; set; }
public DateTime DateLastModified { get; set; }
@@ -112,16 +58,6 @@ namespace OpenNest
public double SalvageRate { get; set; } = 0.5;
/// <summary>
/// The smallest side, in model units, of an offcut credited as salvage or reported as the
/// plate's expected remnant (<see cref="PlateRemnant.Expected"/>). Auto Nest records the
/// minimum it last used.
/// </summary>
public double MinimumRemnantDimension { get; set; } = DefaultMinimumRemnantDimension;
/// <summary>Auto Nest's default minimum remnant side.</summary>
public const double DefaultMinimumRemnantDimension = 12.0;
public Plate CreatePlate()
{
var plate = PlateDefaults.CreateNew();
@@ -144,20 +80,6 @@ namespace OpenNest
plate.SetClampRail(rail);
}
/// <summary>
/// The machine support bed every sheet of this nest rests on, or null for none. Set
/// through <see cref="SetSupportBed"/>; plates added later follow it too.
/// </summary>
public SupportBed SupportBed { get; private set; }
/// <summary>Makes <paramref name="bed"/> this nest's support bed and every plate's.</summary>
public void SetSupportBed(SupportBed bed)
{
SupportBed = bed;
foreach (var plate in Plates)
plate.SupportBed = bed;
}
public void UpdateDrawingQuantities()
{
foreach (var drawing in Drawings)
-21
View File
@@ -1,21 +0,0 @@
using System;
namespace OpenNest
{
/// <summary>
/// The machine a nest is cut on: the id of its machine configuration plus the name it had,
/// so a nest still says which machine it was made for on a PC without that configuration.
/// </summary>
public sealed class NestMachine
{
public NestMachine(Guid id, string name)
{
Id = id;
Name = name ?? string.Empty;
}
public Guid Id { get; }
public string Name { get; }
}
}
-33
View File
@@ -4,43 +4,10 @@ namespace OpenNest
/// Shop-floor workflow state of a saved nest, persisted as a string in
/// <c>nest.json</c> so unknown future values can fall back safely.
/// </summary>
/// <remarks>
/// New values go at the end: the nest server still accepts integer statuses from
/// existing callers, so existing values must keep their numbers. Lists shown to the
/// operator use <see cref="NestStatuses.WorkflowOrder"/>, not declaration order.
/// </remarks>
public enum NestStatus
{
Quote,
ToBeCut,
HasBeenCut,
/// <summary>
/// Created but not yet ready to quote or cut, such as a nest released from a
/// drawing set before it has been nested and checked. Comes before Quote.
/// </summary>
Draft,
}
public static class NestStatuses
{
/// <summary>Every status in the order a nest moves through them.</summary>
public static readonly NestStatus[] WorkflowOrder =
{
NestStatus.Draft,
NestStatus.Quote,
NestStatus.ToBeCut,
NestStatus.HasBeenCut,
};
/// <summary>The name shown to the operator, such as "To Be Cut".</summary>
public static string DisplayName(NestStatus status) => status switch
{
NestStatus.Draft => "Draft",
NestStatus.Quote => "Quote",
NestStatus.ToBeCut => "To Be Cut",
NestStatus.HasBeenCut => "Has Been Cut",
_ => status.ToString(),
};
}
}
-19
View File
@@ -45,15 +45,6 @@ namespace OpenNest
UpdateBounds();
}
/// <summary>A placement of one owned program of the drawing, such as one cut-off segment.</summary>
internal Part(Drawing baseDrawing, Program program)
{
BaseDrawing = baseDrawing;
Program = program;
ownsProgram = true;
UpdateBounds();
}
/// <summary>
/// Location of the part.
/// </summary>
@@ -186,16 +177,6 @@ namespace OpenNest
BoundingBox = bounds;
}
/// <summary>Installs an owned program for a cut-off segment, cut the other way; nothing else changes.</summary>
internal void ReplaceCutOffProgram(Program program)
{
var bounds = program.BoundingBox();
bounds.Offset(Location);
Program = program;
ownsProgram = true;
BoundingBox = bounds;
}
public void RemoveLeadIns()
{
var rotation = preLeadInRotation;
+30 -140
View File
@@ -6,7 +6,6 @@ using OpenNest.Collections;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Shapes;
using OpenNest.Support;
namespace OpenNest
{
@@ -95,9 +94,6 @@ namespace OpenNest
get { return size; }
set
{
// A turned sheet is the same sheet; any other size is no longer the coded item.
if (stockItemCodes.Count > 0 && !SameSheet(size, value))
stockItemCodes = Array.Empty<string>();
size = value;
ResolveClampRail();
}
@@ -105,30 +101,6 @@ namespace OpenNest
private Size size;
private static bool SameSheet(Size a, Size b) =>
a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
/// <summary>
/// The inventory item each physical sheet of this plate is cut from: entry i names sheet
/// i of <see cref="Quantity"/>, and sheets past the end are uncoded. Empty when no sheet
/// is coded. A resize (other than turning the sheet) clears it, lowering
/// <see cref="Quantity"/> truncates it, and raising it leaves the extra sheets uncoded.
/// Display and hand-off data only: quantity accounting never reads it.
/// </summary>
public IReadOnlyList<string> StockItemCodes
{
get { return stockItemCodes; }
set
{
ArgumentNullException.ThrowIfNull(value);
if (value.Any(string.IsNullOrWhiteSpace))
throw new ArgumentException("Stock item codes must not be blank.", nameof(value));
stockItemCodes = value.Take(System.Math.Max(quantity, 0)).ToArray();
}
}
private IReadOnlyList<string> stockItemCodes = Array.Empty<string>();
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
/// <summary>
@@ -158,7 +130,7 @@ namespace OpenNest
/// Raised after <see cref="ClampZones"/> content changes so views can refresh
/// the zone overlay. Carries no geometry arguments.
/// </summary>
public event EventHandler ClampZonesChanged;
public event EventHandler? ClampZonesChanged;
private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty);
@@ -170,13 +142,6 @@ namespace OpenNest
/// </summary>
public ClampRail ClampRail { get; private set; }
/// <summary>
/// The machine support bed this sheet rests on (its nest's, see
/// <see cref="Nest.SetSupportBed"/>), or null for none. Display, analysis and support
/// tabs only: never material, spacing or a keep-out.
/// </summary>
public SupportBed SupportBed { get; set; }
private static readonly IReadOnlySet<string> NoRemovedClamps = ImmutableHashSet<string>.Empty;
/// <summary>
@@ -293,16 +258,17 @@ namespace OpenNest
}
/// <summary>
/// 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 (<see cref="Parts"/> 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.
/// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is
/// regenerated and put back at the same place in the cut sequence
/// (<see cref="Parts"/> order). New cut-offs are added at the end.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings)
{
var saved = new Dictionary<CutOff, List<(int Index, double Start, double End)>>();
// Remember each cut-off's place in the cut sequence, so a part drag
// or cut-off move doesn't send it to the end of the sequence.
var sequence = new Dictionary<CutOff, int>();
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (!Parts[i].BaseDrawing.IsCutOff)
@@ -310,108 +276,44 @@ namespace OpenNest
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));
}
sequence[cutoff] = i;
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;
});
RegenerateCutOffs(settings, sequence);
}
/// <summary>
/// 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
/// <paramref name="sequence"/> (its place in <see cref="Parts"/> order). Cut-offs missing
/// from it are added at the end. Callers must remove existing cut-off parts first.
/// Regenerates all cut-off drawings and materializes them as parts, placing
/// each cut-off at its index in <paramref name="sequence"/> (its place in
/// <see cref="Parts"/> order). Cut-offs missing from it are added at the end.
/// Callers must remove existing cut-off parts first.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence) =>
RegenerateCutOffs(settings, (cutoff, extents) =>
sequence != null && sequence.TryGetValue(cutoff, out var index)
? Enumerable.Range(index, extents.Count).ToArray() : null);
/// <summary>
/// As <see cref="RegenerateCutOffs(CutOffSettings, IReadOnlyDictionary{CutOff, int})"/>,
/// 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 from its smallest index.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> sequence) =>
RegenerateCutOffs(settings, (cutoff, extents) =>
{
if (sequence == null || !sequence.TryGetValue(cutoff, out var indices) || indices == null || indices.Count == 0)
return null;
if (indices.Count != extents.Count)
return Enumerable.Range(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;
});
// sequence gives a cut-off's segment indices in program order from its segments' extents
// along its axis (NaN when unmeasurable), or null to append them.
private void RegenerateCutOffs(CutOffSettings settings,
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence)
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence)
{
var cache = BuildPerimeterCache(this);
var placed = new List<(int Index, int Order, Part Part)>();
var placed = new List<(int Index, Part Part)>();
// Regenerate and materialize each cut-off's segments
// Regenerate and materialize each cut-off
foreach (var cutoff in CutOffs)
{
cutoff.Regenerate(this, settings, cache);
var programs = cutoff.SegmentPrograms();
if (programs.Count == 0)
if (cutoff.Drawing.Program.Codes.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);
for (var k = 0; k < parts.Length; k++)
{
if (indices != null && k < indices.Count)
placed.Add((indices[k], placed.Count, parts[k]));
else
Parts.Add(parts[k]);
}
var part = new Part(cutoff.Drawing);
if (sequence != null && sequence.TryGetValue(cutoff, out var index))
placed.Add((index, part));
else
Parts.Add(part);
}
// Lowest index first: each insert then lands on its saved index, because every part
// sequenced before it is already in place. Segments sharing an index keep their
// program order (a later insert at the same index lands before an earlier one).
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenByDescending(p => p.Order))
// Lowest index first: each insert then lands on its saved index, because
// every part sequenced before it is already in place.
foreach (var (index, part) in placed.OrderBy(p => p.Index))
Parts.Insert(System.Math.Clamp(index, 0, Parts.Count), part);
}
@@ -484,21 +386,9 @@ namespace OpenNest
}
/// <summary>
/// The number of times to cut the plate. Lowering it drops the
/// <see cref="StockItemCodes"/> of the sheets no longer cut.
/// The number of times to cut the plate.
/// </summary>
public int Quantity
{
get { return quantity; }
set
{
quantity = value;
if (stockItemCodes.Count > System.Math.Max(value, 0))
stockItemCodes = stockItemCodes.Take(System.Math.Max(value, 0)).ToArray();
}
}
private int quantity;
public int Quantity { get; set; }
/// <summary>
/// The quadrant the plate is located in.
-1
View File
@@ -236,7 +236,6 @@ namespace OpenNest
remaining.CutOffs.Clear();
remaining.Parts.Clear();
remaining.Quantity = 1;
remaining.StockItemCodes = Array.Empty<string>();
}
finally
{
-6
View File
@@ -6,12 +6,6 @@ namespace OpenNest
public double Length { get; set; }
public double Cost { get; set; }
/// <summary>Sheets on hand for this request; null offers the size without limit. Not saved with the nest.</summary>
public int? Quantity { get; set; }
/// <summary>The engine stock id for this request; null uses the option's position. Not saved with the nest.</summary>
public string Id { get; set; }
public double Area => Width * Length;
}
}
-169
View File
@@ -1,169 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest;
/// <summary>Where an expected remnant comes from.</summary>
public enum PlateRemnantSource
{
/// <summary>Material a full-span cut-off cuts free of the parts.</summary>
CutOff,
/// <summary>The largest full-width or full-length edge strip outside the parts, as salvage measures it.</summary>
Offcut,
}
/// <summary>
/// The rectangular drop a plate is expected to leave, in plate coordinates (the quadrant frame
/// of its parts and cut-offs). Derived from the current layout; never stored on the plate.
/// </summary>
public sealed class PlateRemnant
{
private PlateRemnant(Box box, PlateRemnantSource source)
{
Box = box;
Source = source;
}
public Box Box { get; }
public PlateRemnantSource Source { get; }
/// <summary>
/// The drop <paramref name="plate"/> is expected to leave, or null when it has no parts or no
/// rectangle has both sides at least <paramref name="minimumDimension"/>. A full-span cut-off
/// (no limit short of either sheet edge) with no part material beyond it defines the drop: the
/// sheet past its nominal position, less the plate's kerf for each bounding cut-off line. It
/// wins over a larger edge offcut. Otherwise the drop is the largest edge offcut that
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks.
/// </summary>
public static PlateRemnant Expected(Plate plate, double minimumDimension)
{
// A part with no material (marks only) bounds nothing.
var bounds = plate.Parts
.Where(p => !p.BaseDrawing.IsCutOff && p.Program != null)
.Select(p => ConvertProgram.ToGeometry(p.Program).Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()
is { Count: > 0 } material
? material.GetBoundingBox().Translate(p.Location)
: null)
.Where(b => b != null)
.ToList();
if (bounds.Count == 0)
return null;
var minimum = System.Math.Max(0, minimumDimension);
var cutOff = CutOffDrop(plate, bounds, minimum);
if (cutOff != null)
return new PlateRemnant(cutOff, PlateRemnantSource.CutOff);
var offcut = LargestOffcut(plate.WorkArea(), plate.PartSpacing,
bounds.Min(b => b.Left), bounds.Min(b => b.Bottom), bounds.Max(b => b.Right), bounds.Max(b => b.Top),
minimum);
return offcut == null ? null : new PlateRemnant(offcut, PlateRemnantSource.Offcut);
}
/// <summary>
/// The largest full-width/full-length edge strip of <paramref name="workArea"/> clear of the
/// material envelope (<paramref name="left"/> to <paramref name="top"/>) by
/// <paramref name="partSpacing"/>, with both sides at least <paramref name="minimum"/>; null
/// when none qualifies. Ties keep the first of bottom, top, left, right. This is the salvage
/// offcut whole-job costing credits.
/// </summary>
public static Box LargestOffcut(Box workArea, double partSpacing,
double left, double bottom, double right, double top, double minimum)
{
var work = workArea;
var gap = partSpacing;
var candidates = new[]
{
new Box(work.Left, work.Bottom, work.Length, bottom - work.Bottom - gap),
new Box(work.Left, top + gap, work.Length, work.Top - top - gap),
new Box(work.Left, work.Bottom, left - work.Left - gap, work.Width),
new Box(right + gap, work.Bottom, work.Right - right - gap, work.Width),
};
Box best = null;
foreach (var candidate in candidates)
{
if (candidate.Length >= minimum && candidate.Width >= minimum
&& (best == null || candidate.Length * candidate.Width > best.Length * best.Width))
best = candidate;
}
return best;
}
/// <summary>Analytic world-space material bounds; surface marks never bound material.</summary>
public static Box MaterialBounds(Part part) =>
ConvertProgram.ToGeometry(part.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.GetBoundingBox()
.Translate(part.Location);
/// <summary>
/// The largest piece between full-span cut-off lines (or a line and the sheet edge) that no
/// part material reaches into, with both sides at least <paramref name="minimum"/>.
/// </summary>
private static Box CutOffDrop(Plate plate, IReadOnlyList<Box> material, double minimum)
{
var sheet = plate.BoundingBox(includeParts: false);
var kerf = plate.CuttingParameters?.Kerf is double k && double.IsFinite(k) && k > 0 ? k : 0;
Box best = null;
foreach (var vertical in new[] { true, false })
{
var low = vertical ? sheet.Left : sheet.Bottom;
var high = vertical ? sheet.Right : sheet.Top;
var lines = plate.CutOffs
.Where(c => (c.Axis == CutOffAxis.Vertical) == vertical && SpansSheet(c, sheet))
.Select(c => vertical ? c.Position.X : c.Position.Y)
.Where(at => at > low + Tolerance.Epsilon && at < high - Tolerance.Epsilon)
.Distinct()
.OrderBy(at => at)
.ToList();
if (lines.Count == 0)
continue;
// Each piece runs from one boundary to the next; at least one is a cut-off line.
var boundaries = new List<(double At, bool Cut)> { (low, false) };
boundaries.AddRange(lines.Select(at => (at, true)));
boundaries.Add((high, false));
for (var i = 0; i + 1 < boundaries.Count; i++)
{
var (from, fromCut) = boundaries[i];
var (to, toCut) = boundaries[i + 1];
if (material.Any(b => Reaches(b, vertical, from, to)))
continue;
var start = fromCut ? from + kerf : from;
var end = toCut ? to - kerf : to;
var piece = vertical
? new Box(start, sheet.Bottom, end - start, sheet.Width)
: new Box(sheet.Left, start, sheet.Length, end - start);
if (piece.Length >= minimum && piece.Width >= minimum
&& (best == null || piece.Length * piece.Width > best.Length * best.Width))
best = piece;
}
}
return best;
}
/// <summary>
/// Whether the nominal line runs edge to edge. A head-locked overcut only extends the
/// program past the far edge, so the nominal limits decide.
/// </summary>
private static bool SpansSheet(CutOff cutOff, Box sheet)
{
var vertical = cutOff.Axis == CutOffAxis.Vertical;
var alongMin = vertical ? sheet.Bottom : sheet.Left;
var alongMax = vertical ? sheet.Top : sheet.Right;
return (cutOff.StartLimit is not double start || start <= alongMin + Tolerance.Epsilon)
&& (cutOff.EndLimit is not double end || end >= alongMax - Tolerance.Epsilon);
}
private static bool Reaches(Box material, bool vertical, double from, double to)
{
var low = vertical ? material.Left : material.Bottom;
var high = vertical ? material.Right : material.Top;
return high > from + Tolerance.Epsilon && low < to - Tolerance.Epsilon;
}
}
@@ -35,16 +35,6 @@ namespace OpenNest.PostSettings
/// <summary>Column header for the value of a name/value table.</summary>
public string ValueHeader { get; set; }
/// <summary>
/// For a text field: the record table property whose column, named by
/// <see cref="SuggestionsColumn"/>, lists the values offered in a drop-down.
/// The field still accepts any text.
/// </summary>
public string SuggestionsTable { get; set; }
/// <summary>The column of <see cref="SuggestionsTable"/> offered as suggestions.</summary>
public string SuggestionsColumn { get; set; }
}
/// <summary>Declares a settings section's order and description.</summary>
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Reflection;
@@ -15,29 +14,6 @@ namespace OpenNest.PostSettings
Boolean,
Choice,
StringMap,
Table,
}
/// <summary>A column of a <see cref="PostSettingKind.Table"/> field: one property of its row type.</summary>
public sealed class PostSettingsColumn
{
internal PostSettingsColumn(PropertyInfo property, PostSettingKind kind)
{
Property = property;
Kind = kind;
Label =
property.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName
?? property.Name;
}
public PropertyInfo Property { get; }
public string Name => Property.Name;
/// <summary>Text, Integer or Decimal.</summary>
public PostSettingKind Kind { get; }
public string Label { get; }
}
public sealed class PostSettingsField
@@ -45,17 +21,11 @@ namespace OpenNest.PostSettings
internal PostSettingsField(
PropertyInfo property,
PostSettingKind kind,
bool isOptional,
IReadOnlyList<PostSettingsColumn> columns,
PostSettingAttribute setting
)
{
Property = property;
Kind = kind;
IsOptional = isOptional;
Columns = columns ?? Array.Empty<PostSettingsColumn>();
SuggestionsTable = setting?.SuggestionsTable;
SuggestionsColumn = setting?.SuggestionsColumn;
Label =
property.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName
?? property.Name;
@@ -78,17 +48,6 @@ namespace OpenNest.PostSettings
public PostSettingKind Kind { get; }
/// <summary>A nullable number: the editor can leave it unset (null).</summary>
public bool IsOptional { get; }
/// <summary>The row type's columns for a <see cref="PostSettingKind.Table"/> field.</summary>
public IReadOnlyList<PostSettingsColumn> Columns { get; }
/// <summary>Table field whose <see cref="SuggestionsColumn"/> a text field offers; null for none.</summary>
public string SuggestionsTable { get; }
public string SuggestionsColumn { get; }
public string Label { get; }
public string Description { get; }
@@ -174,7 +133,7 @@ namespace OpenNest.PostSettings
for (var i = 0; i < properties.Count; i++)
{
var property = properties[i];
var kind = KindOf(property.PropertyType, out var isOptional, out var columns);
var kind = KindOf(property.PropertyType);
if (kind == null)
return null;
@@ -187,7 +146,7 @@ namespace OpenNest.PostSettings
section,
setting?.Order ?? int.MaxValue,
i,
new PostSettingsField(property, kind.Value, isOptional, columns, setting)
new PostSettingsField(property, kind.Value, setting)
)
);
}
@@ -248,126 +207,6 @@ namespace OpenNest.PostSettings
return map;
}
/// <summary>
/// Builds a <see cref="PostSettingKind.Table"/> field's list from edited
/// rows of cell text, one entry per column, in row order. Cells are
/// trimmed and numbers parsed with <paramref name="culture"/>; fully blank
/// rows are skipped. Throws <see cref="FormatException"/> naming the
/// 1-based row and column for a missing or malformed number.
/// </summary>
public static object BuildList(
PostSettingsField field,
IEnumerable<IReadOnlyList<string>> rows,
IFormatProvider culture
)
{
if (field == null || field.Kind != PostSettingKind.Table)
throw new ArgumentException("A table field is required.", nameof(field));
var list = (System.Collections.IList)Activator.CreateInstance(field.Property.PropertyType);
var rowType = ElementType(field.Property.PropertyType);
var row = 0;
foreach (var cells in rows ?? Enumerable.Empty<IReadOnlyList<string>>())
{
row++;
var text = field
.Columns.Select((_, c) => (cells != null && c < cells.Count ? cells[c] : null)?.Trim() ?? "")
.ToList();
if (text.All(t => t.Length == 0))
continue;
var item = Activator.CreateInstance(rowType);
for (var c = 0; c < field.Columns.Count; c++)
{
var column = field.Columns[c];
column.Property.SetValue(item, ParseCell(column, text[c], row, culture));
}
list.Add(item);
}
return list;
}
private static object ParseCell(PostSettingsColumn column, string text, int row, IFormatProvider culture)
{
if (column.Kind == PostSettingKind.Text)
return text;
if (text.Length == 0)
throw new FormatException($"Row {row}: {column.Label} is required.");
if (column.Kind == PostSettingKind.Integer)
{
if (int.TryParse(text, NumberStyles.Integer, culture, out var whole))
return whole;
}
else if (double.TryParse(text, NumberStyles.Float, culture, out var number) && double.IsFinite(number))
{
return number;
}
throw new FormatException($"Row {row}: {column.Label} \"{text}\" is not a valid number.");
}
private static PostSettingKind? KindOf(
Type type,
out bool isOptional,
out IReadOnlyList<PostSettingsColumn> columns
)
{
isOptional = false;
columns = null;
var underlying = Nullable.GetUnderlyingType(type);
if (underlying == typeof(int) || underlying == typeof(double))
{
isOptional = true;
return KindOf(underlying);
}
var rowType = ElementType(type);
if (rowType != null)
{
columns = ColumnsOf(rowType);
return columns == null ? null : PostSettingKind.Table;
}
return KindOf(type);
}
private static Type ElementType(Type type) =>
type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)
? type.GetGenericArguments()[0]
: null;
/// <summary>
/// A row type's editable columns, or null when it is not a class with a
/// public parameterless constructor whose every public settable property
/// is text or a number.
/// </summary>
private static IReadOnlyList<PostSettingsColumn> ColumnsOf(Type rowType)
{
if (!rowType.IsClass || rowType == typeof(string) || rowType.GetConstructor(Type.EmptyTypes) == null)
return null;
var columns = new List<PostSettingsColumn>();
var properties = rowType
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetSetMethod() != null && p.GetIndexParameters().Length == 0)
.OrderBy(p => p.MetadataToken);
foreach (var property in properties)
{
var kind = KindOf(property.PropertyType);
if (kind is not (PostSettingKind.Text or PostSettingKind.Integer or PostSettingKind.Decimal))
return null;
columns.Add(new PostSettingsColumn(property, kind.Value));
}
return columns.Count == 0 ? null : columns;
}
private static PostSettingKind? KindOf(Type type)
{
if (type == typeof(string))
+1 -5
View File
@@ -24,16 +24,12 @@ 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. A display perimeter is material: it is the
/// supplied blank's edge.
/// material: they close a tabbed cutout's boundary.
/// </summary>
public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe && layer != Break;
}
-12
View File
@@ -1,12 +0,0 @@
namespace OpenNest.Support
{
/// <summary>The axis a <see cref="SupportSlats"/> bed's slats run along, the full length or width of the table.</summary>
public enum SlatAxis
{
/// <summary>Slats run along X, the table's full length; they repeat along Y.</summary>
X,
/// <summary>Slats run along Y, the table's full width; they repeat along X.</summary>
Y,
}
}
-531
View File
@@ -1,531 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Support
{
/// <summary>
/// Decides whether a cut part stays level on its support bed. Plate is of even thickness,
/// so the part's center of gravity is its area centroid; the part rests on the bed's
/// contacts under its material and stays level while that center lies inside their convex
/// hull. The margin is how far inside (positive) or outside (negative) it lies; a part with
/// fewer than three contacts that are not in line can only balance, so its margin is at
/// most zero. With a <see cref="SupportBed.RegistrationTolerance"/>, the bed is also
/// checked shifted by that much along each axis and diagonally, keeping the worst margin.
/// A perimeter tab holds the part at the tab, so a tab point counts as one more contact.
/// A tab never sits in an inside corner, where the operator cannot grind its nub flush.
/// </summary>
public static class SupportAnalyzer
{
/// <summary>Points sampled along the outline, besides its vertices, to find the best tab point.</summary>
public const int TabSamples = 256;
/// <summary>
/// The least the outline must turn inward, in radians, for an inside corner; a run of
/// inward-turning vertices (a chorded fillet) counts as one corner by its whole turn.
/// </summary>
public const double InsideCornerAngle = System.Math.PI / 6;
/// <summary>How far a tab of size <paramref name="tabSize"/> keeps from an inside corner: its uncut span and as much again.</summary>
public static double InsideCornerClearance(double tabSize) => tabSize > 0 ? 2 * tabSize : 0;
/// <param name="insideCornerClearance">
/// How far a tab point keeps from an inside corner; 0 keeps it off the corner only.
/// </param>
public static PartSupport Analyze(SupportBed bed, int quadrant, Part part, double insideCornerClearance = 0) =>
Analyze(bed, quadrant, SupportMaterial.FromPart(part), insideCornerClearance);
/// <param name="insideCornerClearance">
/// How far a tab point keeps from an inside corner; 0 keeps it off the corner only.
/// </param>
public static PartSupport Analyze(SupportBed bed, int quadrant, SupportMaterial material,
double insideCornerClearance = 0)
{
ArgumentNullException.ThrowIfNull(bed);
ArgumentNullException.ThrowIfNull(material);
if (!(insideCornerClearance >= 0) || double.IsInfinity(insideCornerClearance))
throw new ArgumentOutOfRangeException(nameof(insideCornerClearance));
if (!material.IsComplete)
return new PartSupport(material.Reason);
if (!material.TryGetCenterOfGravity(out var center, out _))
return new PartSupport("The part encloses no area.");
var hulls = Shifts(bed.RegistrationTolerance)
.Select(shift => Hull(bed.Contacts(material, quadrant, shift, extremesOnly: true)))
.ToArray();
var margin = hulls.Min(hull => Margin(center, hull));
var contacts = bed.Contacts(material, quadrant, Vector.Zero);
var corners = InsideCorners(material.Outline);
var best = BestTabPoint(material.Outline, center, corners, insideCornerClearance,
point => MarginWith(center, hulls, point), out var bestMargin);
return new PartSupport(center, contacts, margin, bed.StabilityMargin, hulls, best, bestMargin,
corners, insideCornerClearance);
}
/// <summary>
/// A part on a plate with no support bed, tabbed for its <see cref="Width"/> instead
/// (<see cref="CNC.CuttingStrategy.CuttingParameters.TabsWithoutBedAt"/>). With no contacts
/// to judge by, a tab anywhere clear of inside corners is accepted; the best tab point is
/// the clear outline point nearest the line through the center of gravity across the
/// outline's long side, then the one farthest from an inside corner.
/// </summary>
/// <param name="insideCornerClearance">
/// How far a tab point keeps from an inside corner; 0 keeps it off the corner only.
/// </param>
public static PartSupport AnalyzeWithoutBed(SupportMaterial material, double insideCornerClearance = 0)
{
ArgumentNullException.ThrowIfNull(material);
if (!(insideCornerClearance >= 0) || double.IsInfinity(insideCornerClearance))
throw new ArgumentOutOfRangeException(nameof(insideCornerClearance));
if (!material.IsComplete)
return new PartSupport(material.Reason);
if (!material.TryGetCenterOfGravity(out var center, out _))
return new PartSupport("The part encloses no area.");
var corners = InsideCorners(material.Outline);
var best = BestTabPoint(material.Outline, center, corners, insideCornerClearance,
_ => double.PositiveInfinity, out _);
return new PartSupport(center, best, corners, insideCornerClearance, Width(material));
}
/// <summary>
/// The part's width: the short side of its outline's minimum-area bounding rectangle, or
/// NaN when its material could not be read.
/// </summary>
public static double Width(SupportMaterial material)
{
ArgumentNullException.ThrowIfNull(material);
if (!material.IsComplete || material.Outline.Count < 3)
return double.NaN;
var box = BoundingRectangle(material.Outline);
return System.Math.Min(box.Width, box.Height);
}
/// <summary>
/// The part's length: the long side of its outline's minimum-area bounding rectangle, or
/// NaN when its material could not be read.
/// </summary>
public static double Length(SupportMaterial material)
{
ArgumentNullException.ThrowIfNull(material);
if (!material.IsComplete || material.Outline.Count < 3)
return double.NaN;
var box = BoundingRectangle(material.Outline);
return System.Math.Max(box.Width, box.Height);
}
/// <summary>
/// <paramref name="support"/>, a bed analysis of a part the bed cannot hold level, for a
/// part short enough for its tab alone to hold
/// (<see cref="CNC.CuttingStrategy.CuttingParameters.TabHoldsAt"/>): its contacts and
/// margin are kept, any tab clear of inside corners steadies it, and the best tab point is
/// chosen as without a bed (<see cref="AnalyzeWithoutBed"/>).
/// </summary>
public static PartSupport HeldByTab(PartSupport support, SupportMaterial material)
{
ArgumentNullException.ThrowIfNull(support);
ArgumentNullException.ThrowIfNull(material);
if (!support.IsComplete || support.IsSupported || !support.HasBed)
return support;
var best = BestTabPoint(material.Outline, support.CenterOfGravity, support.InsideCorners,
support.InsideCornerClearance, _ => double.PositiveInfinity, out _);
return support.WithTabHold(best, Length(material));
}
// The outline point a tab steadies the part most from, by `score`, clear of every inside
// corner where the outline allows it. A tab cannot lift the margin past the contacts' own
// hull edges, so a whole stretch of outline often ties; of those, the one nearest the line
// through the center across the outline's long side, then the one farthest from an inside
// corner, or the first.
private static Vector? BestTabPoint(IReadOnlyList<Vector> outline, Vector center, IReadOnlyList<Vector> corners,
double insideCornerClearance, Func<Vector, double> score, out double bestMargin)
{
var samples = OutlineSamples(outline)
.Select(point => (Point: point, Clear: Clearance(corners, point)))
.ToList();
var allowed = samples.Where(s => IsClear(s.Clear, insideCornerClearance)).ToList();
if (allowed.Count > 0)
samples = allowed;
var scored = samples.Select(s => (s.Point, s.Clear, Margin: score(s.Point))).ToList();
bestMargin = scored.Count == 0 ? double.NegativeInfinity : scored.Max(s => s.Margin);
var along = scored.Count == 0 ? Vector.Zero : LongSide(outline);
Vector? best = null;
var bestOffset = double.PositiveInfinity;
var bestClear = double.NegativeInfinity;
foreach (var sample in scored)
{
if (sample.Margin < bestMargin - Math.Tolerance.Epsilon)
continue;
var offset = System.Math.Abs((sample.Point - center).DotProduct(along));
if (offset < bestOffset - Math.Tolerance.Epsilon
|| (offset <= bestOffset + Math.Tolerance.Epsilon && sample.Clear > bestClear))
{
best = sample.Point;
bestOffset = offset;
bestClear = sample.Clear;
}
}
return best;
}
/// <summary>The unit direction of the longer side of the outline's minimum-area bounding rectangle.</summary>
private static Vector LongSide(IReadOnlyList<Vector> outline)
{
var box = BoundingRectangle(outline);
var angle = box.Width >= box.Height ? box.Angle : box.Angle + Math.Angle.HalfPI;
return new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
}
// The full angle range checks every hull edge.
private static BoundingRectangleResult BoundingRectangle(IReadOnlyList<Vector> outline) =>
RotatingCalipers.MinimumBoundingRectangle(ConvexHull.Compute(outline.ToList()), 0, Math.Angle.TwoPI);
// A point `clear` from the nearest inside corner keeps `clearance` from it; with no
// clearance it must still not be the corner.
internal static bool IsClear(double clear, double clearance) =>
clearance > 0 ? clear >= clearance : clear > Math.Tolerance.Epsilon;
internal static double Clearance(IReadOnlyList<Vector> corners, Vector point)
{
var clear = double.PositiveInfinity;
foreach (var corner in corners)
clear = System.Math.Min(clear, corner.DistanceTo(point));
return clear;
}
/// <summary>
/// The outline vertices that lie in an inside corner: each run of consecutive vertices
/// turning inward whose turns add up to at least <see cref="InsideCornerAngle"/>.
/// </summary>
internal static Vector[] InsideCorners(IReadOnlyList<Vector> outline)
{
var count = outline.Count;
if (count < 3)
return Array.Empty<Vector>();
var area = 0.0;
for (var i = 0; i < count; i++)
{
var a = outline[i];
var b = outline[(i + 1) % count];
area += a.X * b.Y - b.X * a.Y;
}
if (area == 0)
return Array.Empty<Vector>();
// Signed turn at each vertex: negative turns inward, whatever the outline's winding.
var turns = new double[count];
for (var i = 0; i < count; i++)
{
var into = outline[i] - outline[(i + count - 1) % count];
var outOf = outline[(i + 1) % count] - outline[i];
var cross = into.X * outOf.Y - into.Y * outOf.X;
var dot = into.X * outOf.X + into.Y * outOf.Y;
turns[i] = System.Math.Atan2(cross, dot) * System.Math.Sign(area);
}
// Start after a vertex that does not turn inward so no run is split by the wrap.
var start = Array.FindIndex(turns, turn => turn >= 0);
if (start < 0)
return Array.Empty<Vector>();
var corners = new List<Vector>();
var run = new List<Vector>();
var turned = 0.0;
for (var step = 1; step <= count; step++)
{
var i = (start + step) % count;
if (turns[i] < 0)
{
run.Add(outline[i]);
turned -= turns[i];
continue;
}
if (turned >= InsideCornerAngle - Math.Tolerance.Epsilon)
corners.AddRange(run);
run.Clear();
turned = 0;
}
return corners.ToArray();
}
/// <summary>The worst margin of <paramref name="center"/> over every shifted hull, each with <paramref name="tab"/> added.</summary>
internal static double MarginWith(Vector center, IReadOnlyList<Vector[]> hulls, Vector tab)
{
var worst = double.PositiveInfinity;
foreach (var hull in hulls)
{
var points = new Vector[hull.Length + 1];
hull.CopyTo(points, 0);
points[^1] = tab;
worst = System.Math.Min(worst, Margin(center, Hull(points)));
}
return worst;
}
/// <summary>
/// Signed distance of <paramref name="point"/> inside the convex polygon
/// <paramref name="hull"/> (counter-clockwise): positive inside, negative outside, and
/// never positive for a point or segment hull. An empty hull is negative infinity.
/// </summary>
internal static double Margin(Vector point, IReadOnlyList<Vector> hull)
{
switch (hull.Count)
{
case 0:
return double.NegativeInfinity;
case 1:
return -point.DistanceTo(hull[0]);
case 2:
return -SegmentDistance(point, hull[0], hull[1]);
}
var inside = double.PositiveInfinity;
var outside = double.PositiveInfinity;
var isInside = true;
for (var i = 0; i < hull.Count; i++)
{
var a = hull[i];
var b = hull[(i + 1) % hull.Count];
var edge = b - a;
var length = System.Math.Sqrt(edge.X * edge.X + edge.Y * edge.Y);
var side = (edge.X * (point.Y - a.Y) - edge.Y * (point.X - a.X)) / length;
if (side < 0)
isInside = false;
inside = System.Math.Min(inside, side);
outside = System.Math.Min(outside, SegmentDistance(point, a, b));
}
return isInside ? inside : -outside;
}
/// <summary>The convex hull, counter-clockwise, without repeated or collinear points (monotone chain).</summary>
internal static Vector[] Hull(IEnumerable<Vector> points)
{
var sorted = points.Distinct().OrderBy(p => p.X).ThenBy(p => p.Y).ToArray();
if (sorted.Length < 3)
return sorted;
var hull = new Vector[sorted.Length * 2];
var count = 0;
foreach (var point in sorted)
{
while (count >= 2 && Cross(hull[count - 2], hull[count - 1], point) <= 0)
count--;
hull[count++] = point;
}
var lower = count + 1;
for (var i = sorted.Length - 2; i >= 0; i--)
{
while (count >= lower && Cross(hull[count - 2], hull[count - 1], sorted[i]) <= 0)
count--;
hull[count++] = sorted[i];
}
// The last point repeats the first.
return hull.Take(count - 1).ToArray();
}
private static double Cross(Vector o, Vector a, Vector b) =>
(a.X - o.X) * (b.Y - o.Y) - (a.Y - o.Y) * (b.X - o.X);
private static double SegmentDistance(Vector point, Vector a, Vector b)
{
var edge = b - a;
var lengthSquared = edge.X * edge.X + edge.Y * edge.Y;
if (lengthSquared <= 0)
return point.DistanceTo(a);
var t = System.Math.Clamp(((point.X - a.X) * edge.X + (point.Y - a.Y) * edge.Y) / lengthSquared, 0, 1);
return point.DistanceTo(a + edge * t);
}
private static IEnumerable<Vector> Shifts(double tolerance)
{
if (tolerance <= 0)
return new[] { Vector.Zero };
var steps = new[] { -tolerance, 0, tolerance };
return steps.SelectMany(x => steps.Select(y => new Vector(x, y)));
}
// Every outline vertex, then points spaced evenly along the outline.
private static IEnumerable<Vector> OutlineSamples(IReadOnlyList<Vector> outline)
{
foreach (var vertex in outline)
yield return vertex;
var perimeter = 0.0;
for (var i = 0; i < outline.Count; i++)
perimeter += outline[i].DistanceTo(outline[(i + 1) % outline.Count]);
if (!(perimeter > 0))
yield break;
var step = perimeter / TabSamples;
var travelled = 0.0;
var next = step;
for (var i = 0; i < outline.Count; i++)
{
var a = outline[i];
var b = outline[(i + 1) % outline.Count];
var length = a.DistanceTo(b);
while (length > 0 && next < travelled + length)
{
yield return a + (b - a) * ((next - travelled) / length);
next += step;
}
travelled += length;
}
}
}
/// <summary>
/// How one placed part sits on its support bed (world coordinates): see
/// <see cref="SupportAnalyzer"/>. Immutable.
/// </summary>
public sealed class PartSupport
{
private readonly IReadOnlyList<Vector[]> hulls = Array.Empty<Vector[]>();
internal PartSupport(string reason)
{
Reason = reason ?? "The part's support could not be analyzed.";
Contacts = Array.Empty<Vector>();
}
internal PartSupport(Vector centerOfGravity, IReadOnlyList<Vector> contacts, double margin,
double requiredMargin, IReadOnlyList<Vector[]> hulls, Vector? bestTabPoint, double bestTabMargin,
IReadOnlyList<Vector> insideCorners, double insideCornerClearance)
{
CenterOfGravity = centerOfGravity;
Contacts = contacts;
Margin = margin;
RequiredMargin = requiredMargin;
this.hulls = hulls;
BestTabPoint = bestTabPoint;
BestTabMargin = bestTabMargin;
InsideCorners = insideCorners;
InsideCornerClearance = insideCornerClearance;
}
// A part on a plate with no bed, tabbed for its width: it rests on nothing modeled, and a
// tab anywhere clear of inside corners is as good as any other.
// A copy of `source` held by its tab alone: it keeps its contacts and margin, and any tab
// clear of inside corners steadies it.
private PartSupport(PartSupport source, Vector? bestTabPoint, double length)
{
CenterOfGravity = source.CenterOfGravity;
Contacts = source.Contacts;
Margin = source.Margin;
RequiredMargin = source.RequiredMargin;
hulls = source.hulls;
InsideCorners = source.InsideCorners;
InsideCornerClearance = source.InsideCornerClearance;
BestTabPoint = bestTabPoint;
BestTabMargin = double.PositiveInfinity;
IsHeldByTab = true;
Length = length;
}
internal PartSupport WithTabHold(Vector? bestTabPoint, double length) => new(this, bestTabPoint, length);
internal PartSupport(Vector centerOfGravity, Vector? bestTabPoint, IReadOnlyList<Vector> insideCorners,
double insideCornerClearance, double width)
{
HasBed = false;
CenterOfGravity = centerOfGravity;
Contacts = Array.Empty<Vector>();
BestTabPoint = bestTabPoint;
BestTabMargin = double.PositiveInfinity;
InsideCorners = insideCorners;
InsideCornerClearance = insideCornerClearance;
Width = width;
}
/// <summary>
/// False for a part on a plate with no support bed, tabbed for its <see cref="Width"/>
/// (<see cref="SupportAnalyzer.AnalyzeWithoutBed"/>): it has no contacts, never counts as
/// supported, and any tab clear of inside corners steadies it.
/// </summary>
public bool HasBed { get; } = true;
/// <summary>
/// True for a part on a bed that cannot hold it level but short enough for its tab alone
/// to hold (<see cref="SupportAnalyzer.HeldByTab"/>): any tab clear of inside corners
/// steadies it.
/// </summary>
public bool IsHeldByTab { get; }
/// <summary>The part's length when <see cref="IsHeldByTab"/> (see <see cref="SupportAnalyzer.Length"/>); NaN otherwise.</summary>
public double Length { get; } = double.NaN;
/// <summary>The part's width when analyzed without a bed (see <see cref="SupportAnalyzer.Width"/>); NaN otherwise.</summary>
public double Width { get; } = double.NaN;
/// <summary>True when the part was analyzed; otherwise <see cref="Reason"/> says why not.</summary>
public bool IsComplete => Reason == null;
public string Reason { get; }
public Vector CenterOfGravity { get; } = Vector.Invalid;
/// <summary>Every bed contact under the part at the bed's nominal position.</summary>
public IReadOnlyList<Vector> Contacts { get; }
/// <summary>The outline vertices in an inside corner (see <see cref="SupportAnalyzer.InsideCorners"/>).</summary>
public IReadOnlyList<Vector> InsideCorners { get; } = Array.Empty<Vector>();
/// <summary>How far an accepted tab point keeps from an inside corner.</summary>
public double InsideCornerClearance { get; }
/// <summary>The worst signed distance of the center of gravity inside the contacts' hull.</summary>
public double Margin { get; } = double.NegativeInfinity;
/// <summary>The bed's <see cref="SupportBed.StabilityMargin"/>.</summary>
public double RequiredMargin { get; }
/// <summary>True when the bed alone holds the part level by at least the required margin.</summary>
public bool IsSupported => IsComplete && Meets(Margin);
/// <summary>
/// The outline point clear of inside corners where one tab steadies the part most, or
/// null when not analyzed. Of equally good points, the one farthest from an inside corner.
/// </summary>
public Vector? BestTabPoint { get; }
/// <summary>The margin with a tab at <see cref="BestTabPoint"/>.</summary>
public double BestTabMargin { get; } = double.NegativeInfinity;
/// <summary>True when a tab at <see cref="BestTabPoint"/> holds the part level by the required margin.</summary>
public bool TabCanSupport => IsComplete && Meets(BestTabMargin);
/// <summary>The worst margin with a tab holding the part at <paramref name="tab"/>.</summary>
public double MarginWith(Vector tab) =>
!IsComplete ? double.NegativeInfinity
: !HasBed || IsHeldByTab ? double.PositiveInfinity
: SupportAnalyzer.MarginWith(CenterOfGravity, hulls, tab);
/// <summary>True when a tab at <paramref name="tab"/> holds the part level by the required margin.</summary>
public bool IsSteadiedByTabAt(Vector tab) => IsComplete && Meets(MarginWith(tab));
/// <summary>
/// Whether a tab at <paramref name="tab"/> is good enough: it keeps clear of inside corners,
/// and it holds the part level by the required margin or, where no single tab can, it is
/// as good as the best one found.
/// </summary>
public bool AcceptsTabAt(Vector tab)
{
if (!IsComplete || !IsClearOfInsideCorners(tab))
return false;
var margin = MarginWith(tab);
return TabCanSupport ? Meets(margin) : margin >= BestTabMargin - Math.Tolerance.Epsilon;
}
/// <summary>Whether a tab at <paramref name="tab"/> holds the part level by the required margin.</summary>
public bool HoldsLevelWithTabAt(Vector tab) => IsComplete && Meets(MarginWith(tab));
/// <summary>Whether <paramref name="point"/> keeps <see cref="InsideCornerClearance"/> from every inside corner.</summary>
public bool IsClearOfInsideCorners(Vector point) =>
SupportAnalyzer.IsClear(SupportAnalyzer.Clearance(InsideCorners, point), InsideCornerClearance);
private bool Meets(double margin) => margin > 0 && margin >= RequiredMargin;
}
}
-65
View File
@@ -1,65 +0,0 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Support
{
/// <summary>
/// What a cut part rests on: the machine's support bed, fixed to the machine and measured
/// from the plate origin into the sheet, so its position on any sheet follows from the
/// sheet's quadrant alone. A layout only reports where it touches material; the
/// <see cref="SupportAnalyzer"/> decides from those contacts whether a part stays level.
/// The laser grid (<see cref="SupportGrid"/>) is the built-in layout; straight or curved
/// slats imported from a CAD layout can follow as further subclasses.
/// </summary>
public abstract class SupportBed : IEquatable<SupportBed>
{
protected SupportBed(double registrationTolerance, double stabilityMargin)
{
if (!double.IsFinite(registrationTolerance) || registrationTolerance < 0)
throw new ArgumentOutOfRangeException(nameof(registrationTolerance),
"Registration tolerance must be finite and zero or more.");
if (!double.IsFinite(stabilityMargin) || stabilityMargin < 0)
throw new ArgumentOutOfRangeException(nameof(stabilityMargin),
"Stability margin must be finite and zero or more.");
RegistrationTolerance = registrationTolerance;
StabilityMargin = stabilityMargin;
}
/// <summary>
/// How far a sheet may sit from its nominal place on the bed, along each axis. The
/// analyzer checks the bed shifted by up to this much and keeps the worst result.
/// </summary>
public double RegistrationTolerance { get; }
/// <summary>
/// How far inside its contacts a part's center of gravity must lie for the part to count
/// as supported; it allows for slag, gas pressure and a contact that is worn low.
/// </summary>
public double StabilityMargin { get; }
/// <summary>
/// The contacts under <paramref name="material"/> (world coordinates) on a sheet in
/// <paramref name="quadrant"/>, with the bed moved by <paramref name="shift"/>. With
/// <paramref name="extremesOnly"/>, a layout may omit contacts that cannot be corners of
/// their convex hull; the analyzer needs no others.
/// </summary>
public abstract IReadOnlyList<Vector> Contacts(SupportMaterial material, int quadrant, Vector shift,
bool extremesOnly = false);
/// <summary>This layout with every length multiplied by <paramref name="factor"/> (a unit change).</summary>
public abstract SupportBed Scaled(double factor);
public abstract bool Equals(SupportBed other);
public override bool Equals(object obj) => obj is SupportBed other && Equals(other);
public abstract override int GetHashCode();
/// <summary>-1 where a sheet in <paramref name="quadrant"/> lies on the negative side of the origin along X.</summary>
protected static double SignX(int quadrant) => quadrant is 2 or 3 ? -1 : 1;
/// <summary>-1 where a sheet in <paramref name="quadrant"/> lies on the negative side of the origin along Y.</summary>
protected static double SignY(int quadrant) => quadrant is 3 or 4 ? -1 : 1;
}
}
-116
View File
@@ -1,116 +0,0 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Support
{
/// <summary>
/// A laser bed whose parts rest on a regular grid of contact points: one point every
/// <see cref="PitchX"/> along X and every <see cref="PitchY"/> along Y, the first at
/// (<see cref="OffsetX"/>, <see cref="OffsetY"/>) measured from the plate origin into the
/// sheet. A sheet in another quadrant sees the same grid mirrored, since it lies on the
/// other side of the origin. Lengths are in the nest's units.
/// </summary>
public sealed class SupportGrid : SupportBed
{
public SupportGrid(double pitchX, double pitchY, double offsetX = 0, double offsetY = 0,
double registrationTolerance = 0, double stabilityMargin = 0)
: base(registrationTolerance, stabilityMargin)
{
PitchX = Positive(pitchX, nameof(pitchX));
PitchY = Positive(pitchY, nameof(pitchY));
OffsetX = Finite(offsetX, nameof(offsetX));
OffsetY = Finite(offsetY, nameof(offsetY));
}
public double PitchX { get; }
public double PitchY { get; }
public double OffsetX { get; }
public double OffsetY { get; }
public override IReadOnlyList<Vector> Contacts(SupportMaterial material, int quadrant, Vector shift,
bool extremesOnly = false)
{
ArgumentNullException.ThrowIfNull(material);
var contacts = new List<Vector>();
if (!material.IsComplete)
return contacts;
// The grid's world lattice: OffsetX into the sheet from the origin, then every PitchX.
var baseX = SignX(quadrant) * OffsetX + shift.X;
var baseY = SignY(quadrant) * OffsetY + shift.Y;
var bounds = material.Bounds;
var firstRow = (long)System.Math.Ceiling((bounds.Bottom - baseY) / PitchY);
var lastRow = (long)System.Math.Floor((bounds.Top - baseY) / PitchY);
for (var row = firstRow; row <= lastRow; row++)
{
var y = baseY + row * PitchY;
foreach (var (start, end) in material.SpansAtY(y))
{
var first = (long)System.Math.Ceiling((start - baseX) / PitchX);
var last = (long)System.Math.Floor((end - baseX) / PitchX);
if (first > last)
continue;
// Within one span only its end points can be corners of the contacts' hull.
if (extremesOnly)
{
contacts.Add(new Vector(baseX + first * PitchX, y));
if (last != first)
contacts.Add(new Vector(baseX + last * PitchX, y));
continue;
}
for (var column = first; column <= last; column++)
contacts.Add(new Vector(baseX + column * PitchX, y));
}
}
return contacts;
}
/// <summary>
/// The grid points within <paramref name="region"/> (world coordinates) on a sheet in
/// <paramref name="quadrant"/>, for display; empty when there would be more than
/// <paramref name="limit"/>.
/// </summary>
public IReadOnlyList<Vector> PointsIn(Box region, int quadrant, int limit = 20000)
{
ArgumentNullException.ThrowIfNull(region);
var baseX = SignX(quadrant) * OffsetX;
var baseY = SignY(quadrant) * OffsetY;
var firstColumn = System.Math.Ceiling((region.Left - baseX) / PitchX);
var lastColumn = System.Math.Floor((region.Right - baseX) / PitchX);
var firstRow = System.Math.Ceiling((region.Bottom - baseY) / PitchY);
var lastRow = System.Math.Floor((region.Top - baseY) / PitchY);
var count = (lastColumn - firstColumn + 1) * (lastRow - firstRow + 1);
if (!double.IsFinite(count) || count <= 0 || count > limit)
return Array.Empty<Vector>();
var points = new List<Vector>((int)count);
for (var row = firstRow; row <= lastRow; row++)
for (var column = firstColumn; column <= lastColumn; column++)
points.Add(new Vector(baseX + column * PitchX, baseY + row * PitchY));
return points;
}
public override SupportBed Scaled(double factor) =>
new SupportGrid(PitchX * factor, PitchY * factor, OffsetX * factor, OffsetY * factor,
RegistrationTolerance * factor, StabilityMargin * factor);
public override bool Equals(SupportBed other) =>
other is SupportGrid grid
&& grid.PitchX == PitchX && grid.PitchY == PitchY
&& grid.OffsetX == OffsetX && grid.OffsetY == OffsetY
&& grid.RegistrationTolerance == RegistrationTolerance
&& grid.StabilityMargin == StabilityMargin;
public override int GetHashCode() =>
HashCode.Combine(PitchX, PitchY, OffsetX, OffsetY, RegistrationTolerance, StabilityMargin);
private static double Positive(double value, string name) =>
double.IsFinite(value) && value > 0 ? value
: throw new ArgumentOutOfRangeException(name, "Grid pitch must be finite and positive.");
private static double Finite(double value, string name) =>
double.IsFinite(value) ? value
: throw new ArgumentOutOfRangeException(name, "Grid offset must be finite.");
}
}
-252
View File
@@ -1,252 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Support
{
/// <summary>
/// The material of one placed part as it drops onto the support bed, in world coordinates:
/// its outline minus its holes, chorded at <see cref="ChordTolerance"/>. A tabbed cutout's
/// slug stays attached while the sheet is cut (see <see cref="NestingMaterial.ForNesting"/>),
/// so it counts as part. Immutable; an unusable program gives an incomplete material.
/// </summary>
public sealed class SupportMaterial
{
/// <summary>Chord tolerance for arcs, in model units.</summary>
public const double ChordTolerance = 0.001;
private readonly Vector[] outline;
private readonly Vector[][] holes;
private readonly Box bounds = new();
private SupportMaterial(Vector[] outline, Vector[][] holes, string reason)
{
this.outline = outline ?? Array.Empty<Vector>();
this.holes = holes ?? Array.Empty<Vector[]>();
Reason = reason;
if (reason != null)
return;
var minX = double.PositiveInfinity;
var minY = double.PositiveInfinity;
var maxX = double.NegativeInfinity;
var maxY = double.NegativeInfinity;
foreach (var point in this.outline)
{
minX = System.Math.Min(minX, point.X);
minY = System.Math.Min(minY, point.Y);
maxX = System.Math.Max(maxX, point.X);
maxY = System.Math.Max(maxY, point.Y);
}
bounds = new Box(minX, minY, maxX - minX, maxY - minY);
}
/// <summary>True when the material was captured; otherwise <see cref="Reason"/> says why not.</summary>
public bool IsComplete => Reason == null;
public string Reason { get; }
/// <summary>The outline's vertices, without a repeated closing vertex.</summary>
public IReadOnlyList<Vector> Outline => outline;
/// <summary>Each hole's vertices, without a repeated closing vertex.</summary>
public IReadOnlyList<IReadOnlyList<Vector>> Holes => holes;
/// <summary>A copy of the outline's bounding box.</summary>
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
/// <summary>
/// Material from an outline and holes already in world coordinates. Fewer than three
/// vertices, or a nonfinite one, gives an incomplete material.
/// </summary>
public static SupportMaterial FromPolygons(IEnumerable<Vector> outline, IEnumerable<IEnumerable<Vector>> holes = null)
{
var ring = Ring(outline);
if (ring == null)
return Incomplete("The part outline is not a polygon.");
var cutouts = new List<Vector[]>();
foreach (var hole in holes ?? Enumerable.Empty<IEnumerable<Vector>>())
{
var cutout = Ring(hole);
if (cutout == null)
return Incomplete("A part hole is not a polygon.");
cutouts.Add(cutout);
}
return new SupportMaterial(ring, cutouts.ToArray(), null);
}
/// <summary>The material of a placed part: its program at its location.</summary>
public static SupportMaterial FromPart(Part part)
{
ArgumentNullException.ThrowIfNull(part);
return FromProgram(part.Program, part.Location);
}
/// <summary>
/// The material of <paramref name="program"/> (part coordinates, rotation applied) placed
/// at <paramref name="location"/>. Leads, rapids, scribes and break cuts are not material.
/// A perimeter left open by a tab still bounds its material, closed across the tab.
/// </summary>
public static SupportMaterial FromProgram(Program program, Vector location)
{
ArgumentNullException.ThrowIfNull(program);
try
{
var entities = ConvertProgram.ToGeometry(program)
.Where(e => e.Layer != SpecialLayers.Leadin && e.Layer != SpecialLayers.Leadout)
.ForNesting();
if (entities.Count == 0)
return Incomplete("The part has no material contour.");
var profile = new ShapeProfile(entities);
var ring = Ring(Chord(profile.Perimeter, location));
if (ring == null)
return Incomplete("The part outline is not a polygon.");
var cutouts = new List<Vector[]>();
foreach (var cutout in profile.Cutouts)
if (Ring(Chord(cutout, location)) is { } hole)
cutouts.Add(hole);
return new SupportMaterial(ring, cutouts.ToArray(), null);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or NotSupportedException
or ArithmeticException or IndexOutOfRangeException)
{
return Incomplete(ex.Message);
}
}
private static SupportMaterial Incomplete(string reason) => new(null, null, reason);
private static IEnumerable<Vector> Chord(Shape shape, Vector location) =>
shape.ToPolygonWithTolerance(ChordTolerance).Vertices.Select(v => v + location);
// The distinct vertices of a closed ring, or null when it cannot bound any area.
private static Vector[] Ring(IEnumerable<Vector> vertices)
{
if (vertices == null)
return null;
var ring = new List<Vector>();
foreach (var vertex in vertices)
{
if (!double.IsFinite(vertex.X) || !double.IsFinite(vertex.Y))
return null;
if (ring.Count == 0 || ring[^1] != vertex)
ring.Add(vertex);
}
if (ring.Count > 1 && ring[0] == ring[^1])
ring.RemoveAt(ring.Count - 1);
return ring.Count < 3 ? null : ring.ToArray();
}
/// <summary>
/// The material's area and center of gravity (its area centroid: plate is of even
/// thickness), or false when the material is incomplete or encloses no area.
/// </summary>
public bool TryGetCenterOfGravity(out Vector centerOfGravity, out double area)
{
centerOfGravity = Vector.Invalid;
area = 0;
if (!IsComplete || !PolygonAreaMoments.TryCompute(outline, out var whole))
return false;
var cutouts = new List<PolygonAreaMoments>();
foreach (var hole in holes)
if (PolygonAreaMoments.TryCompute(hole, out var moments))
cutouts.Add(moments);
if (!PolygonAreaMoments.TrySubtract(whole, cutouts, out var net))
return false;
centerOfGravity = net.Centroid;
area = net.Area;
return true;
}
/// <summary>
/// The spans of the horizontal line at <paramref name="y"/> that lie in material, in
/// ascending order. A line along an edge or through a vertex counts that crossing once,
/// on the half-open rule, so touching is not crossing.
/// </summary>
public IReadOnlyList<(double Start, double End)> SpansAtY(double y)
{
var crossings = new List<double>();
if (!IsComplete || y < bounds.Bottom || y > bounds.Top)
return Array.Empty<(double, double)>();
AddCrossings(outline, y, crossings);
foreach (var hole in holes)
AddCrossings(hole, y, crossings);
crossings.Sort();
var spans = new List<(double, double)>(crossings.Count / 2);
for (var i = 0; i + 1 < crossings.Count; i += 2)
spans.Add((crossings[i], crossings[i + 1]));
return spans;
}
/// <summary>
/// The spans of the vertical line at <paramref name="x"/> that lie in material, in
/// ascending order, on the same half-open rule as <see cref="SpansAtY"/>.
/// </summary>
public IReadOnlyList<(double Start, double End)> SpansAtX(double x)
{
var crossings = new List<double>();
if (!IsComplete || x < bounds.Left || x > bounds.Right)
return Array.Empty<(double, double)>();
AddCrossingsAtX(outline, x, crossings);
foreach (var hole in holes)
AddCrossingsAtX(hole, x, crossings);
crossings.Sort();
var spans = new List<(double, double)>(crossings.Count / 2);
for (var i = 0; i + 1 < crossings.Count; i += 2)
spans.Add((crossings[i], crossings[i + 1]));
return spans;
}
/// <summary>Whether <paramref name="point"/> lies in material (even-odd over outline and holes).</summary>
public bool Contains(Vector point)
{
if (!IsComplete)
return false;
var inside = Inside(outline, point);
foreach (var hole in holes)
if (Inside(hole, point))
inside = !inside;
return inside;
}
private static void AddCrossings(Vector[] ring, double y, List<double> crossings)
{
for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++)
{
var a = ring[j];
var b = ring[i];
if ((a.Y > y) != (b.Y > y))
crossings.Add(a.X + (y - a.Y) * (b.X - a.X) / (b.Y - a.Y));
}
}
private static void AddCrossingsAtX(Vector[] ring, double x, List<double> crossings)
{
for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++)
{
var a = ring[j];
var b = ring[i];
if ((a.X > x) != (b.X > x))
crossings.Add(a.Y + (x - a.X) * (b.Y - a.Y) / (b.X - a.X));
}
}
private static bool Inside(Vector[] ring, Vector point)
{
var inside = false;
for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++)
{
var a = ring[j];
var b = ring[i];
if ((a.Y > point.Y) != (b.Y > point.Y)
&& point.X < a.X + (point.Y - a.Y) * (b.X - a.X) / (b.Y - a.Y))
inside = !inside;
}
return inside;
}
}
}
-152
View File
@@ -1,152 +0,0 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Support
{
/// <summary>
/// A bed of straight slats, as on a plasma table: each slat a rectangle <see cref="Thickness"/>
/// wide running the full length of the table along <see cref="Axis"/>, one every
/// <see cref="Pitch"/> across it, the first centered <see cref="Offset"/> from the plate origin
/// into the sheet. A sheet in another quadrant sees the same slats mirrored. A part rests on
/// the region where its material crosses a slat; the corners of that region (where the
/// outline crosses a slat's edges, and outline vertices on the slat) are its contacts.
/// Lengths are in the nest's units.
/// </summary>
public sealed class SupportSlats : SupportBed
{
public SupportSlats(SlatAxis axis, double pitch, double thickness, double offset = 0,
double registrationTolerance = 0, double stabilityMargin = 0)
: base(registrationTolerance, stabilityMargin)
{
if (axis is not (SlatAxis.X or SlatAxis.Y))
throw new ArgumentOutOfRangeException(nameof(axis), "Slats run along X or Y.");
if (!double.IsFinite(pitch) || pitch <= 0)
throw new ArgumentOutOfRangeException(nameof(pitch), "Slat pitch must be finite and positive.");
if (!double.IsFinite(thickness) || thickness < 0)
throw new ArgumentOutOfRangeException(nameof(thickness), "Slat thickness must be finite and zero or more.");
if (!double.IsFinite(offset))
throw new ArgumentOutOfRangeException(nameof(offset), "Slat offset must be finite.");
Axis = axis;
Pitch = pitch;
Thickness = thickness;
Offset = offset;
}
public SlatAxis Axis { get; }
/// <summary>The distance between neighboring slats' center lines.</summary>
public double Pitch { get; }
/// <summary>How wide each slat's top edge is; zero treats a slat as a line.</summary>
public double Thickness { get; }
/// <summary>The first slat's center line, measured from the plate origin into the sheet.</summary>
public double Offset { get; }
public override IReadOnlyList<Vector> Contacts(SupportMaterial material, int quadrant, Vector shift,
bool extremesOnly = false)
{
ArgumentNullException.ThrowIfNull(material);
var contacts = new List<Vector>();
if (!material.IsComplete)
return contacts;
// Work across the slats: Y for slats along X, X for slats along Y.
var alongX = Axis == SlatAxis.X;
var baseline = alongX ? SignY(quadrant) * Offset + shift.Y : SignX(quadrant) * Offset + shift.X;
var bounds = material.Bounds;
var low = alongX ? bounds.Bottom : bounds.Left;
var high = alongX ? bounds.Top : bounds.Right;
var half = Thickness / 2;
var first = (long)System.Math.Ceiling((low - half - baseline) / Pitch);
var last = (long)System.Math.Floor((high + half - baseline) / Pitch);
if (first > last)
return contacts;
// Where the outline crosses each slat's edges.
for (var slat = first; slat <= last; slat++)
{
var center = baseline + slat * Pitch;
AddEdge(material, alongX, center - half, contacts);
if (half > 0)
AddEdge(material, alongX, center + half, contacts);
}
// Outline and hole vertices that sit on a slat: every vertex on one slat's top.
AddVertices(material.Outline, alongX, baseline, half, contacts);
foreach (var hole in material.Holes)
AddVertices(hole, alongX, baseline, half, contacts);
return contacts;
}
/// <summary>
/// The slats within <paramref name="region"/> (world coordinates) on a sheet in
/// <paramref name="quadrant"/>, each cut off to the region, for display; empty when there
/// would be more than <paramref name="limit"/>.
/// </summary>
public IReadOnlyList<Box> SlatsIn(Box region, int quadrant, int limit = 5000)
{
ArgumentNullException.ThrowIfNull(region);
var alongX = Axis == SlatAxis.X;
var baseline = alongX ? SignY(quadrant) * Offset : SignX(quadrant) * Offset;
var low = alongX ? region.Bottom : region.Left;
var high = alongX ? region.Top : region.Right;
var half = Thickness / 2;
var first = System.Math.Ceiling((low - half - baseline) / Pitch);
var last = System.Math.Floor((high + half - baseline) / Pitch);
var count = last - first + 1;
if (!double.IsFinite(count) || count <= 0 || count > limit)
return Array.Empty<Box>();
var slats = new List<Box>((int)count);
for (var slat = first; slat <= last; slat++)
{
var center = baseline + slat * Pitch;
var start = System.Math.Max(low, center - half);
var end = System.Math.Min(high, center + half);
slats.Add(alongX
? new Box(region.Left, start, region.Length, end - start)
: new Box(start, region.Bottom, end - start, region.Width));
}
return slats;
}
public override SupportBed Scaled(double factor) =>
new SupportSlats(Axis, Pitch * factor, Thickness * factor, Offset * factor,
RegistrationTolerance * factor, StabilityMargin * factor);
public override bool Equals(SupportBed other) =>
other is SupportSlats slats
&& slats.Axis == Axis && slats.Pitch == Pitch
&& slats.Thickness == Thickness && slats.Offset == Offset
&& slats.RegistrationTolerance == RegistrationTolerance
&& slats.StabilityMargin == StabilityMargin;
public override int GetHashCode() =>
HashCode.Combine(Axis, Pitch, Thickness, Offset, RegistrationTolerance, StabilityMargin);
private static void AddEdge(SupportMaterial material, bool alongX, double across, List<Vector> contacts)
{
var spans = alongX ? material.SpansAtY(across) : material.SpansAtX(across);
foreach (var (start, end) in spans)
{
contacts.Add(alongX ? new Vector(start, across) : new Vector(across, start));
contacts.Add(alongX ? new Vector(end, across) : new Vector(across, end));
}
}
private void AddVertices(IReadOnlyList<Vector> ring, bool alongX, double baseline, double half,
List<Vector> contacts)
{
foreach (var vertex in ring)
{
var across = alongX ? vertex.Y : vertex.X;
// The nearest slat center; the vertex is on that slat when within half its thickness.
var center = baseline + System.Math.Round((across - baseline) / Pitch) * Pitch;
if (System.Math.Abs(across - center) <= half)
contacts.Add(vertex);
}
}
}
}
@@ -1,78 +0,0 @@
using System;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.Support
{
/// <summary>
/// Which parts <see cref="CuttingParameters.SupportTabs"/> tabs (docs/support-tabs.md): the
/// parts the plate's support bed cannot hold level or, on a plate with no bed, the parts
/// whose width is in the settings' no-bed range. Also the cutting settings for a lead-in
/// placed by hand: the tool cuts where it is clicked, so the part is tabbed there. Plan
/// Cutting chooses the tab point itself.
/// </summary>
public static class SupportTabSettings
{
/// <summary>
/// The analysis that tabs a part, or null when support tabs are off or the part is
/// outside the rule: with a bed, the bed's analysis, held by its tab alone
/// (<see cref="SupportAnalyzer.HeldByTab"/>) when the bed cannot hold it level and its
/// length is within <see cref="CuttingParameters.TabHoldsAt"/>; without one, a no-bed analysis
/// (<see cref="SupportAnalyzer.AnalyzeWithoutBed"/>) of a part whose width is in
/// <see cref="CuttingParameters.TabsWithoutBedAt"/>'s range. The part needs a tab when
/// the result is complete and not supported; an incomplete result could not be read.
/// </summary>
/// <param name="insideCornerClearance">
/// How far a tab point keeps from an inside corner; 0 keeps it off the corner only.
/// </param>
public static PartSupport Analyze(CuttingParameters parameters, SupportBed bed, int quadrant,
SupportMaterial material, double insideCornerClearance = 0)
{
ArgumentNullException.ThrowIfNull(material);
if (parameters?.SupportTabs != true)
return null;
if (bed != null)
{
var support = SupportAnalyzer.Analyze(bed, quadrant, material, insideCornerClearance);
return support is { IsComplete: true, IsSupported: false }
&& parameters.TabHoldsAt(SupportAnalyzer.Length(material))
? SupportAnalyzer.HeldByTab(support, material)
: support;
}
if (!parameters.HasNoBedTabRange())
return null;
if (!material.IsComplete)
return SupportAnalyzer.AnalyzeWithoutBed(material, insideCornerClearance);
return parameters.TabsWithoutBedAt(SupportAnalyzer.Width(material))
? SupportAnalyzer.AnalyzeWithoutBed(material, insideCornerClearance)
: null;
}
/// <summary>
/// Whether support tabs are on but can tab nothing: the plate has no support bed and the
/// settings set no no-bed width range.
/// </summary>
public static bool SkipsWithoutBed(CuttingParameters parameters, SupportBed bed) =>
parameters?.SupportTabs == true && bed == null && !parameters.HasNoBedTabRange();
/// <summary>
/// <paramref name="parameters"/> itself, or a copy with tabs enabled when support tabs are
/// on, a tab size is set and <paramref name="part"/> needs a tab (see <see cref="Analyze"/>).
/// The caller's settings are never changed.
/// </summary>
public static CuttingParameters ForManualLeadIn(CuttingParameters parameters, Plate plate, Part part)
{
ArgumentNullException.ThrowIfNull(parameters);
if (!parameters.SupportTabs || parameters.TabsEnabled || parameters.TabConfig == null
|| plate == null || part == null
|| (plate.SupportBed == null && !parameters.HasNoBedTabRange()))
return parameters;
var support = Analyze(parameters, plate.SupportBed, plate.Quadrant, SupportMaterial.FromPart(part));
if (support is not { IsComplete: true, IsSupported: false })
return parameters;
var tabbed = OwnedCuttingParameters.Copy(parameters);
tabbed.TabsEnabled = true;
return tabbed;
}
}
}
+1 -3
View File
@@ -10,8 +10,6 @@ namespace OpenNest
{
public static TimingInfo GetTimingInfo(Program pgm)
{
// A display perimeter's tail is never cut, so it takes no time.
pgm = DisplayPerimeterTail.WithoutTail(pgm);
var entities = ConvertProgram.ToGeometry(pgm);
var shapes = ShapeBuilder.GetShapes(
entities.Where(entity => entity.Layer != SpecialLayers.Rapid)
@@ -38,7 +36,7 @@ namespace OpenNest
foreach (var part in plate.Parts)
{
var endpt = DisplayPerimeterTail.WithoutTail(part.Program).EndPoint() + part.Location;
var endpt = part.Program.EndPoint() + part.Location;
info += GetTimingInfo(part.Program);
info.TravelDistance += pos.DistanceTo(endpt);
pos = endpt;
-196
View File
@@ -1,196 +0,0 @@
using System.Globalization;
namespace OpenNest.Data;
/// <summary>The paging facts a <see cref="BrowseSession{TPage, TSort}"/> reads from a server page.</summary>
public interface IBrowsePage
{
int Offset { get; }
/// <summary>Number of records matching the request across all pages.</summary>
int Total { get; }
/// <summary>Number of records on this page.</summary>
int ItemCount { get; }
}
/// <summary>
/// Browse state shared by the paged server lists: the requested search, sort and page, and the
/// last page applied. Every change issues one bounded server query. A response that a later
/// request superseded is discarded (and the earlier request is cancelled), so an older result
/// can never replace a newer one. Call from one thread (the UI thread).
/// </summary>
public abstract class BrowseSession<TPage, TSort> : IDisposable
where TPage : class, IBrowsePage
where TSort : struct, Enum
{
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
protected BrowseSession(int pageSize, int maxPageSize, TSort sort, bool descending = true)
{
if (pageSize < 1 || pageSize > maxPageSize)
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {maxPageSize}.");
PageSize = pageSize;
Sort = sort;
Descending = descending;
}
public int PageSize { get; }
/// <summary>Trimmed search text of the latest request.</summary>
public string Search { get; private set; } = "";
public TSort Sort { get; private set; }
public bool Descending { get; private set; }
/// <summary>Offset of the latest request.</summary>
public int Offset { get; private set; }
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
public TPage? Page { get; private set; }
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
public bool IsLoading { get; private set; }
public bool CanGoPrevious => Offset > 0;
public bool CanGoNext => Page is { } page && page.Offset + page.ItemCount < page.Total;
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 nests".</summary>
public abstract string Summary { get; }
/// <summary>Applies new search text and returns to the first page.</summary>
public Task<bool> SetSearchAsync(string? search)
{
Search = (search ?? "").Trim();
return FirstPageAsync();
}
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
public Task<bool> SortByAsync(TSort field)
{
Descending = EqualityComparer<TSort>.Default.Equals(field, Sort) && !Descending;
Sort = field;
return FirstPageAsync();
}
public Task<bool> NextPageAsync()
{
if (!CanGoNext)
return Task.FromResult(false);
Offset = Page!.Offset + PageSize;
return RunAsync();
}
public Task<bool> PreviousPageAsync()
{
if (!CanGoPrevious)
return Task.FromResult(false);
Offset = System.Math.Max(0, Offset - PageSize);
return RunAsync();
}
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
public Task<bool> RefreshAsync() => RunAsync();
/// <summary>Re-queries from the first page, after a derived filter changed.</summary>
protected Task<bool> FirstPageAsync()
{
Offset = 0;
return RunAsync();
}
/// <summary>Queries the page at <see cref="Offset"/> with the current filters.</summary>
protected abstract Task<TPage> QueryAsync(CancellationToken cancellationToken);
/// <summary>The standard summary: <paramref name="empty"/> when nothing matches, else the shown range of <paramref name="noun"/>.</summary>
protected string PageSummary(string noun, string empty)
{
if (Page is not { } page)
return "";
if (page.Total == 0)
return empty;
if (page.ItemCount == 0)
return $"No {noun} on this page ({Count(page.Total)} in total).";
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.ItemCount)} of {Count(page.Total)} {noun}";
}
/// <summary>
/// Sends the current request. Returns true when its page was applied, false when a later
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
/// failures of superseded requests are ignored.
/// </summary>
private async Task<bool> RunAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a request that completes synchronously when cancelled
// must already see itself as superseded. Each source is released by its own request.
var previous = _pending;
using var cancellation = new CancellationTokenSource();
_pending = cancellation;
var generation = ++_generation;
IsLoading = true;
try
{
previous?.Cancel();
var page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
// A deletion, an archive or another PC can leave the requested page past the end.
if (page.ItemCount == 0 && page.Offset > 0 && page.Total > 0)
{
Offset = (page.Total - 1) / PageSize * PageSize;
page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
}
Page = page;
return true;
}
catch (Exception) when (generation != _generation)
{
return false;
}
catch
{
Page = null;
throw;
}
finally
{
if (ReferenceEquals(_pending, cancellation))
_pending = null;
if (generation == _generation)
IsLoading = false;
}
}
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
/// <summary>
/// Cancels any request in flight (which releases its own source when it ends);
/// the server client is not owned and stays open.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_generation++;
IsLoading = false;
var pending = _pending;
_pending = null;
pending?.Cancel();
}
}
+21 -47
View File
@@ -194,39 +194,19 @@ public sealed class ClampSettings
/// </summary>
public static ClampSettings Load(string path, out ClampSettingsStatus status)
{
var settings = Fallback;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
status = ClampSettingsStatus.Missing;
return Fallback;
return settings;
}
string json;
try
{
json = File.ReadAllText(path);
}
catch (Exception ex) when (IsUnreadableFile(ex))
{
status = ClampSettingsStatus.Invalid;
return Fallback;
}
return Parse(json, out status);
}
/// <summary>
/// Reads settings from <paramref name="json"/> (a file or the shared copy) the way
/// <see cref="Load(string, out ClampSettingsStatus)"/> reads a file, never throwing.
/// </summary>
public static ClampSettings Parse(string json, out ClampSettingsStatus status)
{
var settings = Fallback;
// Read as a raw document so a missing field (fallback, Ok) is distinguishable from a
// present but unusable one, including an explicit null (fallback, Partial).
Dictionary<string, JsonElement> values;
try
{
using var document = JsonDocument.Parse(json ?? "");
using var document = JsonDocument.Parse(File.ReadAllText(path));
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
status = ClampSettingsStatus.Invalid;
@@ -326,29 +306,6 @@ public sealed class ClampSettings
/// the same way <see cref="LocalJsonProvider"/> does.
/// </summary>
public void Save(string path, int maxRetries = 3)
{
var json = ToJson();
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
for (var attempt = 0; attempt < maxRetries; attempt++)
{
try
{
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < maxRetries - 1)
{
Thread.Sleep(100);
}
}
}
/// <summary>The settings as <see cref="Save"/> writes them, for the shared copy.</summary>
public string ToJson()
{
var dto = new Dictionary<string, object>
{
@@ -367,7 +324,24 @@ public sealed class ClampSettings
dto["originEndCenters"] = OriginEndCenters;
if (FarEndCenters.Count > 0)
dto["farEndCenters"] = FarEndCenters;
return JsonSerializer.Serialize(dto, JsonOptions);
var json = JsonSerializer.Serialize(dto, JsonOptions);
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
for (var attempt = 0; attempt < maxRetries; attempt++)
{
try
{
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < maxRetries - 1)
{
Thread.Sleep(100);
}
}
}
private static double Scale(Units from, Units to) =>
+16 -16
View File
@@ -16,7 +16,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
@@ -25,7 +25,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
@@ -34,7 +34,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
@@ -43,7 +43,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
@@ -52,7 +52,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
@@ -61,7 +61,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
@@ -70,7 +70,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.375, "angle": 90.0, "radius": 0.1875 },
"leadOut": { "type": "Line", "length": 0.1875, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.3125, "minSegmentLength": 1.25, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.625, "overtravel": 0.3125, "minSegmentLength": 1.25, "direction": "AwayFromOrigin" },
"plateSizes": [ "60x120", "60x144", "72x120" ]
},
{
@@ -79,7 +79,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.5, "angle": 90.0, "radius": 0.25 },
"leadOut": { "type": "Line", "length": 0.25, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.375, "minSegmentLength": 1.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.75, "overtravel": 0.375, "minSegmentLength": 1.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "60x120", "60x144", "72x120" ]
}
]
@@ -95,7 +95,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
@@ -104,7 +104,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
@@ -113,7 +113,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
@@ -122,7 +122,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
}
]
@@ -138,7 +138,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
@@ -147,7 +147,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
@@ -156,7 +156,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
@@ -165,7 +165,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
}
]
+174 -17
View File
@@ -1,31 +1,42 @@
using System.Globalization;
using OpenNest;
namespace OpenNest.Data;
/// <summary>
/// Browse state for Add from Drawing Library: the requested search, sort, page and
/// nest filter, and the last page applied. By default only drawings with the nest's
/// material, grade and thickness are listed; <see cref="SetShowAllAsync"/> lifts that
/// filter. Paging, superseding and disposal follow <see cref="BrowseSession{TPage, TSort}"/>.
/// Call from one thread (the UI thread).
/// filter. Every change issues one bounded server query. A response that a later request
/// superseded is discarded (and the earlier request is cancelled), so an older result can
/// never replace a newer one. Call from one thread (the UI thread).
/// </summary>
public sealed class DrawingLibraryBrowseSession : BrowseSession<DrawingLibraryPage, DrawingSortField>
public sealed class DrawingLibraryBrowseSession : IDisposable
{
private readonly IDrawingLibrary _library;
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
/// <param name="nest">The nest to filter to, or null to list every active drawing (File &gt; Drawing Library).</param>
public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest? nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
: base(pageSize, DrawingLibraryQuery.MaxLimit, DrawingSortField.SavedAt)
public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
{
ArgumentNullException.ThrowIfNull(library);
ArgumentNullException.ThrowIfNull(nest);
if (pageSize < 1 || pageSize > DrawingLibraryQuery.MaxLimit)
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {DrawingLibraryQuery.MaxLimit}.");
_library = library;
MaterialName = (nest?.Material?.Name ?? "").Trim();
MaterialGrade = (nest?.Material?.Grade ?? "").Trim();
PageSize = pageSize;
MaterialName = (nest.Material?.Name ?? "").Trim();
MaterialGrade = (nest.Material?.Grade ?? "").Trim();
// A nest without a thickness has nothing to match; it filters by material only.
Thickness = nest != null && double.IsFinite(nest.Thickness) && nest.Thickness > 0
Thickness = double.IsFinite(nest.Thickness) && nest.Thickness > 0
? DrawingLibraryRules.NormalizeThickness(nest.Thickness)
: null;
}
public int PageSize { get; }
/// <summary>The nest's material name, applied unless <see cref="ShowAll"/>; blank means any.</summary>
public string MaterialName { get; }
@@ -42,21 +53,148 @@ public sealed class DrawingLibraryBrowseSession : BrowseSession<DrawingLibraryPa
public bool IsFilteredToNest =>
!ShowAll && (MaterialName.Length > 0 || MaterialGrade.Length > 0 || Thickness is not null);
/// <inheritdoc />
public override string Summary =>
PageSummary("drawings",
Search.Length > 0 ? "No drawings match the search."
: IsFilteredToNest ? "No library drawings match this nest's material and thickness. Choose Show all to see every drawing."
: "No drawings in the library.");
/// <summary>Trimmed search text of the latest request.</summary>
public string Search { get; private set; } = "";
public DrawingSortField Sort { get; private set; } = DrawingSortField.SavedAt;
public bool Descending { get; private set; } = true;
/// <summary>Offset of the latest request.</summary>
public int Offset { get; private set; }
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
public DrawingLibraryPage? Page { get; private set; }
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
public bool IsLoading { get; private set; }
public bool CanGoPrevious => Offset > 0;
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 drawings".</summary>
public string Summary
{
get
{
if (Page is not { } page)
return "";
if (page.Total == 0)
{
if (Search.Length > 0)
return "No drawings match the search.";
return IsFilteredToNest
? "No library drawings match this nest's material and thickness. Choose Show all to see every drawing."
: "No drawings in the library.";
}
if (page.Items.Count == 0)
return $"No drawings on this page ({Count(page.Total)} in total).";
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} drawings";
}
}
/// <summary>Applies new search text and returns to the first page.</summary>
public Task<bool> SetSearchAsync(string? search)
{
Search = (search ?? "").Trim();
Offset = 0;
return RunAsync();
}
/// <summary>Lifts or restores the nest filter and returns to the first page.</summary>
public Task<bool> SetShowAllAsync(bool showAll)
{
ShowAll = showAll;
return FirstPageAsync();
Offset = 0;
return RunAsync();
}
protected override Task<DrawingLibraryPage> QueryAsync(CancellationToken cancellationToken) =>
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
public Task<bool> SortByAsync(DrawingSortField field)
{
Descending = field == Sort && !Descending;
Sort = field;
Offset = 0;
return RunAsync();
}
public Task<bool> NextPageAsync()
{
if (!CanGoNext)
return Task.FromResult(false);
Offset = Page!.Offset + PageSize;
return RunAsync();
}
public Task<bool> PreviousPageAsync()
{
if (!CanGoPrevious)
return Task.FromResult(false);
Offset = System.Math.Max(0, Offset - PageSize);
return RunAsync();
}
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
public Task<bool> RefreshAsync() => RunAsync();
/// <summary>
/// Sends the current request. Returns true when its page was applied, false when a later
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
/// failures of superseded requests are ignored.
/// </summary>
private async Task<bool> RunAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a request that completes synchronously when cancelled
// must already see itself as superseded. Each source is released by its own request.
var previous = _pending;
using var cancellation = new CancellationTokenSource();
_pending = cancellation;
var generation = ++_generation;
IsLoading = true;
try
{
previous?.Cancel();
var page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
// An archive or another PC can leave the requested page past the end.
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
{
Offset = (page.Total - 1) / PageSize * PageSize;
page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
}
Page = page;
return true;
}
catch (Exception) when (generation != _generation)
{
return false;
}
catch
{
Page = null;
throw;
}
finally
{
if (ReferenceEquals(_pending, cancellation))
_pending = null;
if (generation == _generation)
IsLoading = false;
}
}
private Task<DrawingLibraryPage> QueryAsync(CancellationToken cancellationToken) =>
_library.QueryAsync(
new DrawingLibraryQuery
{
@@ -70,4 +208,23 @@ public sealed class DrawingLibraryBrowseSession : BrowseSession<DrawingLibraryPa
Limit = PageSize,
},
cancellationToken);
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
/// <summary>
/// Cancels any request in flight (which releases its own source when it ends);
/// the library client is not owned and stays open.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_generation++;
IsLoading = false;
var pending = _pending;
_pending = null;
pending?.Cancel();
}
}
-83
View File
@@ -1,83 +0,0 @@
namespace OpenNest.Data;
/// <summary>
/// The customers, materials, grades and thicknesses an edit offers in its drop-downs: those
/// already used by library drawings, plus the materials and grades of a material catalog.
/// Names match by <see cref="SharedListNames.Key"/> and keep the first spelling seen, so
/// pass the newest records first. Thicknesses come only from records in the edit's units.
/// The lists are suggestions; any typed value may still be saved.
/// </summary>
public sealed class DrawingLibraryChoices
{
private readonly List<DrawingLibraryRecord> _records;
private readonly List<MaterialConfig> _catalog;
private DrawingLibraryChoices(List<DrawingLibraryRecord> records, List<MaterialConfig> catalog, Units units)
{
_records = records;
Units = units;
_catalog = catalog;
Customers = Distinct(records.Select(r => r.Customer));
Materials = Distinct(records.Select(r => r.MaterialName).Concat(catalog.Select(m => m.Name)));
}
public static DrawingLibraryChoices Empty { get; } = new(new(), new(), Units.Inches);
/// <param name="records">Library drawings, newest first; archived ones are skipped.</param>
/// <param name="units">The edit's units; thicknesses in other units are not offered.</param>
/// <param name="catalog">Materials and grades on file, for example the machine configurations'.</param>
public static DrawingLibraryChoices From(IEnumerable<DrawingLibraryRecord> records, Units units,
IEnumerable<MaterialConfig>? catalog = null)
{
ArgumentNullException.ThrowIfNull(records);
return new DrawingLibraryChoices(records.Where(r => r != null && !r.Archived).ToList(),
catalog?.Where(m => m != null).ToList() ?? new(), units);
}
public Units Units { get; }
/// <summary>Customers, sorted.</summary>
public IReadOnlyList<string> Customers { get; }
/// <summary>Material names, sorted.</summary>
public IReadOnlyList<string> Materials { get; }
/// <summary>The grades used or on file for <paramref name="material"/>, sorted.</summary>
public IReadOnlyList<string> GradesOf(string? material)
{
var key = SharedListNames.Key(material);
if (key.Length == 0)
return Array.Empty<string>();
return Distinct(_records.Where(r => SharedListNames.Key(r.MaterialName) == key).Select(r => r.MaterialGrade)
.Concat(_catalog.Where(m => SharedListNames.Key(m.Name) == key).Select(m => m.Grade)));
}
/// <summary>
/// The thicknesses library drawings of <paramref name="material"/> (and of
/// <paramref name="grade"/>, when one is given) use, thinnest first.
/// </summary>
public IReadOnlyList<double> ThicknessesOf(string? material, string? grade)
{
var materialKey = SharedListNames.Key(material);
if (materialKey.Length == 0)
return Array.Empty<double>();
var gradeKey = SharedListNames.Key(grade);
return _records
.Where(r => r.Units == Units && SharedListNames.Key(r.MaterialName) == materialKey
&& (gradeKey.Length == 0 || SharedListNames.Key(r.MaterialGrade) == gradeKey)
&& double.IsFinite(r.Thickness) && r.Thickness > 0)
.Select(r => DrawingLibraryRules.NormalizeThickness(r.Thickness))
.Distinct()
.OrderBy(t => t)
.ToList();
}
private static IReadOnlyList<string> Distinct(IEnumerable<string?> names) =>
names
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => MaterialListRules.Spelling(name))
.GroupBy(SharedListNames.Key)
.Select(group => group.First())
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
+26 -115
View File
@@ -1,23 +1,10 @@
namespace OpenNest.Data;
/// <summary>
/// A library drawing's editable details. Units are not among them: they give the stored
/// geometry its meaning, so changing them would rescale the part.
/// </summary>
public sealed record DrawingLibraryDetails(
string Name,
string Customer,
string MaterialName,
string MaterialGrade,
double Thickness,
CutType CutType);
/// <summary>
/// Edits one listed library drawing's details (name, customer, material, grade, thickness,
/// cut type), notes and nesting settings in place: the save is a metadata-only revision
/// guarded by the revision it was listed with, so the package (and its hash) stays the same
/// and nests keep the revision they hold. A stale revision raises
/// <see cref="DrawingLibraryConflictException"/> and changes nothing.
/// Edits one listed library drawing's notes and nesting settings in place: the save is a
/// metadata-only revision guarded by the revision it was listed with, so the package (and
/// its hash) stays the same. A stale revision raises <see cref="DrawingLibraryConflictException"/>
/// and changes nothing.
/// </summary>
public sealed class DrawingLibraryEditSession
{
@@ -39,118 +26,42 @@ public sealed class DrawingLibraryEditSession
public string SavedBy { get; }
/// <summary>The listed record's details, the starting point of an edit.</summary>
public DrawingLibraryDetails Details => new(Listed.Name, Listed.Customer, Listed.MaterialName,
Listed.MaterialGrade, Listed.Thickness, Listed.CutType);
/// <summary>
/// The record to save: the edited details (text trimmed, thickness rounded as stored),
/// the listed units, and the new notes and settings.
/// </summary>
public DrawingLibraryRecord Build(DrawingLibraryDetails details, string notes, DrawingNestingSettings settings)
/// <summary>The record to save: the listed details with new notes and settings.</summary>
public DrawingLibraryRecord Build(string notes, DrawingNestingSettings settings) => new()
{
ArgumentNullException.ThrowIfNull(details);
return new DrawingLibraryRecord
{
Id = Listed.Id,
Name = (details.Name ?? "").Trim(),
Customer = (details.Customer ?? "").Trim(),
MaterialName = (details.MaterialName ?? "").Trim(),
MaterialGrade = (details.MaterialGrade ?? "").Trim(),
Thickness = double.IsFinite(details.Thickness)
? DrawingLibraryRules.NormalizeThickness(details.Thickness)
: details.Thickness,
Units = Listed.Units,
CutType = details.CutType,
Notes = notes ?? "",
Settings = settings ?? throw new ArgumentNullException(nameof(settings)),
SavedBy = SavedBy,
};
}
Id = Listed.Id,
Name = Listed.Name,
Customer = Listed.Customer,
MaterialName = Listed.MaterialName,
MaterialGrade = Listed.MaterialGrade,
Thickness = Listed.Thickness,
Units = Listed.Units,
CutType = Listed.CutType,
Notes = notes ?? "",
Settings = settings ?? throw new ArgumentNullException(nameof(settings)),
SavedBy = SavedBy,
};
/// <summary>The first rule the edit breaks, or null when it may be saved.</summary>
public string? GetError(DrawingLibraryDetails details, string notes, DrawingNestingSettings settings) =>
DrawingLibraryRules.GetMetadataError(Build(details, notes, settings));
public string? GetError(string notes, DrawingNestingSettings settings) =>
DrawingLibraryRules.GetMetadataError(Build(notes, settings));
/// <summary>True when saving would change nothing.</summary>
public bool IsUnchanged(DrawingLibraryDetails details, string notes, DrawingNestingSettings settings) =>
SameDetails(Build(details, notes, settings), Listed)
&& (notes ?? "") == Listed.Notes && Equals(settings, Listed.Settings);
/// <summary>
/// True when the edit changes the details that identify a library drawing (D4: customer,
/// name, material, grade, thickness and cut type, normalized as duplicates are matched).
/// </summary>
public bool ChangesIdentity(DrawingLibraryDetails details) =>
!DrawingLibraryDuplicates.SameDuplicateKey(BuildDetails(details), Listed);
/// <summary>
/// The other active library drawings the edited details would duplicate (D4), newest
/// first. Empty when the edit keeps this drawing's identity, so drawings that already
/// shared it are not reported on every save.
/// </summary>
public async Task<IReadOnlyList<DrawingLibraryRecord>> FindCollisionsAsync(DrawingLibraryDetails details,
CancellationToken cancellationToken = default)
{
var record = BuildDetails(details);
if (!ChangesIdentity(details) || DrawingLibraryRules.GetMetadataError(record) != null)
return Array.Empty<DrawingLibraryRecord>();
var matches = await DrawingLibraryDuplicates.FindMatchesAsync(_library, record, cancellationToken);
return matches.Where(m => m.Id != Listed.Id).ToList();
}
/// <summary>
/// The drop-down choices for this edit: the customers, materials, grades and thicknesses
/// of the active library drawings (the newest <see cref="DrawingLibrarySaveSession.MaxScannedDrawings"/>
/// when there are more) and of the listed drawing, plus <paramref name="catalog"/>'s materials.
/// </summary>
public async Task<DrawingLibraryChoices> LoadChoicesAsync(IEnumerable<MaterialConfig>? catalog = null,
CancellationToken cancellationToken = default)
{
var records = new List<DrawingLibraryRecord>();
var offset = 0;
while (offset < DrawingLibrarySaveSession.MaxScannedDrawings)
{
var page = await _library.QueryAsync(new DrawingLibraryQuery
{
Sort = DrawingSortField.SavedAt,
Descending = true,
Offset = offset,
Limit = DrawingLibraryQuery.MaxLimit,
}, cancellationToken);
records.AddRange(page.Items);
offset += page.Items.Count;
if (page.Items.Count == 0 || offset >= page.Total)
break;
}
records.Add(Listed);
return DrawingLibraryChoices.From(records, Listed.Units, catalog);
}
// The record with the edited details and the listed notes and settings, for identity checks.
private DrawingLibraryRecord BuildDetails(DrawingLibraryDetails details) =>
Build(details, Listed.Notes, Listed.Settings ?? new DrawingNestingSettings());
public bool IsUnchanged(string notes, DrawingNestingSettings settings) =>
(notes ?? "") == Listed.Notes && Equals(settings, Listed.Settings);
/// <summary>Saves the edit as the next revision; returns the stored record.</summary>
public async Task<DrawingLibraryRecord> SaveAsync(DrawingLibraryDetails details, string notes,
DrawingNestingSettings settings, CancellationToken cancellationToken = default)
public async Task<DrawingLibraryRecord> SaveAsync(string notes, DrawingNestingSettings settings,
CancellationToken cancellationToken = default)
{
var record = Build(details, notes, settings);
var record = Build(notes, settings);
var error = DrawingLibraryRules.GetMetadataError(record);
if (error != null)
throw new ArgumentException(error, nameof(details));
throw new ArgumentException(error, nameof(settings));
var saved = await _library.UpdateMetadataAsync(Listed.Id, record, Listed.Revision, cancellationToken);
if (!string.Equals(saved.PackageSha256, Listed.PackageSha256, StringComparison.OrdinalIgnoreCase)
|| !SameDetails(saved, record) || saved.Units != record.Units
|| !Equals(saved.Settings, record.Settings) || saved.Notes != record.Notes)
throw new IOException("The nest server stored a different library drawing than was edited. Reload the library.");
return saved;
}
// Compares a built (trimmed, rounded) record with another, as stored.
private static bool SameDetails(DrawingLibraryRecord built, DrawingLibraryRecord other) =>
built.Name == other.Name.Trim() && built.Customer == other.Customer.Trim()
&& built.MaterialName == other.MaterialName.Trim() && built.MaterialGrade == other.MaterialGrade.Trim()
&& built.Thickness == DrawingLibraryRules.NormalizeThickness(other.Thickness)
&& built.CutType == other.CutType;
}
+1 -3
View File
@@ -1,7 +1,7 @@
namespace OpenNest.Data;
/// <summary>One bounded page of library drawing metadata.</summary>
public sealed class DrawingLibraryPage : IBrowsePage
public sealed class DrawingLibraryPage
{
/// <summary>At most <see cref="Limit"/> records, in the requested order.</summary>
public IReadOnlyList<DrawingLibraryRecord> Items { get; init; } = Array.Empty<DrawingLibraryRecord>();
@@ -12,6 +12,4 @@ public sealed class DrawingLibraryPage : IBrowsePage
public int Offset { get; init; }
public int Limit { get; init; }
int IBrowsePage.ItemCount => Items.Count;
}
+36 -46
View File
@@ -85,51 +85,6 @@ public sealed class DrawingLibraryDuplicates
/// <summary>True when these duplicates were found for exactly <paramref name="metadata"/>'s matching details.</summary>
public bool IsFor(DrawingLibraryRecord metadata) => SameDuplicateKey(Metadata, metadata);
/// <summary>
/// The active library drawings whose duplicate key equals <paramref name="metadata"/>'s,
/// newest first, reading every page the server filters down to; none when it is invalid.
/// </summary>
internal static async Task<List<DrawingLibraryRecord>> FindMatchesAsync(IDrawingLibrary library,
DrawingLibraryRecord metadata, CancellationToken cancellationToken)
{
var matches = new List<DrawingLibraryRecord>();
if (string.IsNullOrWhiteSpace(metadata.Name) || DrawingLibraryRules.GetMetadataError(metadata) != null)
return matches;
// The server matches customer, material and grade by the same normalized key. A
// blank one means "any" there, so every key is checked again here; the name search
// narrows by its longest word because LIKE does not collapse whitespace.
var offset = 0;
while (true)
{
var page = await library.QueryAsync(new DrawingLibraryQuery
{
Search = LongestWord(metadata.Name),
Customer = metadata.Customer,
MaterialName = metadata.MaterialName,
MaterialGrade = metadata.MaterialGrade,
Thickness = metadata.Thickness,
CutType = metadata.CutType,
Sort = DrawingSortField.SavedAt,
Descending = true,
Offset = offset,
Limit = DrawingLibraryQuery.MaxLimit,
}, cancellationToken);
matches.AddRange(page.Items.Where(r => !r.Archived && SameDuplicateKey(r, metadata)
&& matches.All(m => m.Id != r.Id)));
offset += page.Items.Count;
if (page.Items.Count == 0 || offset >= page.Total)
return matches;
if (offset >= DrawingLibrarySaveSession.MaxScannedDrawings)
throw new IOException($"More than {DrawingLibrarySaveSession.MaxScannedDrawings} library drawings share this name's details. Narrow the name or customer before saving.");
}
}
private static string LongestWord(string name) =>
name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.OrderByDescending(word => word.Length)
.FirstOrDefault() ?? "";
internal static bool SameDuplicateKey(DrawingLibraryRecord a, DrawingLibraryRecord b) =>
SharedListNames.Key(a.Customer) == SharedListNames.Key(b.Customer)
&& SharedListNames.Key(a.Name) == SharedListNames.Key(b.Name)
@@ -214,7 +169,37 @@ public sealed class DrawingLibrarySaveSession
{
ArgumentNullException.ThrowIfNull(metadata);
var snapshot = Copy(metadata);
var matches = await DrawingLibraryDuplicates.FindMatchesAsync(_library, snapshot, cancellationToken);
var matches = new List<DrawingLibraryRecord>();
if (!string.IsNullOrWhiteSpace(snapshot.Name) && DrawingLibraryRules.GetMetadataError(snapshot) == null)
{
// The server matches customer, material and grade by the same normalized key. A
// blank one means "any" there, so every key is checked again here; the name search
// narrows by its longest word because LIKE does not collapse whitespace.
var offset = 0;
while (true)
{
var page = await _library.QueryAsync(new DrawingLibraryQuery
{
Search = LongestWord(snapshot.Name),
Customer = snapshot.Customer,
MaterialName = snapshot.MaterialName,
MaterialGrade = snapshot.MaterialGrade,
Thickness = snapshot.Thickness,
CutType = snapshot.CutType,
Sort = DrawingSortField.SavedAt,
Descending = true,
Offset = offset,
Limit = DrawingLibraryQuery.MaxLimit,
}, cancellationToken);
matches.AddRange(page.Items.Where(r => !r.Archived && DrawingLibraryDuplicates.SameDuplicateKey(r, snapshot)
&& matches.All(m => m.Id != r.Id)));
offset += page.Items.Count;
if (page.Items.Count == 0 || offset >= page.Total)
break;
if (offset >= MaxScannedDrawings)
throw new IOException($"More than {MaxScannedDrawings} library drawings share this name's details. Narrow the name or customer before saving.");
}
}
DrawingLibraryRecord? linked = null;
if (Drawing.Library is { } link && link.ServerKey == ServerKey
@@ -351,6 +336,11 @@ public sealed class DrawingLibrarySaveSession
return new DrawingLibrarySaveResult(saved, outcome, recoveredEarlierSave);
}
private static string LongestWord(string name) =>
name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.OrderByDescending(word => word.Length)
.FirstOrDefault() ?? "";
private static DrawingLibraryRecord Copy(DrawingLibraryRecord record) => new()
{
Id = record.Id,
-45
View File
@@ -1,45 +0,0 @@
namespace OpenNest.Data;
/// <summary>
/// Browse state for Nest &gt; Drawing Sets: the requested search, archived toggle, sort and
/// page, and the last page applied. Paging, superseding and disposal follow
/// <see cref="BrowseSession{TPage, TSort}"/>. Call from one thread (the UI thread).
/// </summary>
public sealed class DrawingSetBrowseSession : BrowseSession<DrawingSetPage, DrawingSetSortField>
{
private readonly IDrawingSetStore _sets;
public DrawingSetBrowseSession(IDrawingSetStore sets, int pageSize = DrawingSetQuery.DefaultLimit)
: base(pageSize, DrawingSetQuery.MaxLimit, DrawingSetSortField.SavedAt)
{
ArgumentNullException.ThrowIfNull(sets);
_sets = sets;
}
/// <summary>True when archived sets are listed with the active ones.</summary>
public bool IncludeArchived { get; private set; }
/// <inheritdoc />
public override string Summary =>
PageSummary("sets", Search.Length > 0 ? "No sets match the search." : "No drawing sets yet. Choose New Set to make one.");
/// <summary>Lists or hides archived sets and returns to the first page.</summary>
public Task<bool> SetIncludeArchivedAsync(bool includeArchived)
{
IncludeArchived = includeArchived;
return FirstPageAsync();
}
protected override Task<DrawingSetPage> QueryAsync(CancellationToken cancellationToken) =>
_sets.QueryAsync(
new DrawingSetQuery
{
Search = Search,
IncludeArchived = IncludeArchived,
Sort = Sort,
Descending = Descending,
Offset = Offset,
Limit = PageSize,
},
cancellationToken);
}
-241
View File
@@ -1,241 +0,0 @@
namespace OpenNest.Data;
/// <summary>What adding library drawings to a set did.</summary>
public sealed class DrawingSetAddResult
{
internal DrawingSetAddResult(IReadOnlyList<DrawingLibraryRecord> added, IReadOnlyList<DrawingLibraryRecord> alreadyMembers)
{
Added = added;
AlreadyMembers = alreadyMembers;
}
public IReadOnlyList<DrawingLibraryRecord> Added { get; }
/// <summary>Drawings left out because the set already lists them (S6: one member per drawing).</summary>
public IReadOnlyList<DrawingLibraryRecord> AlreadyMembers { get; }
}
/// <summary>
/// Edits one drawing set (new or read from the server): its name, customer and notes and its
/// ordered members, each one library drawing with a quantity. A save of an existing set is
/// guarded by the revision it was read at, so a stale save raises
/// <see cref="DrawingSetSaveConflictException"/> and changes nothing.
/// <para>
/// A new set's id is allocated here, before the first save. When that save fails without a
/// definite answer it stays pending: the next save first reads the id, adopts the stored set
/// if it is there (then saves any later change as its next revision), and otherwise creates it
/// again under the same id. A new set is therefore never stored twice.
/// </para>
/// </summary>
public sealed class DrawingSetEditSession
{
private readonly IDrawingSetStore _sets;
private readonly List<DrawingSetMember> _members = new();
private bool _pendingCreate;
/// <param name="current">A set read with <see cref="IDrawingSetStore.GetAsync"/>, or null for a new set.</param>
public DrawingSetEditSession(IDrawingSetStore sets, DrawingSetRecord? current, string savedBy)
{
_sets = sets ?? throw new ArgumentNullException(nameof(sets));
SavedBy = savedBy ?? "";
if (current == null)
{
Id = Guid.NewGuid();
return;
}
if (current.Id == Guid.Empty || current.Revision < 1)
throw new ArgumentException("Choose a set read from the server to edit.", nameof(current));
if (current.Archived)
throw new ArgumentException("An archived set cannot be edited. Restore it first.", nameof(current));
if (current.Members is null || current.Members.Any(m => m?.Drawing is null))
throw new ArgumentException("The set's members are missing their library drawings. Reload the set.", nameof(current));
Id = current.Id;
Load(current);
}
public Guid Id { get; }
/// <summary>The store the set is read from and saved to; also reads its history.</summary>
public IDrawingSetStore Store => _sets;
public string SavedBy { get; }
/// <summary>The set as last read or saved; null until a new set is first saved.</summary>
public DrawingSetRecord? Saved { get; private set; }
public bool IsNew => Saved == null;
public string Name { get; set; } = "";
public string Customer { get; set; } = "";
public string Notes { get; set; } = "";
/// <summary>The members in order. Each carries the library record it was added or read with.</summary>
public IReadOnlyList<DrawingSetMember> Members => _members;
/// <summary>An earlier save failed without a definite answer; the next save checks it first.</summary>
public bool HasPendingSave => _pendingCreate;
/// <summary>
/// Appends each drawing not already in the set, in order, with <paramref name="quantity"/>.
/// Drawings the set already lists (or listed twice in <paramref name="drawings"/>) are skipped.
/// </summary>
public DrawingSetAddResult AddDrawings(IEnumerable<DrawingLibraryRecord> drawings, int quantity = 1)
{
ArgumentNullException.ThrowIfNull(drawings);
if (quantity < 1 || quantity > DrawingSetRules.MaxQuantity)
throw new ArgumentOutOfRangeException(nameof(quantity), quantity, $"Quantity must be 1 to {DrawingSetRules.MaxQuantity}.");
var added = new List<DrawingLibraryRecord>();
var skipped = new List<DrawingLibraryRecord>();
foreach (var drawing in drawings)
{
if (drawing is null || drawing.Id == Guid.Empty)
throw new ArgumentException("Only library drawings can be added to a set.", nameof(drawings));
if (_members.Any(m => m.DrawingId == drawing.Id))
{
skipped.Add(drawing);
continue;
}
_members.Add(new DrawingSetMember { DrawingId = drawing.Id, Quantity = quantity, Drawing = drawing });
added.Add(drawing);
}
return new DrawingSetAddResult(added, skipped);
}
public void RemoveAt(int index)
{
CheckIndex(index);
_members.RemoveAt(index);
}
/// <summary>Moves a member by <paramref name="offset"/> places; false when it is already at that end.</summary>
public bool Move(int index, int offset)
{
CheckIndex(index);
var target = index + offset;
if (target < 0 || target >= _members.Count || offset == 0)
return false;
var member = _members[index];
_members.RemoveAt(index);
_members.Insert(target, member);
return true;
}
public void SetQuantity(int index, int quantity)
{
CheckIndex(index);
if (quantity < 1 || quantity > DrawingSetRules.MaxQuantity)
throw new ArgumentOutOfRangeException(nameof(quantity), quantity, $"Quantity must be 1 to {DrawingSetRules.MaxQuantity}.");
_members[index].Quantity = quantity;
}
/// <summary>
/// Shows <paramref name="drawing"/>, a newer revision of a member's library drawing (for
/// example one just edited), on that member. Members are saved by id, so the set itself is
/// not changed; false when no member lists the drawing or it already shows that revision.
/// </summary>
public bool UpdateDrawing(DrawingLibraryRecord drawing)
{
ArgumentNullException.ThrowIfNull(drawing);
var member = _members.FirstOrDefault(m => m.DrawingId == drawing.Id);
if (member == null || (member.Drawing != null && member.Drawing.Revision >= drawing.Revision))
return false;
member.Drawing = drawing;
return true;
}
/// <summary>The record a save sends.</summary>
public DrawingSetRecord Build() => new()
{
Id = Id,
Name = Name ?? "",
Customer = Customer ?? "",
Notes = Notes ?? "",
SavedBy = SavedBy,
Members = _members.Select(m => new DrawingSetMember { DrawingId = m.DrawingId, Quantity = m.Quantity, Drawing = m.Drawing }).ToList(),
};
/// <summary>The first rule the set breaks, or null when it may be saved.</summary>
public string? GetError() => DrawingSetRules.GetError(Build());
/// <summary>True when saving would store something the server does not already hold.</summary>
public bool IsChanged => Saved == null || !SameContent(Saved, Build());
/// <summary>
/// Saves the set: revision 1 for a new set, otherwise the next revision when the one it was
/// read at is still current. On success the session holds the stored record and its members.
/// </summary>
public async Task<DrawingSetRecord> SaveAsync(CancellationToken cancellationToken = default)
{
var record = Build();
var error = DrawingSetRules.GetError(record);
if (error != null)
throw new ArgumentException(error);
if (_pendingCreate && await _sets.GetAsync(Id, cancellationToken) is { } stored)
Adopt(stored);
DrawingSetRecord saved;
if (Saved == null)
{
_pendingCreate = true;
try
{
saved = await _sets.CreateAsync(record, cancellationToken);
}
catch (DrawingSetSaveConflictException conflict)
{
// The id is this session's own: an earlier save whose reply was lost was stored.
Adopt(conflict.Current);
if (!IsChanged)
{
Load(conflict.Current);
return conflict.Current;
}
saved = await _sets.UpdateAsync(Id, record, conflict.Current.Revision, cancellationToken);
}
}
else
{
if (!IsChanged)
return Saved;
saved = await _sets.UpdateAsync(Id, record, Saved.Revision, cancellationToken);
}
_pendingCreate = false;
Load(saved);
return saved;
}
private void Adopt(DrawingSetRecord stored)
{
_pendingCreate = false;
Saved = stored;
}
private void Load(DrawingSetRecord record)
{
Saved = record;
Name = record.Name;
Customer = record.Customer;
Notes = record.Notes;
_members.Clear();
_members.AddRange(record.Members.Select(m => new DrawingSetMember { DrawingId = m.DrawingId, Quantity = m.Quantity, Drawing = m.Drawing }));
}
private void CheckIndex(int index)
{
if (index < 0 || index >= _members.Count)
throw new ArgumentOutOfRangeException(nameof(index));
}
// The server's identical-save rule: name, customer, notes and members in order.
private static bool SameContent(DrawingSetRecord a, DrawingSetRecord b) =>
a.Name == b.Name && a.Customer == b.Customer && a.Notes == b.Notes
&& a.Members.Select(m => (m.DrawingId, m.Quantity)).SequenceEqual(b.Members.Select(m => (m.DrawingId, m.Quantity)));
}
-319
View File
@@ -1,319 +0,0 @@
using System.Security.Cryptography;
namespace OpenNest.Data;
/// <summary>A drawing to import into a set: its library details, package and quantity per set.</summary>
public sealed class DrawingSetImportItem
{
public DrawingSetImportItem(string label, DrawingLibraryRecord metadata, byte[] package, long quantity)
{
Label = label ?? "";
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
Package = package ?? throw new ArgumentNullException(nameof(package));
Quantity = quantity;
}
/// <summary>How the operator knows the drawing, such as "PT01 (items 3, 7)".</summary>
public string Label { get; }
/// <summary>The details a new library drawing gets; customer, name, material, grade, thickness, units and cut type are its identity (D4).</summary>
public DrawingLibraryRecord Metadata { get; }
/// <summary>The canonical drawing package, compared by geometry with the library's.</summary>
public byte[] Package { get; }
public long Quantity { get; }
}
/// <summary>What an import does with one drawing.</summary>
public enum DrawingSetImportAction
{
/// <summary>An active library drawing with the same identity holds the same geometry: it is used as it is.</summary>
Reuse,
/// <summary>One active library drawing has the same identity but other geometry: it gains the next revision.</summary>
Revise,
/// <summary>No active library drawing has this identity: a new one is uploaded.</summary>
Create,
/// <summary>The drawing cannot be imported; <see cref="DrawingSetImportEntry.Reason"/> says why.</summary>
Skip,
}
/// <summary>One planned drawing: the action and the library drawing it uses or revises.</summary>
public sealed class DrawingSetImportEntry
{
internal DrawingSetImportEntry(DrawingSetImportItem item, DrawingSetImportAction action,
DrawingLibraryRecord? existing, string reason, Guid newId = default)
{
Item = item;
Action = action;
Existing = existing;
Reason = reason;
NewId = newId;
}
public DrawingSetImportItem Item { get; }
public DrawingSetImportAction Action { get; }
/// <summary>The library drawing reused or revised, as listed when planned.</summary>
public DrawingLibraryRecord? Existing { get; }
/// <summary>Why a skipped drawing is left out; empty otherwise.</summary>
public string Reason { get; }
/// <summary>The id a created drawing is uploaded under, allocated when planned.</summary>
public Guid NewId { get; }
/// <summary>The operator-facing account of the action, such as "New revision 4 (geometry changed)".</summary>
public string Describe() => Action switch
{
DrawingSetImportAction.Reuse => $"In the library (revision {Existing!.Revision})",
DrawingSetImportAction.Revise => $"New revision {Existing!.Revision + 1} (geometry changed)",
DrawingSetImportAction.Create => "New library drawing",
_ => "Not imported: " + Reason,
};
}
/// <summary>A library write failed part-way; <see cref="Written"/> lists what was stored before it.</summary>
public sealed class DrawingSetImportException : IOException
{
internal DrawingSetImportException(string message, IReadOnlyList<DrawingLibraryRecord> written, Exception inner)
: base(message, inner)
{
Written = written;
}
public IReadOnlyList<DrawingLibraryRecord> Written { get; }
}
/// <summary>
/// Imports drawings (for example a BOM's) into a new drawing set through the library.
/// <see cref="PlanAsync"/> only reads: it matches each drawing to the active library
/// drawings with its identity (D4: normalized customer, name, material and grade, exact
/// thickness, units and cut type) and compares geometry. Same identity and
/// geometry: reuse the drawing. Same identity, other geometry: the next revision of that
/// drawing. No match: a new drawing. Several matches with none holding the geometry are
/// skipped, as are invalid items and a second item with the identity of an earlier one.
/// <para>
/// <see cref="ApplyAsync"/> is the only step that writes, and only the uploads and
/// revisions the plan lists; the caller confirms them first. It then adds every
/// imported drawing to a <see cref="DrawingSetEditSession"/>, which stores nothing
/// until the operator saves the set. A failed write stops the import with the drawings
/// already stored listed; planning the same drawings again reuses them.
/// </para>
/// </summary>
public sealed class DrawingSetImport
{
private DrawingSetImport(IReadOnlyList<DrawingSetImportEntry> entries)
{
Entries = entries;
}
/// <summary>Every item, in the order given.</summary>
public IReadOnlyList<DrawingSetImportEntry> Entries { get; }
public IEnumerable<DrawingSetImportEntry> Imported => Entries.Where(e => e.Action != DrawingSetImportAction.Skip);
public int Count(DrawingSetImportAction action) => Entries.Count(e => e.Action == action);
/// <summary>True when applying stores anything in the library.</summary>
public bool HasWrites => Entries.Any(e => e.Action is DrawingSetImportAction.Create or DrawingSetImportAction.Revise);
/// <param name="geometryKey">
/// A key equal for packages with the same geometry (<c>OpenNest.IO.DrawingPackage.GeometryKey</c>):
/// a file read from another folder records another path, and older packages carry random
/// entity ids, so equal hashes are not required. It may
/// throw <see cref="InvalidDataException"/> for an unreadable package.
/// </param>
public static async Task<DrawingSetImport> PlanAsync(IDrawingLibrary library, IEnumerable<DrawingSetImportItem> items,
Func<byte[], string> geometryKey, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(library);
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(geometryKey);
var entries = new List<DrawingSetImportEntry>();
var planned = new List<DrawingSetImportItem>();
foreach (var item in items)
{
var reason = DrawingLibraryRules.GetMetadataError(item.Metadata)
?? DrawingLibraryRules.GetPackageError(item.Package)
?? (item.Quantity < 1 || item.Quantity > DrawingSetRules.MaxQuantity
? $"quantity {item.Quantity:N0} is outside 1 to {DrawingSetRules.MaxQuantity:N0}"
: null);
var twin = planned.FirstOrDefault(p => DrawingLibraryDuplicates.SameDuplicateKey(p.Metadata, item.Metadata));
if (reason == null && twin != null)
reason = $"same name and details as {twin.Label}; give one of them another material or thickness";
if (reason != null)
{
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Skip, null, reason));
continue;
}
planned.Add(item);
var matches = await DrawingLibraryDuplicates.FindMatchesAsync(library, item.Metadata, cancellationToken);
var same = await FindSameGeometryAsync(library, item, matches, geometryKey, cancellationToken);
if (same != null)
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Reuse, same, ""));
else if (matches.Count == 1)
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Revise, matches[0], ""));
else if (matches.Count > 1)
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Skip, null,
$"{matches.Count} library drawings have these details; add the right one in the set editor"));
else
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Create, null, "", Guid.NewGuid()));
}
return new DrawingSetImport(entries);
}
// The first match holding the item's geometry: by package hash, otherwise by downloading
// the match's current package and comparing geometry keys.
private static async Task<DrawingLibraryRecord?> FindSameGeometryAsync(IDrawingLibrary library,
DrawingSetImportItem item, IReadOnlyList<DrawingLibraryRecord> matches, Func<byte[], string> geometryKey,
CancellationToken cancellationToken)
{
if (matches.Count == 0)
return null;
var hash = Sha256(item.Package);
var same = matches.FirstOrDefault(m => string.Equals(m.PackageSha256, hash, StringComparison.OrdinalIgnoreCase));
if (same != null)
return same;
var key = geometryKey(item.Package);
foreach (var match in matches)
{
var stored = await library.GetRevisionPackageAsync(match.Id, match.Revision, cancellationToken);
if (stored == null)
continue;
try
{
if (geometryKey(stored) == key)
return match;
}
catch (InvalidDataException)
{
// An unreadable stored package is not the same geometry; revising replaces it.
}
}
return null;
}
/// <summary>The library changes <see cref="ApplyAsync"/> makes, one line each, for the operator to confirm.</summary>
public string DescribeWrites()
{
var lines = new List<string>();
var created = Entries.Where(e => e.Action == DrawingSetImportAction.Create).ToList();
var revised = Entries.Where(e => e.Action == DrawingSetImportAction.Revise).ToList();
if (created.Count > 0)
{
lines.Add($"{created.Count} new library drawing{(created.Count == 1 ? "" : "s")}:");
lines.AddRange(created.Select(e => "- " + e.Item.Label));
}
if (revised.Count > 0)
{
if (lines.Count > 0)
lines.Add("");
lines.Add($"{revised.Count} new revision{(revised.Count == 1 ? "" : "s")} of library drawings whose geometry changed:");
lines.AddRange(revised.Select(e => $"- {e.Item.Label}: revision {e.Existing!.Revision} to {e.Existing.Revision + 1}"));
}
return string.Join(Environment.NewLine, lines);
}
/// <summary>
/// Stores the planned uploads and revisions, in order, then adds every imported drawing
/// to <paramref name="edit"/> at its quantity. A revision is guarded by the revision it
/// was planned against and keeps that drawing's details, notes and settings. When a
/// write fails, <paramref name="edit"/> is left unchanged and a
/// <see cref="DrawingSetImportException"/> lists what was stored before it.
/// </summary>
public async Task<IReadOnlyList<DrawingLibraryRecord>> ApplyAsync(IDrawingLibraryStore library,
DrawingSetEditSession edit, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(library);
ArgumentNullException.ThrowIfNull(edit);
var written = new List<DrawingLibraryRecord>();
var members = new List<(DrawingLibraryRecord Record, int Quantity)>();
foreach (var entry in Imported)
{
DrawingLibraryRecord record;
try
{
record = entry.Action switch
{
DrawingSetImportAction.Create => await library.UploadAsync(Created(entry), entry.Item.Package, cancellationToken),
DrawingSetImportAction.Revise => await library.UpdatePackageAsync(entry.Existing!.Id,
Revised(entry.Existing, entry.Item.Metadata.SavedBy), entry.Item.Package, entry.Existing.Revision, cancellationToken),
_ => entry.Existing!,
};
}
catch (Exception ex) when (ex is IOException or HttpRequestException or TaskCanceledException)
{
var stored = written.Count == 0
? "Nothing was stored in the library."
: $"Already stored: {string.Join(", ", written.Select(r => r.Name))}. Importing again reuses them.";
throw new DrawingSetImportException(
$"Could not store '{entry.Item.Label}' in the drawing library: {ex.Message}{Environment.NewLine}{stored}",
written, ex);
}
var expectedId = entry.Action == DrawingSetImportAction.Create ? entry.NewId : entry.Existing!.Id;
if (record.Id != expectedId)
throw new DrawingSetImportException(
$"The nest server returned a different library drawing than '{entry.Item.Label}'. Import again to check what was stored.",
written, new IOException("Unexpected drawing id."));
if (entry.Action != DrawingSetImportAction.Reuse)
written.Add(record);
members.Add((record, (int)entry.Item.Quantity));
}
foreach (var (record, quantity) in members)
edit.AddDrawings(new[] { record }, quantity);
return written;
}
private static DrawingLibraryRecord Created(DrawingSetImportEntry entry)
{
var record = Copy(entry.Item.Metadata);
record.Id = entry.NewId;
record.Revision = 0;
record.Thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness);
record.Archived = false;
return record;
}
// The identity already matches; keep the stored spelling, notes and settings.
private static DrawingLibraryRecord Revised(DrawingLibraryRecord existing, string savedBy)
{
var record = Copy(existing);
record.Revision = 0;
record.SavedBy = savedBy;
return record;
}
private static DrawingLibraryRecord Copy(DrawingLibraryRecord record) => new()
{
Id = record.Id,
Revision = record.Revision,
Name = record.Name,
Customer = record.Customer,
MaterialName = record.MaterialName,
MaterialGrade = record.MaterialGrade,
Thickness = record.Thickness,
Units = record.Units,
CutType = record.CutType,
Notes = record.Notes,
Settings = record.Settings,
Archived = record.Archived,
SavedBy = record.SavedBy,
};
private static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
-15
View File
@@ -1,15 +0,0 @@
namespace OpenNest.Data;
/// <summary>One drawing of a set and how many of it the set needs.</summary>
public sealed class DrawingSetMember
{
public Guid DrawingId { get; set; }
public int Quantity { get; set; } = 1;
/// <summary>
/// The drawing's current record, archived or not, joined by the server when it reads
/// a set. Ignored on save.
/// </summary>
public DrawingLibraryRecord? Drawing { get; set; }
}
-17
View File
@@ -1,17 +0,0 @@
namespace OpenNest.Data;
/// <summary>One bounded page of drawing set records, without their members.</summary>
public sealed class DrawingSetPage : IBrowsePage
{
/// <summary>At most <see cref="Limit"/> records, in the requested order.</summary>
public IReadOnlyList<DrawingSetRecord> Items { get; init; } = Array.Empty<DrawingSetRecord>();
/// <summary>Number of records matching the request across all pages.</summary>
public int Total { get; init; }
public int Offset { get; init; }
public int Limit { get; init; }
int IBrowsePage.ItemCount => Items.Count;
}
-48
View File
@@ -1,48 +0,0 @@
namespace OpenNest.Data;
/// <summary>
/// One bounded page request for browsing drawing sets (<c>GET /api/sets/query</c>).
/// The server filters and pages in SQL; the client and server share these bounds.
/// </summary>
public sealed class DrawingSetQuery
{
public const int DefaultLimit = 100;
public const int MaxLimit = 500;
public const int MaxSearchLength = 200;
/// <summary>
/// Case-insensitive (ASCII) substring of name, customer or notes. Blank means no filter.
/// </summary>
public string Search { get; init; } = "";
/// <summary>Exact customer, compared by <see cref="SharedListNames.Key"/>. Blank means any.</summary>
public string Customer { get; init; } = "";
public bool IncludeArchived { get; init; }
/// <summary>Column to order by; ties are broken by id in the same direction.</summary>
public DrawingSetSortField Sort { get; init; } = DrawingSetSortField.SavedAt;
/// <summary>Largest/newest/Z first when true (the default, newest saved first).</summary>
public bool Descending { get; init; } = true;
public int Offset { get; init; }
public int Limit { get; init; } = DefaultLimit;
/// <summary>The search text as matched: trimmed, never null.</summary>
public string NormalizedSearch => (Search ?? "").Trim();
/// <summary>The first violated bound, or null when the query may be sent.</summary>
public string? GetValidationError()
{
if (!Enum.IsDefined(Sort))
return "sort must be a supported column.";
if (Offset < 0)
return "offset must be zero or greater.";
if (Limit < 1 || Limit > MaxLimit)
return $"limit must be between 1 and {MaxLimit}.";
return DrawingLibraryRules.TextError("search", NormalizedSearch, MaxSearchLength)
?? DrawingLibraryRules.TextError("customer", Customer ?? "", DrawingLibraryRules.MaxTextLength);
}
}
-39
View File
@@ -1,39 +0,0 @@
namespace OpenNest.Data;
/// <summary>
/// One revision of a drawing set: a named list of library drawings with per-member
/// quantities (a kit, an assembly, a recurring order). Members reference drawings by id;
/// the set always means each drawing's current revision.
/// </summary>
public sealed class DrawingSetRecord
{
/// <summary>Allocated by the client before the first save; kept by every revision.</summary>
public Guid Id { get; set; }
/// <summary>Immutable revision number, starting at 1. Ignored on save.</summary>
public int Revision { get; set; }
public string Name { get; set; } = "";
public string Customer { get; set; } = "";
public string Notes { get; set; } = "";
/// <summary>Hidden from browsing by default; still readable by id.</summary>
public bool Archived { get; set; }
/// <summary>When the server stored this revision. Server-computed.</summary>
public DateTime SavedAt { get; set; }
/// <summary>Who saved this revision, as reported by the client.</summary>
public string SavedBy { get; set; } = "";
/// <summary>Number of members in this revision. Server-computed.</summary>
public int MemberCount { get; set; }
/// <summary>
/// The members in order, one per drawing. Empty on query and history pages, which
/// carry <see cref="MemberCount"/> only.
/// </summary>
public IReadOnlyList<DrawingSetMember> Members { get; set; } = Array.Empty<DrawingSetMember>();
}
Loaded 100 of 620 files, more files were not shown because too many files have changed in this diff. Show more