mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 03:51:05 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e767ca3b6 | ||
|
|
e18304066d | ||
|
|
dcb0a5c519 | ||
|
|
1592d4bfd9 | ||
|
|
857a560dce | ||
|
|
69a83b3ad9 | ||
|
|
a3a75d589d | ||
|
|
a3b7c715d5 | ||
|
|
16afb41c26 | ||
|
|
2e72469d6c | ||
|
|
3dd1acd3cf | ||
|
|
9a62a5102d | ||
|
|
223bfea47c | ||
|
|
d1270f5208 | ||
|
|
fe31a3f8ec | ||
|
|
a88c056f4b | ||
|
|
81ec6cc738 | ||
|
|
11b04cc272 | ||
|
|
6a9b81326e | ||
|
|
37cdc52f0d | ||
|
|
d3d7bc0299 | ||
|
|
3884d058ec | ||
|
|
27610c3b1d | ||
|
|
1b99a7c866 | ||
|
|
6e40c2e6ed | ||
|
|
0ba5539fab | ||
|
|
2e17dd7bc4 | ||
|
|
86b0370a8f | ||
|
|
e5e2c268b8 | ||
|
|
7f1c5a3854 | ||
|
|
96d8a6874f | ||
|
|
fecfcc337e | ||
|
|
b0697f92b3 | ||
|
|
98f8e98230 | ||
|
|
5611d9a663 | ||
|
|
5761eefd37 | ||
|
|
9f335ea111 | ||
|
|
0c7ad5aefb | ||
|
|
73a7c8d6a2 | ||
|
|
479883e7ac | ||
|
|
07499fe1cd | ||
|
|
1b1f86085d | ||
|
|
36c8de825e | ||
|
|
ccd361dfa9 | ||
|
|
7c4276a457 | ||
|
|
14ba2e653b | ||
|
|
18612e698d | ||
|
|
90e2fd2e6e | ||
|
|
9f3c22dc4f | ||
|
|
84e6adbcf2 | ||
|
|
b35490addf | ||
|
|
1fbe57a841 | ||
|
|
1bb58117af | ||
|
|
70e8666335 | ||
|
|
2d8d877482 | ||
|
|
610b9dafcb | ||
|
|
079dfaf9ea | ||
|
|
c27188c722 | ||
|
|
548522775f | ||
|
|
be01124a0f | ||
|
|
f847b216f2 | ||
|
|
23f093fcc1 | ||
|
|
15750949dd | ||
|
|
ea1decc097 | ||
|
|
42f1762c56 | ||
|
|
455c543501 | ||
|
|
4b982e8eeb | ||
|
|
0d369c9a51 | ||
|
|
cbf3b728aa | ||
|
|
fa70713bfc | ||
|
|
ba55d5bff0 | ||
|
|
f600c5e58b | ||
|
|
27796851f3 | ||
|
|
d4269c1f06 | ||
|
|
8cd9b2cbe3 | ||
|
|
b341848e07 | ||
|
|
33f60d3dcb | ||
|
|
ad3443f60d | ||
|
|
ebecd42433 | ||
|
|
3f5d402489 | ||
|
|
a6056a6065 | ||
|
|
dfa6ccb954 | ||
|
|
87672543cd | ||
|
|
a9198e5c25 | ||
|
|
6a556b75ab | ||
|
|
b376d217dd | ||
|
|
49bb7389f7 | ||
|
|
ee208b2766 | ||
|
|
0d1dade063 | ||
|
|
48863d51c8 | ||
|
|
cd1961e26b | ||
|
|
414479fc4e | ||
|
|
bc21382c5d | ||
|
|
ba2102c793 | ||
|
|
4b312a552d | ||
|
|
a3caff6e40 | ||
|
|
6b63d41030 | ||
|
|
9c78b4ebec | ||
|
|
993555dba6 | ||
|
|
13352b8acd | ||
|
|
7ce5e73a82 | ||
|
|
a00e2c3233 | ||
|
|
0785bb84c9 | ||
|
|
45592a811c | ||
|
|
39f28c7fa9 | ||
|
|
07e7d7edcd | ||
|
|
bbadd6aa75 | ||
|
|
c188014a30 | ||
|
|
069efc2368 | ||
|
|
ed92c37845 | ||
|
|
00cb639c8e | ||
|
|
9e4361fd11 | ||
|
|
3c3bab0a9b | ||
|
|
6201065034 | ||
|
|
684207039a | ||
|
|
8f568482bc | ||
|
|
73e54fbd67 | ||
|
|
b45e9df593 | ||
|
|
6e3705eb89 | ||
|
|
c325a1745a | ||
|
|
dc4fc1d593 | ||
|
|
d4903a648c | ||
|
|
9d69c89b21 | ||
|
|
939d7bfff2 | ||
|
|
8fd1fabc54 | ||
|
|
54b15cff19 | ||
|
|
f657292801 | ||
|
|
8f27eac2ca | ||
|
|
ff46de69df | ||
|
|
1fc36e9e54 | ||
|
|
099a020424 | ||
|
|
c58ae6f9e1 | ||
|
|
63771b0029 | ||
|
|
09d58ce064 | ||
|
|
64eca9775f | ||
|
|
d1c77b8ec7 | ||
|
|
35359906fe | ||
|
|
5ecdc98581 | ||
|
|
3ca7d6185f | ||
|
|
f6781d27c3 | ||
|
|
74203483c5 | ||
|
|
4b604d3874 | ||
|
|
28db6ae807 | ||
|
|
19809c61d0 | ||
|
|
382abf702b | ||
|
|
2963764215 | ||
|
|
64e24fc6c8 | ||
|
|
0eb6d5886a | ||
|
|
9ab6fbbeaf | ||
|
|
8c7dc41ce8 | ||
|
|
c3881c5534 | ||
|
|
7bf753ce17 | ||
|
|
11b6032459 | ||
|
|
0ca4e64189 | ||
|
|
f901840ed0 | ||
|
|
059a873d32 | ||
|
|
0922e41401 | ||
|
|
ed9c1f9df6 | ||
|
|
50f966913d | ||
|
|
96f02c665c | ||
|
|
aeb90d8a86 | ||
|
|
f51e5aefdd | ||
|
|
a7c0747e11 | ||
|
|
b65876c031 | ||
|
|
cc731ff3d6 | ||
|
|
1ccba9b910 | ||
|
|
af6975b9f1 | ||
|
|
44666b289d | ||
|
|
be057bf337 | ||
|
|
28ee04a480 | ||
|
|
1b323e2380 | ||
|
|
2d74d09023 | ||
|
|
cc2b3d1b81 | ||
|
|
63233db108 | ||
|
|
abfc1a714a | ||
|
|
efa4844049 |
No files matched your search
@@ -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/`.
|
||||
- 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.
|
||||
- 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,17 +38,18 @@ 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`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory.
|
||||
- `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.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 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.
|
||||
- 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.
|
||||
- `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.
|
||||
@@ -57,13 +58,18 @@ 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) 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) (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
|
||||
- [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.
|
||||
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
@@ -0,0 +1,30 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<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>
|
||||
@@ -0,0 +1,139 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
@@ -0,0 +1,18 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -37,6 +38,12 @@ 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; }
|
||||
|
||||
@@ -60,7 +67,8 @@ 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.
|
||||
/// longer picks plate sizes on the engine's behalf. Stock comes from
|
||||
/// the same template builder as desktop Auto Nest, so clamps match it.
|
||||
/// </summary>
|
||||
public NestJob BuildNestJob(
|
||||
int maxPlates,
|
||||
@@ -71,14 +79,13 @@ namespace OpenNest.Benchmark
|
||||
var parts = Requests.Select(r =>
|
||||
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
|
||||
);
|
||||
var stock = CandidateSizes.Select(size => new NestPlateStock(
|
||||
size.ToString(1),
|
||||
size,
|
||||
null,
|
||||
PartSpacing,
|
||||
EdgeSpacing,
|
||||
Quadrant
|
||||
));
|
||||
// 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()
|
||||
);
|
||||
return new NestJob(
|
||||
parts,
|
||||
stock,
|
||||
@@ -90,5 +97,22 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,14 +126,7 @@ namespace OpenNest.Benchmark
|
||||
.BaselinePlateRuns.Select(
|
||||
(run, index) =>
|
||||
{
|
||||
var stock = new NestPlateStock(
|
||||
$"baseline-{index}",
|
||||
run.Plate.Size,
|
||||
1,
|
||||
run.Plate.PartSpacing,
|
||||
run.Plate.EdgeSpacing,
|
||||
run.Plate.Quadrant
|
||||
);
|
||||
var stock = DrawingJobMapper.FromPlate($"baseline-{index}", run.Plate, 1);
|
||||
var placements = run
|
||||
.Parts.Select(part =>
|
||||
{
|
||||
|
||||
@@ -74,6 +74,8 @@ 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,
|
||||
@@ -182,23 +184,45 @@ 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) ResolvePlateTemplate(
|
||||
Nest nest
|
||||
)
|
||||
private static (
|
||||
Spacing EdgeSpacing,
|
||||
double PartSpacing,
|
||||
int Quadrant,
|
||||
ClampRail ClampRail,
|
||||
IReadOnlyList<ClampZone> ClampZones
|
||||
) ResolvePlateTemplate(Nest nest)
|
||||
{
|
||||
var source = nest.Plates?.FirstOrDefault();
|
||||
|
||||
if (source != null)
|
||||
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
|
||||
return (
|
||||
source.EdgeSpacing,
|
||||
source.PartSpacing,
|
||||
source.Quadrant,
|
||||
source.ClampRail,
|
||||
source.ClampRail == null ? source.ClampZones.ToList() : Array.Empty<ClampZone>()
|
||||
);
|
||||
|
||||
var defaults = nest.PlateDefaults;
|
||||
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
|
||||
return (
|
||||
defaults.EdgeSpacing,
|
||||
defaults.PartSpacing,
|
||||
defaults.Quadrant,
|
||||
defaults.ClampRail,
|
||||
Array.Empty<ClampZone>()
|
||||
);
|
||||
}
|
||||
|
||||
private static List<Size> ResolveSheetSizes(Nest nest)
|
||||
|
||||
@@ -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 StockLadder require --autonest)"
|
||||
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as Irregular require --autonest)"
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -824,6 +824,7 @@ 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 + "...",
|
||||
});
|
||||
|
||||
@@ -27,6 +27,14 @@ 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
|
||||
@@ -39,6 +47,8 @@ public enum AutomaticCutOffDiagnosticCode
|
||||
NoSafeTailSeparator,
|
||||
TailBelowMinimum,
|
||||
ShiftedCut,
|
||||
HorizontalRemnant,
|
||||
NoSafeHorizontalSeparator,
|
||||
}
|
||||
|
||||
public sealed record AutomaticCutOffDiagnostic(
|
||||
@@ -77,6 +87,15 @@ 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; }
|
||||
|
||||
@@ -90,6 +109,15 @@ 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;
|
||||
@@ -115,7 +143,9 @@ 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)
|
||||
{
|
||||
@@ -123,7 +153,9 @@ public static class AutomaticCutOffPlanner
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign));
|
||||
var (partX, partY) = ValidatePart(part, bounds, sign, ySign);
|
||||
occupied = System.Math.Max(occupied, partX);
|
||||
occupiedY = System.Math.Max(occupiedY, partY);
|
||||
hasParts = true;
|
||||
}
|
||||
if (!hasParts)
|
||||
@@ -131,16 +163,62 @@ 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);
|
||||
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
|
||||
+ GeometryTolerance;
|
||||
occupiedY = System.Math.Min(occupiedY, width);
|
||||
var margin = System.Math.Max(plate.PartSpacing, settings.PartClearance) + GeometryTolerance;
|
||||
var separator = occupied + margin;
|
||||
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.
|
||||
var keepsTail = hasTailCandidate && length - separator >= options.MinimumTailLength;
|
||||
// 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);
|
||||
var usedSpan = keepsTail ? separator : length;
|
||||
var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
|
||||
// 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);
|
||||
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
|
||||
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
|
||||
|
||||
@@ -148,7 +226,31 @@ public static class AutomaticCutOffPlanner
|
||||
ValidateExisting(plate, bounds, settings);
|
||||
var cache = Plate.BuildPerimeterCache(plate);
|
||||
var definitions = new List<CutOff>();
|
||||
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
||||
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 separated = false;
|
||||
var separatorX = (double?)null;
|
||||
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
|
||||
@@ -162,7 +264,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 && FullSpanLimits(c, bounds, settings))
|
||||
.Where(c => c.Axis == CutOffAxis.Vertical && Spans(c))
|
||||
.Select(c => sign * c.Position.X)
|
||||
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
|
||||
.Distinct().OrderBy(distance => distance).Append(usedSpan);
|
||||
@@ -212,6 +314,15 @@ 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.
|
||||
@@ -219,12 +330,24 @@ 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(),
|
||||
@@ -236,8 +359,22 @@ 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);
|
||||
@@ -290,8 +427,7 @@ public static class AutomaticCutOffPlanner
|
||||
RegisterCandidate(distance);
|
||||
if (!measurements.TryGetValue(distance, out var measured))
|
||||
{
|
||||
measured = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical)
|
||||
.MeasureSegments(plate, settings, cache);
|
||||
measured = NewVertical(sign * distance).MeasureSegments(plate, settings, cache);
|
||||
measurements.Add(distance, measured);
|
||||
}
|
||||
return measured;
|
||||
@@ -302,7 +438,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 => !FullSpanLimits(c, bounds, settings)))
|
||||
if (matches.Any(c => !Spans(c)))
|
||||
{
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
|
||||
@@ -335,7 +471,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
|
||||
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
|
||||
? NewVertical(x)
|
||||
: new CutOff(existing.Position, existing.Axis)
|
||||
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
|
||||
candidate.Regenerate(plate, settings, cache);
|
||||
@@ -353,7 +489,7 @@ public static class AutomaticCutOffPlanner
|
||||
|
||||
if (isSeparator)
|
||||
{
|
||||
if (!usable || !IsFullSpanProgram(program, bounds))
|
||||
if (!usable || !IsFullSpanProgram(program, true, spanLow, spanHigh))
|
||||
{
|
||||
WarnNoSeparator(x);
|
||||
return;
|
||||
@@ -361,7 +497,7 @@ public static class AutomaticCutOffPlanner
|
||||
separated = true;
|
||||
separatorX = candidate.Position.X;
|
||||
}
|
||||
else if (usable && !IsFullSpanProgram(program, bounds))
|
||||
else if (usable && !IsFullSpanProgram(program, true, spanLow, spanHigh))
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.SegmentedCut,
|
||||
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
|
||||
@@ -414,12 +550,14 @@ 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 ValidatePart(Part part, Box sheet, int sign)
|
||||
private static (double X, double Y) ValidatePart(Part part, Box sheet, int sign, int ySign)
|
||||
{
|
||||
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
|
||||
"Part pose must be finite.", "plate");
|
||||
@@ -432,19 +570,32 @@ 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");
|
||||
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
||||
var hasDisplayPerimeter = DisplayPerimeterTail.HasDisplayPerimeterMotion(part.Program);
|
||||
Require(Inside(box, sheet, hasDisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
|
||||
"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);
|
||||
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var outline = entities.Where(e => e.Layer == SpecialLayers.DisplayPerimeter).ToList();
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
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
|
||||
@@ -452,12 +603,15 @@ 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), "Part geometry extends outside the physical sheet.", "plate");
|
||||
Require(Inside(entityBox, sheet,
|
||||
entity.Layer == SpecialLayers.DisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
|
||||
"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;
|
||||
return (occupied, occupiedY);
|
||||
}
|
||||
|
||||
private static void ValidateProgram(Program program, HashSet<Program> path)
|
||||
@@ -502,7 +656,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)
|
||||
private static bool HasUsableSegments(Program program, bool vertical = true)
|
||||
{
|
||||
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
|
||||
return false;
|
||||
@@ -510,30 +664,49 @@ 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) ||
|
||||
!Near(from.EndPoint.X, to.EndPoint.X) ||
|
||||
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance)
|
||||
(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)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFullSpanProgram(Program program, Box bounds)
|
||||
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)
|
||||
{
|
||||
// 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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
// 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 Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
|
||||
|
||||
@@ -21,12 +21,17 @@ namespace OpenNest.BestFit
|
||||
IReadOnlyList<double> HullAngles
|
||||
);
|
||||
|
||||
/// <summary>The saved best-fit results for one drawing at one plate size and spacing.</summary>
|
||||
/// <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>
|
||||
public sealed record SavedPairSet(
|
||||
double PlateWidth,
|
||||
double PlateHeight,
|
||||
double Spacing,
|
||||
IReadOnlyList<SavedPairResult> Results
|
||||
IReadOnlyList<SavedPairResult> Results,
|
||||
double? CanonicalAngle = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
@@ -45,7 +50,10 @@ namespace OpenNest.BestFit
|
||||
IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys
|
||||
);
|
||||
|
||||
/// <summary>Restores saved sets for <paramref name="drawing"/>.</summary>
|
||||
/// <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>
|
||||
void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets);
|
||||
}
|
||||
}
|
||||
@@ -238,7 +238,7 @@ namespace OpenNest.CNC
|
||||
builder.PartIndex = i;
|
||||
var mode = Mode.Absolute;
|
||||
var pos = part.Location;
|
||||
builder.AddProgram(part.Program, part.Location, ref mode, ref pos);
|
||||
builder.AddProgram(DisplayPerimeterTail.WithoutTail(part.Program), part.Location, ref mode, ref pos);
|
||||
}
|
||||
|
||||
var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
|
||||
|
||||
@@ -36,6 +36,35 @@ 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,23 +55,24 @@ internal static class ContourProgramVerifier
|
||||
var first = run.Curves[0];
|
||||
var ringIndex = -1;
|
||||
var entityIndex = -1;
|
||||
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
|
||||
// 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)))
|
||||
{
|
||||
Query(token, ref budget);
|
||||
var admitted = AdmittedClosureSlack(material.Rings[r]);
|
||||
for (var e = 0; e < material.Rings[r].Length; e++)
|
||||
var curve = material.Rings[r][e];
|
||||
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
|
||||
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
||||
{
|
||||
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;
|
||||
}
|
||||
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))
|
||||
{
|
||||
@@ -108,7 +109,7 @@ internal static class ContourProgramVerifier
|
||||
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
||||
true, tolerance, token, ref budget)) return false;
|
||||
}
|
||||
return visited.Count == material.Rings.Count;
|
||||
return visited.Count == material.Rings.Count - (material.HasDisplayPerimeter ? 1 : 0);
|
||||
}
|
||||
|
||||
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
|
||||
|
||||
@@ -22,20 +22,39 @@ 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<PlannedPartProgram> programs = null, IEnumerable<PlannedCutOffProgram> cutOffs = 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
|
||||
@@ -161,27 +180,49 @@ 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, reordered || programs.Count != 0));
|
||||
staged.Add(new(plate, order, programs, cutOffs, reordered || programs.Count != 0 || cutOffs.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)>();
|
||||
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States,
|
||||
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
|
||||
try
|
||||
{
|
||||
foreach (var item in staged)
|
||||
{
|
||||
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
|
||||
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).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.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)
|
||||
@@ -190,6 +231,11 @@ 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.",
|
||||
@@ -208,5 +254,6 @@ 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, bool Changed);
|
||||
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs,
|
||||
List<(Part Segment, Program Program, CutOff CutOff)> CutOffs, bool Changed);
|
||||
}
|
||||
@@ -48,6 +48,15 @@ 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 ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab };
|
||||
&& 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 };
|
||||
|
||||
private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
|
||||
{
|
||||
|
||||
@@ -12,8 +12,9 @@ namespace OpenNest.CNC.CuttingPlanning;
|
||||
public sealed class LeadMaterialSnapshot
|
||||
{
|
||||
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
|
||||
string reason)
|
||||
string reason, bool displayPerimeter = false)
|
||||
{
|
||||
HasDisplayPerimeter = displayPerimeter;
|
||||
Rings = rings;
|
||||
Tabs = tabs;
|
||||
Reason = reason;
|
||||
@@ -41,6 +42,12 @@ public sealed class LeadMaterialSnapshot
|
||||
/// </summary>
|
||||
internal IReadOnlyList<bool[]> Tabs { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the perimeter (<see cref="Rings"/>[0]) is a display perimeter: a supplied blank's
|
||||
/// edge that bounds material but is never cut. Every other ring is a hole the program cuts.
|
||||
/// </summary>
|
||||
internal bool HasDisplayPerimeter { get; }
|
||||
|
||||
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
|
||||
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
|
||||
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
|
||||
@@ -53,6 +60,8 @@ public sealed class LeadMaterialSnapshot
|
||||
var ringTabs = new List<bool[]>();
|
||||
var chain = new List<PostVerificationGeometry.Curve>();
|
||||
var chainTabs = new List<bool>();
|
||||
var ringDisplay = new List<bool>();
|
||||
var chainDisplay = new List<bool>();
|
||||
foreach (var motion in execution.Motions)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -63,7 +72,8 @@ public sealed class LeadMaterialSnapshot
|
||||
continue;
|
||||
}
|
||||
// A tabbed cutout's tabs are never cut but still close its hole.
|
||||
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab))
|
||||
// A display perimeter is never cut but still bounds the blank's material.
|
||||
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter))
|
||||
throw new ArgumentException("Material capture contains an unsupported layer.");
|
||||
var curve = motion.Curve;
|
||||
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
|
||||
@@ -72,6 +82,7 @@ public sealed class LeadMaterialSnapshot
|
||||
throw new ArgumentException("Material contour is discontinuous.");
|
||||
chain.Add(curve);
|
||||
chainTabs.Add(motion.Layer == LayerType.Tab);
|
||||
chainDisplay.Add(motion.Layer == LayerType.DisplayPerimeter);
|
||||
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||
Finish();
|
||||
}
|
||||
@@ -80,37 +91,36 @@ public sealed class LeadMaterialSnapshot
|
||||
throw new ArgumentException("Material has no closed contour.");
|
||||
var budget = 1000000;
|
||||
// Certify simple rings and mutually disjoint boundaries before containment.
|
||||
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}).");
|
||||
}
|
||||
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}).");
|
||||
}
|
||||
var outer = -1;
|
||||
for (var i = 0; i < rings.Count; i++)
|
||||
{
|
||||
@@ -123,16 +133,22 @@ public sealed class LeadMaterialSnapshot
|
||||
}
|
||||
}
|
||||
if (outer < 0)
|
||||
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
|
||||
throw new ArgumentException(ringDisplay.Contains(true)
|
||||
? "A display perimeter must enclose every other contour."
|
||||
: "Material must have exactly one enclosing perimeter.");
|
||||
if (ringTabs[outer].Any(tab => tab))
|
||||
throw new ArgumentException("A perimeter cannot have tabs.");
|
||||
if (ringDisplay.Where((_, i) => i != outer).Any(display => display))
|
||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||
if (ringDisplay[outer] && rings.Count == 1)
|
||||
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
||||
var holes = rings.Where((_, i) => i != outer).ToArray();
|
||||
for (var i = 0; i < holes.Length; i++)
|
||||
for (var j = i + 1; j < holes.Length; j++)
|
||||
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
|
||||
throw new ArgumentException("Nested cutouts are not supported material.");
|
||||
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
|
||||
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null);
|
||||
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
|
||||
|
||||
void Finish()
|
||||
{
|
||||
@@ -145,10 +161,14 @@ public sealed class LeadMaterialSnapshot
|
||||
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
|
||||
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
|
||||
}
|
||||
if (chainDisplay.Contains(true) && chainDisplay.Contains(false))
|
||||
throw new ArgumentException("A display perimeter must be one whole closed contour.");
|
||||
rings.Add(chain.ToArray());
|
||||
ringTabs.Add(chainTabs.ToArray());
|
||||
ringDisplay.Add(chainDisplay.Count > 0 && chainDisplay[0]);
|
||||
chain.Clear();
|
||||
chainTabs.Clear();
|
||||
chainDisplay.Clear();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
@@ -157,18 +177,182 @@ public sealed class LeadMaterialSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
/// <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)
|
||||
{
|
||||
var inside = false;
|
||||
foreach (var curve in ring)
|
||||
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)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.CrossesRay(point))
|
||||
inside = !inside;
|
||||
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));
|
||||
}
|
||||
return 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;
|
||||
}
|
||||
|
||||
/// <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,7 +125,8 @@ 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;
|
||||
foreach (var boundary in target.Rings.SelectMany(r => r))
|
||||
// A boundary clear of the contour's first motion contains none of its points.
|
||||
foreach (var boundary in target.BoundariesNear(adjacent.Curve.Extent))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
@@ -190,10 +191,7 @@ 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);
|
||||
}
|
||||
@@ -232,11 +230,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, charged as if it ran.
|
||||
// Ray parity is still asked natively unless the lead lies beside the material's rows.
|
||||
// (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.
|
||||
bool FarInside(LeadMaterialSnapshot material)
|
||||
{
|
||||
Charge(material.BoundaryCount);
|
||||
var midpoint = move.Curve.Midpoint;
|
||||
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
|
||||
}
|
||||
@@ -289,7 +287,8 @@ public static class LeadPathValidator
|
||||
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
|
||||
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
|
||||
{
|
||||
foreach (var boundary in material.Rings.SelectMany(r => r))
|
||||
// Boundaries no native query from the curve can reach report no contact; only the others run.
|
||||
foreach (var boundary in material.BoundariesNear(curve.Extent))
|
||||
{
|
||||
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 && m.Curve != null)
|
||||
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter && 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,7 +71,8 @@ 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) },
|
||||
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn
|
||||
{ Radius = Dimension(n.Radius), SweepAngle = Sweep(n.SweepAngle) },
|
||||
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
|
||||
@@ -137,4 +138,7 @@ 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,7 +82,8 @@ 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)).ToArray();
|
||||
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit,
|
||||
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
|
||||
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
||||
|
||||
void Fingerprint(CuttingParameters parameters)
|
||||
@@ -155,7 +156,8 @@ 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.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)
|
||||
|| !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
|
||||
return $"Cutoff {i + 1} definition changed.";
|
||||
}
|
||||
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
|
||||
@@ -204,5 +206,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? StartLimit, double? EndLimit, double HeadLockOvercut, bool FarEdgeFirst);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Threading;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Support;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
@@ -14,9 +15,11 @@ public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector
|
||||
internal PreparedContours Owner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public Vector? Pierce { get; internal init; }
|
||||
}
|
||||
@@ -41,10 +44,26 @@ 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;
|
||||
@@ -55,7 +74,8 @@ public sealed class PreparedContours
|
||||
}
|
||||
|
||||
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
|
||||
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters)
|
||||
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters,
|
||||
unpinned.displayOutline)
|
||||
{
|
||||
contourParameters = new CuttingParameters[shapes.Length];
|
||||
var rejected = new List<CutPinResolution>();
|
||||
@@ -78,7 +98,9 @@ public sealed class PreparedContours
|
||||
rejected.Add(pin);
|
||||
continue;
|
||||
}
|
||||
var choice = new ContourChoice(contour, pin.EntityOrdinal, pin.Point) { Owner = this };
|
||||
var choice = new ContourChoice(contour, pin.EntityOrdinal,
|
||||
SnapToVertex(shapes[contour], pin.EntityOrdinal, pin.Point))
|
||||
{ Owner = this };
|
||||
ValidateChoice(choice);
|
||||
pinned[contour] = choice;
|
||||
pinNumbers[contour] = pin.PinIndex + 1;
|
||||
@@ -87,17 +109,83 @@ 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();
|
||||
public int PerimeterOrdinal => Count - 1;
|
||||
/// <summary>
|
||||
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
|
||||
/// is a display perimeter, so every contour below it is a hole.
|
||||
/// </summary>
|
||||
public int PerimeterOrdinal => hasCutPerimeter ? Count - 1 : Count;
|
||||
|
||||
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
|
||||
public bool HasCutPerimeter => hasCutPerimeter;
|
||||
|
||||
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
|
||||
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
||||
|
||||
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
||||
public bool IsPinned(int contourOrdinal) => GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
|
||||
public bool IsPinned(int contourOrdinal) => !(contourOrdinal == PerimeterOrdinal && !hasCutPerimeter)
|
||||
&& GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
|
||||
|
||||
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
|
||||
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
|
||||
@@ -122,17 +210,15 @@ public sealed class PreparedContours
|
||||
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
|
||||
}
|
||||
|
||||
// The pin's leads replace every contour type's leads, since the type the emitter detects is
|
||||
// The pin's leads replace both lead sets, 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)
|
||||
@@ -155,6 +241,7 @@ public sealed class PreparedContours
|
||||
var scribes = new List<Entity>();
|
||||
var breakCuts = new List<Shape>();
|
||||
var tabbedCutouts = new List<Shape>();
|
||||
Shape displayPerimeter = null;
|
||||
Shape breakCut = null;
|
||||
var current = new Shape();
|
||||
foreach (var entity in geometryProgram.ToGeometry())
|
||||
@@ -185,7 +272,8 @@ public sealed class PreparedContours
|
||||
scribes.Add(entity);
|
||||
continue;
|
||||
}
|
||||
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab)
|
||||
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab
|
||||
&& entity.Layer != SpecialLayers.DisplayPerimeter)
|
||||
throw new ArgumentException("Preparation requires clean cut/display contours.");
|
||||
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
||||
throw new ArgumentException("Degenerate contour entity.");
|
||||
@@ -203,6 +291,28 @@ 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;
|
||||
@@ -223,7 +333,15 @@ public sealed class PreparedContours
|
||||
void FinishContour()
|
||||
{
|
||||
current.UpdateBounds();
|
||||
if (ContourCuttingStrategy.IsTabbedCutout(current))
|
||||
if (current.Entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
||||
{
|
||||
if (!current.Entities.TrueForAll(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
||||
throw new ArgumentException("A display perimeter must be one whole closed contour.");
|
||||
if (displayPerimeter != null)
|
||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||
displayPerimeter = current;
|
||||
}
|
||||
else if (ContourCuttingStrategy.IsTabbedCutout(current))
|
||||
{
|
||||
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
|
||||
throw new ArgumentException("A tabbed cutout has nothing to cut.");
|
||||
@@ -285,8 +403,12 @@ public sealed class PreparedContours
|
||||
void Add(int entityOrdinal, Vector point)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
|
||||
entries.Add(Entry(contourOrdinal, entityOrdinal, point));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +433,7 @@ 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;
|
||||
@@ -323,12 +446,61 @@ 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)
|
||||
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)
|
||||
{
|
||||
PostVerificationGeometry.Validate(pierce);
|
||||
var shape = GetShape(PerimeterOrdinal);
|
||||
var shape = GetShape(contourOrdinal);
|
||||
var point = shape.ClosestPointTo(pierce, out var entity);
|
||||
var choice = new ContourChoice(PerimeterOrdinal, shape.Entities.IndexOf(entity), point)
|
||||
point = SnapToVertex(shape, shape.Entities.IndexOf(entity), point);
|
||||
var choice = new ContourChoice(contourOrdinal, shape.Entities.IndexOf(entity), point)
|
||||
{
|
||||
Owner = this,
|
||||
Pierce = pierce,
|
||||
@@ -355,7 +527,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 MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
|
||||
return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -372,7 +544,8 @@ public sealed class PreparedContours
|
||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
||||
int contourOrdinal,
|
||||
Vector? lookAhead = null,
|
||||
CancellationToken token = default)
|
||||
CancellationToken token = default,
|
||||
SupportEntries supportEntries = SupportEntries.Steadying)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
@@ -382,7 +555,45 @@ public sealed class PreparedContours
|
||||
? new List<ContourEntryCandidate>()
|
||||
: PreferredCandidates(shape, contourOrdinal, token);
|
||||
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
|
||||
return MergeByGeometry(all);
|
||||
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);
|
||||
}
|
||||
|
||||
private static bool IsSingleCircle(Shape shape) =>
|
||||
@@ -475,9 +686,10 @@ 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.
|
||||
// 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.
|
||||
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
|
||||
if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
|
||||
if (t <= OpenNest.Math.Tolerance.Epsilon || t >= line.Length - OpenNest.Math.Tolerance.Epsilon)
|
||||
return;
|
||||
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
|
||||
}
|
||||
@@ -609,7 +821,7 @@ public sealed class PreparedContours
|
||||
public Program Emit(IReadOnlyList<ContourChoice> choices)
|
||||
{
|
||||
Validate(choices, true);
|
||||
return EmitPrefix(choices);
|
||||
return EmitOwned(choices, complete: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -626,21 +838,28 @@ 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.
|
||||
// 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();
|
||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
|
||||
contourParameters);
|
||||
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal);
|
||||
}
|
||||
|
||||
// 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 new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
|
||||
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(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
|
||||
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
|
||||
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList());
|
||||
}
|
||||
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal,
|
||||
complete ? (Shape)displayOutline?.Clone() : null);
|
||||
|
||||
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
|
||||
public int BreakCutCount => breakCuts.Count;
|
||||
@@ -661,6 +880,8 @@ public sealed class PreparedContours
|
||||
token.ThrowIfCancellationRequested();
|
||||
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
|
||||
// Only the outside is tabbed, with its own pin's tab when it is pinned.
|
||||
if (!hasCutPerimeter)
|
||||
return new(this, choices, expected, 0);
|
||||
var perimeter = ParametersFor(PerimeterOrdinal);
|
||||
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
|
||||
}
|
||||
@@ -695,10 +916,8 @@ 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 scrap pierce must lie off its contour.");
|
||||
throw new ArgumentException("A planner-placed pierce must lie off its contour.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,6 +941,9 @@ 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.
|
||||
@@ -733,6 +955,33 @@ 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,6 +12,11 @@ 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) { }
|
||||
|
||||
@@ -24,7 +29,11 @@ public sealed class ReleasedContourState
|
||||
|
||||
public ReleasedContourState Copy()
|
||||
{
|
||||
var copy = new ReleasedContourState(reportMissingLeadIns);
|
||||
var copy = new ReleasedContourState(reportMissingLeadIns)
|
||||
{
|
||||
partContours = partContours,
|
||||
partClosed = partClosed,
|
||||
};
|
||||
copy.obstacles.AddRange(obstacles);
|
||||
return copy;
|
||||
}
|
||||
@@ -62,10 +71,33 @@ 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)
|
||||
bool proveOpenCuts = false, int contoursBefore = 0)
|
||||
{
|
||||
var current = new CutGroup();
|
||||
var unfinished = new List<CutGroup>();
|
||||
@@ -78,7 +110,7 @@ public sealed class ReleasedContourState
|
||||
CutGroup previous = null;
|
||||
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
|
||||
var hasLead = false;
|
||||
var contourNumber = 0;
|
||||
var contourNumber = contoursBefore;
|
||||
var inBreakCut = false;
|
||||
var reportedTab = false;
|
||||
for (var index = 0; index < moves.Count; index++)
|
||||
@@ -95,7 +127,7 @@ public sealed class ReleasedContourState
|
||||
Finish();
|
||||
if (reportMissingLeadIns && !cutoff && !hasLead)
|
||||
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||
"A break cut has no nonzero placed lead-in motion.")
|
||||
"A break cut is missing lead-in geometry.")
|
||||
{ Location = move.Curve.Start }, owned);
|
||||
inBreakCut = true;
|
||||
hasLead = false;
|
||||
@@ -127,7 +159,14 @@ 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;
|
||||
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
|
||||
// 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))
|
||||
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
||||
$"of part {obstacle.Part}.")
|
||||
@@ -135,7 +174,7 @@ public sealed class ReleasedContourState
|
||||
Location = (start + move.End) * 0.5,
|
||||
RapidStart = start,
|
||||
RapidEnd = move.End,
|
||||
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token)
|
||||
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
||||
}, owned);
|
||||
}
|
||||
}
|
||||
@@ -179,7 +218,7 @@ public sealed class ReleasedContourState
|
||||
contourNumber++;
|
||||
if (reportMissingLeadIns && !cutoff && !hasLead)
|
||||
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
|
||||
$"Cutting contour {contourNumber} is missing lead-in geometry.")
|
||||
{ Location = move.Curve.Start }, owned);
|
||||
hasLead = false;
|
||||
// A rapid can pause/reposition without leaving any material gap.
|
||||
@@ -201,6 +240,12 @@ 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();
|
||||
@@ -222,6 +267,9 @@ 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)
|
||||
{
|
||||
@@ -244,9 +292,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 && PostVerificationGeometry.Closed(current.Curves))
|
||||
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
|
||||
{
|
||||
obstacles.Add(new(part, contourNumber, current.Curves.ToArray()));
|
||||
obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd));
|
||||
closed.Add(current);
|
||||
}
|
||||
else if (!cutoff)
|
||||
@@ -255,6 +303,31 @@ 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.
|
||||
@@ -396,6 +469,9 @@ 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)
|
||||
@@ -412,7 +488,8 @@ 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)
|
||||
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves,
|
||||
Vector? OvercutEnd)
|
||||
{
|
||||
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 ArcMove { Layer: LayerType.Cut or LayerType.Display };
|
||||
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 };
|
||||
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,6 +141,7 @@ 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,6 +20,7 @@ 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);
|
||||
|
||||
@@ -107,6 +108,14 @@ 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,
|
||||
@@ -115,6 +124,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
)
|
||||
{
|
||||
var entities = partProgram.ToGeometry();
|
||||
RefuseDisplayPerimeter(entities);
|
||||
var breakCuts = TakeBreakCuts(entities);
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
|
||||
@@ -251,14 +261,20 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
|
||||
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
|
||||
IReadOnlyList<Shape> tabbedCutouts = null)
|
||||
IReadOnlyList<Shape> tabbedCutouts = null, int? perimeterOrdinal = null, Shape displayTail = null)
|
||||
{
|
||||
var result = new Program(Mode.Absolute);
|
||||
EmitScribeContours(result, scribes);
|
||||
EmitBreakCuts(result, breakCuts, shapes);
|
||||
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
|
||||
EmitTabbedCutout(result, cutout);
|
||||
EmitChosenContours(result, shapes, choices, contourParameters);
|
||||
EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
|
||||
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);
|
||||
}
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
@@ -269,20 +285,23 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
// perimeter never degrades to a hole because it was isolated. Output must not be
|
||||
// installed on a Part or accepted as a complete plan.
|
||||
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
|
||||
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null)
|
||||
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null,
|
||||
int? perimeterOrdinal = null)
|
||||
{
|
||||
var result = new Program(Mode.Absolute);
|
||||
EmitScribeContours(result, scribes);
|
||||
EmitChosenContours(result, shapes, new[] { choice }, contourParameters);
|
||||
EmitChosenContours(result, shapes, new[] { choice }, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
|
||||
// One contour per choice in order; the perimeter (last shape) is always External.
|
||||
// One contour per choice in order; the perimeter is always External. Without a cut perimeter
|
||||
// (a display perimeter outside) perimeterOrdinal is past the last shape: every contour is a hole.
|
||||
// Contour emission itself reads only the contour's own geometry and settings, never
|
||||
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
|
||||
private void EmitChosenContours(Program result, Shape[] shapes,
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters)
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters,
|
||||
int perimeterOrdinal)
|
||||
{
|
||||
var shared = Parameters;
|
||||
try
|
||||
@@ -294,7 +313,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
|
||||
var shape = shapes[choice.ContourOrdinal];
|
||||
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
|
||||
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
|
||||
exactCirclePrograms: true, scrapPierce: choice.Pierce);
|
||||
}
|
||||
}
|
||||
@@ -430,6 +449,9 @@ 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)
|
||||
{
|
||||
@@ -446,9 +468,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
);
|
||||
}
|
||||
|
||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
||||
Parameters.GetEffectivePierceClearance());
|
||||
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius());
|
||||
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);
|
||||
}
|
||||
|
||||
// Build hole sub-program relative to (0,0)
|
||||
var holeCenter = circle.Center;
|
||||
@@ -487,6 +514,7 @@ 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)
|
||||
@@ -496,9 +524,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());
|
||||
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius(), turn);
|
||||
}
|
||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
|
||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
|
||||
|
||||
var reindexedShape = shape.ReindexAt(point, entity);
|
||||
|
||||
@@ -529,9 +557,20 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
}
|
||||
|
||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, winding));
|
||||
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, turn));
|
||||
}
|
||||
|
||||
/// <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)
|
||||
@@ -616,8 +655,10 @@ 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. Other styles and contour
|
||||
/// types keep <see cref="ComputeLeadInNormal"/>.
|
||||
/// 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"/>.
|
||||
/// </summary>
|
||||
public static LeadIn ResolveLeadIn(
|
||||
Shape shape,
|
||||
@@ -631,8 +672,19 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
)
|
||||
{
|
||||
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
|
||||
if (contourType != ContourType.External || leadIn is not LineLeadIn line
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
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)
|
||||
return leadIn;
|
||||
|
||||
switch (ClassifyCorner(corner, winding))
|
||||
@@ -662,7 +714,8 @@ 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. Other styles and contour types keep the entity normal.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static LeadOut ResolveLeadOut(
|
||||
Shape shape,
|
||||
@@ -676,8 +729,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
)
|
||||
{
|
||||
normal = ComputeNormal(point, entity, contourType, winding);
|
||||
if (contourType != ContourType.External || leadOut is not LineLeadOut line
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
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)
|
||||
return leadOut;
|
||||
|
||||
switch (ClassifyCorner(corner, winding))
|
||||
@@ -945,7 +1008,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
if (leadIn is NoLeadIn || clearance <= 0)
|
||||
return leadIn;
|
||||
|
||||
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle);
|
||||
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle, circle.Rotation);
|
||||
var maxRadius = circle.Radius - clearance;
|
||||
if (maxRadius <= 0)
|
||||
return leadIn;
|
||||
@@ -994,10 +1057,11 @@ 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.
|
||||
/// manual placement preview. <paramref name="turn"/> is the hole's
|
||||
/// <see cref="LeadTurn"/>, which places a curved lead's pierce.
|
||||
/// </summary>
|
||||
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal,
|
||||
double pierceRadius)
|
||||
double pierceRadius, RotationType turn)
|
||||
{
|
||||
if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|
||||
|| !hole.IsClosed())
|
||||
@@ -1007,7 +1071,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)))
|
||||
if (Clear(leadIn.GetPiercePoint(point, normal, turn)))
|
||||
return leadIn;
|
||||
|
||||
var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
|
||||
@@ -1067,8 +1131,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
return contourType switch
|
||||
{
|
||||
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn,
|
||||
ContourType.Internal => Parameters.InternalLeadIn,
|
||||
ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadIn,
|
||||
_ => Parameters.ExternalLeadIn,
|
||||
};
|
||||
}
|
||||
@@ -1077,8 +1140,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
return contourType switch
|
||||
{
|
||||
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut,
|
||||
ContourType.Internal => Parameters.InternalLeadOut,
|
||||
ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadOut,
|
||||
_ => Parameters.ExternalLeadOut,
|
||||
};
|
||||
}
|
||||
@@ -1351,7 +1413,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);
|
||||
var pierce = leadIn.GetPiercePoint(entry, normal, winding);
|
||||
return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,14 @@ 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>
|
||||
@@ -40,12 +41,55 @@ 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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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,43 +1,62 @@
|
||||
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(piercePoint),
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||
new RapidMove(GetPiercePoint(contourStartPoint, contourNormalAngle, winding)),
|
||||
new ArcMove(contourStartPoint, ArcCenter(contourStartPoint, contourNormalAngle), winding)
|
||||
{
|
||||
Layer = LayerType.Leadin,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
public override Vector GetPiercePoint(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var arcCenterX = contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourStartPoint.Y + Radius * System.Math.Sin(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);
|
||||
return new Vector(
|
||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
|
||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
|
||||
center.X + Radius * System.Math.Cos(angle),
|
||||
center.Y + Radius * System.Math.Sin(angle)
|
||||
);
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor };
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
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; }
|
||||
@@ -14,42 +15,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
|
||||
|
||||
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 Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW) =>
|
||||
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new CleanHoleLeadIn
|
||||
@@ -58,5 +28,8 @@ 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,6 +3,11 @@ 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(
|
||||
@@ -11,7 +16,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
RotationType winding = RotationType.CW
|
||||
);
|
||||
|
||||
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
|
||||
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW);
|
||||
|
||||
public virtual LeadIn Scale(double factor) => this;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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,6 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -14,42 +13,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
|
||||
|
||||
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 Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW) =>
|
||||
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new LineArcLeadIn
|
||||
@@ -58,5 +26,8 @@ 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,7 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
};
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
{
|
||||
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
||||
return new Vector(
|
||||
|
||||
@@ -33,7 +33,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
};
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
{
|
||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
|
||||
|
||||
@@ -14,7 +14,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return new List<ICode> { new RapidMove(contourStartPoint) };
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
{
|
||||
return contourStartPoint;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ 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; }
|
||||
@@ -17,9 +21,12 @@ 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(contourNormalAngle + System.Math.PI / 2),
|
||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)
|
||||
arcCenterX + Radius * System.Math.Cos(endAngle),
|
||||
arcCenterY + Radius * System.Math.Sin(endAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,14 @@ namespace OpenNest.CNC
|
||||
/// leaves it out, cutting the rest of the contour as open pieces.
|
||||
/// </summary>
|
||||
Tab,
|
||||
|
||||
/// <summary>
|
||||
/// The outside edge of a supplied blank that is only shown, never cut (for example a
|
||||
/// purchased door whose instrument cutouts are the only cuts). It bounds material like
|
||||
/// <see cref="Cut"/>, so nesting, containment, lead clearance and cut-offs all see the
|
||||
/// blank, but it has no pierce, lead or motion in any emitted program. It must be one whole
|
||||
/// closed contour enclosing every other contour.
|
||||
/// </summary>
|
||||
DisplayPerimeter,
|
||||
}
|
||||
}
|
||||
@@ -508,6 +508,21 @@ 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 };
|
||||
|
||||
@@ -31,6 +31,8 @@ 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,6 +183,9 @@ namespace OpenNest.Converters
|
||||
if (string.Equals(name, SpecialLayers.Tab.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.Tab;
|
||||
|
||||
if (string.Equals(name, SpecialLayers.DisplayPerimeter.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.DisplayPerimeter;
|
||||
|
||||
return LayerType.Cut;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +213,9 @@ namespace OpenNest.Converters
|
||||
case LayerType.Tab:
|
||||
return SpecialLayers.Tab;
|
||||
|
||||
case LayerType.DisplayPerimeter:
|
||||
return SpecialLayers.DisplayPerimeter;
|
||||
|
||||
default:
|
||||
return new Layer(layer.ToString());
|
||||
}
|
||||
|
||||
+167
-6
@@ -21,6 +21,23 @@ 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;
|
||||
@@ -35,10 +52,128 @@ namespace OpenNest
|
||||
)
|
||||
{
|
||||
var segments = ComputeSegments(plate, settings, cache);
|
||||
var program = BuildProgram(segments, settings);
|
||||
var program = BuildProgram(segments, settings, LockedFarEdge(plate));
|
||||
Drawing.Program = program;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The regenerated program split into its cutting segments, one rapid-and-cut program
|
||||
/// each in program order, so the plate can sequence every segment on its own. A rapid with
|
||||
/// the head height locked returns along the overcut and stays in its segment.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Program> SegmentPrograms()
|
||||
{
|
||||
var program = Drawing.Program;
|
||||
var result = new List<Program>();
|
||||
if (program == null)
|
||||
return result;
|
||||
Program current = null;
|
||||
var locked = false;
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
if (code is RapidMove && !locked || current == null)
|
||||
{
|
||||
current = new Program(program.Mode);
|
||||
result.Add(current);
|
||||
}
|
||||
if (HeadHeightLock.IsLock(code))
|
||||
locked = true;
|
||||
else if (HeadHeightLock.IsUnlock(code))
|
||||
locked = false;
|
||||
current.Codes.Add(code);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the far edge, along this axis on <paramref name="plate"/>, is the high
|
||||
/// coordinate: the sheet runs from the origin toward positive coordinates on that axis.
|
||||
/// </summary>
|
||||
internal static bool FarEdgeIsHigh(CutOffAxis axis, int quadrant) =>
|
||||
axis == CutOffAxis.Vertical ? quadrant is 1 or 2 : quadrant is 1 or 4;
|
||||
|
||||
/// <summary>
|
||||
/// The nominal far edge this cut-off overcuts with the head locked, along its axis, or null
|
||||
/// when it has no head-locked overcut or a limit stops it short of that edge.
|
||||
/// </summary>
|
||||
private (double Edge, bool High)? LockedFarEdge(Plate plate)
|
||||
{
|
||||
if (!(HeadLockOvercut > 0) || !double.IsFinite(HeadLockOvercut))
|
||||
return null;
|
||||
var bounds = plate.BoundingBox(includeParts: false);
|
||||
var high = FarEdgeIsHigh(Axis, plate.Quadrant);
|
||||
if ((high ? EndLimit : StartLimit) != null)
|
||||
return null;
|
||||
var vertical = Axis == CutOffAxis.Vertical;
|
||||
return (high ? (vertical ? bounds.Top : bounds.Right) : (vertical ? bounds.Bottom : bounds.Left), high);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same segment cut the other way at the far edge: a head-locked segment cut toward the
|
||||
/// far edge becomes one started there, and back; null when <paramref name="segment"/> is not
|
||||
/// one of the two forms <see cref="Regenerate"/> builds for the segment reaching that edge.
|
||||
/// </summary>
|
||||
public static Program ReverseAtFarEdge(Program segment)
|
||||
{
|
||||
if (segment?.Codes == null || segment.Mode != Mode.Absolute)
|
||||
return null;
|
||||
var codes = segment.Codes;
|
||||
var result = new Program(segment.Mode);
|
||||
// Toward the far edge: rapid near, cut to the edge, lock, overcut, unlock.
|
||||
if (codes.Count == 5 && codes[0] is RapidMove { Suppressed: false } near
|
||||
&& codes[1] is LinearMove { Suppressed: false, Layer: LayerType.Cut } toEdge
|
||||
&& HeadHeightLock.IsLock(codes[2])
|
||||
&& codes[3] is LinearMove { Suppressed: false, Layer: LayerType.Cut } overcut
|
||||
&& HeadHeightLock.IsUnlock(codes[4]))
|
||||
{
|
||||
result.Codes.Add(new RapidMove(toEdge.EndPoint));
|
||||
result.Codes.Add(new Comment(HeadHeightLock.Lock));
|
||||
result.Codes.Add(new LinearMove(overcut.EndPoint));
|
||||
result.Codes.Add(new RapidMove(toEdge.EndPoint));
|
||||
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
|
||||
result.Codes.Add(new LinearMove(near.EndPoint));
|
||||
return result;
|
||||
}
|
||||
// From the far edge: rapid to the edge, lock, overcut, rapid back, unlock, cut away.
|
||||
if (codes.Count == 6 && codes[0] is RapidMove { Suppressed: false } edge
|
||||
&& HeadHeightLock.IsLock(codes[1])
|
||||
&& codes[2] is LinearMove { Suppressed: false, Layer: LayerType.Cut } outward
|
||||
&& codes[3] is RapidMove { Suppressed: false } back && back.EndPoint == edge.EndPoint
|
||||
&& HeadHeightLock.IsUnlock(codes[4])
|
||||
&& codes[5] is LinearMove { Suppressed: false, Layer: LayerType.Cut } away)
|
||||
{
|
||||
result.Codes.Add(new RapidMove(away.EndPoint));
|
||||
result.Codes.Add(new LinearMove(edge.EndPoint));
|
||||
result.Codes.Add(new Comment(HeadHeightLock.Lock));
|
||||
result.Codes.Add(new LinearMove(outward.EndPoint));
|
||||
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs Plan Cutting's choice for the far-edge segment: <paramref name="segment"/> (one
|
||||
/// of this cut-off's placed parts) takes <paramref name="program"/>, the same segment cut the
|
||||
/// other way, and <see cref="FarEdgeFirst"/> and the whole-line program follow it, so
|
||||
/// regeneration keeps the direction. The caller has checked that the program is that flip.
|
||||
/// </summary>
|
||||
internal void InstallFarEdgeSegment(Part segment, Program program)
|
||||
{
|
||||
var codes = new List<ICode>();
|
||||
foreach (var part in SegmentPrograms())
|
||||
codes.AddRange((ReverseAtFarEdge(part) != null ? program : part).Codes);
|
||||
var whole = new Program(Drawing.Program?.Mode ?? Mode.Absolute);
|
||||
whole.Codes.AddRange(codes);
|
||||
segment.ReplaceCutOffProgram(program);
|
||||
FarEdgeFirst = StartsAtFarEdge(program);
|
||||
Drawing.Program = whole;
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="segment"/> is the far-edge segment cut from the far edge.</summary>
|
||||
public static bool StartsAtFarEdge(Program segment) =>
|
||||
segment?.Codes is { Count: 6 } codes && codes[0] is RapidMove && HeadHeightLock.IsLock(codes[1]);
|
||||
|
||||
/// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary>
|
||||
internal (double Length, int SegmentCount) MeasureSegments(
|
||||
Plate plate,
|
||||
@@ -73,13 +208,20 @@ 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:
|
||||
if (cutting)
|
||||
return false; // A second pierce leaves an uncut gap.
|
||||
// A second pierce leaves an uncut gap; a head-locked rapid returns along
|
||||
// the overcut to where the cut toward the origin begins.
|
||||
if (cutting && !locked)
|
||||
return false;
|
||||
position = rapid.EndPoint + part.Location;
|
||||
break;
|
||||
case LinearMove move when position != null:
|
||||
@@ -119,6 +261,8 @@ 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,
|
||||
@@ -128,13 +272,13 @@ namespace OpenNest
|
||||
{
|
||||
cutPosition = Position.X;
|
||||
lineStart = StartLimit ?? bounds.Y;
|
||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel);
|
||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + overtravel);
|
||||
}
|
||||
else
|
||||
{
|
||||
cutPosition = Position.Y;
|
||||
lineStart = StartLimit ?? bounds.X;
|
||||
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.Overtravel);
|
||||
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
|
||||
}
|
||||
|
||||
var exclusions = new List<(double Start, double End)>();
|
||||
@@ -436,7 +580,8 @@ namespace OpenNest
|
||||
|
||||
private Program BuildProgram(
|
||||
List<(double Start, double End)> segments,
|
||||
CutOffSettings settings
|
||||
CutOffSettings settings,
|
||||
(double Edge, bool High)? farEdge
|
||||
)
|
||||
{
|
||||
var program = new Program();
|
||||
@@ -453,6 +598,22 @@ 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)));
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,18 @@ 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,12 +28,17 @@ 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)
|
||||
: new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y);
|
||||
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom - low)
|
||||
: new Vector(cut.StartLimit ?? sheet.Left - low, cut.Position.Y);
|
||||
var end = cut.Axis == CutOffAxis.Vertical
|
||||
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top)
|
||||
: new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y);
|
||||
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top + high)
|
||||
: new Vector(cut.EndLimit ?? sheet.Right + high, 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)[] cutoffs;
|
||||
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End, long Overcut)[] 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?)[plate.CutOffs.Count];
|
||||
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?, long)[plate.CutOffs.Count];
|
||||
for (var i = 0; i < cutoffs.Length; i++)
|
||||
cutoffs[i] = CutOffState(plate.CutOffs[i]);
|
||||
}
|
||||
@@ -36,11 +36,12 @@ 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?) CutOffState(CutOff cut) =>
|
||||
private static (CutOff, CutOffAxis, long, 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);
|
||||
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null,
|
||||
Bits(cut.HeadLockOvercut));
|
||||
|
||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||
|
||||
|
||||
@@ -15,20 +15,16 @@ 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 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.
|
||||
/// 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.
|
||||
/// 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)
|
||||
@@ -49,7 +45,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 FineRings[parts.Count];
|
||||
var fine = new NativeContours[parts.Count];
|
||||
var raw = new SpacingOutline[parts.Count];
|
||||
var inflated = new SpacingOutline[parts.Count];
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
@@ -99,9 +95,9 @@ internal static class PartSpacingReview
|
||||
Vector marker;
|
||||
try
|
||||
{
|
||||
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);
|
||||
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);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
@@ -148,73 +144,6 @@ 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;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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,6 +135,11 @@ 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))
|
||||
@@ -219,6 +224,8 @@ 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.
|
||||
private bool IsWellConditioned =>
|
||||
internal 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,17 +110,56 @@ 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.
|
||||
var midpoint = start + delta * 0.5;
|
||||
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 inside = false;
|
||||
foreach (var curve in curves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.CrossesRay(midpoint))
|
||||
if (curve.CrossesRay(point, rayY))
|
||||
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)
|
||||
{
|
||||
@@ -279,6 +318,18 @@ 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>
|
||||
@@ -455,7 +506,20 @@ internal static class PostVerificationGeometry
|
||||
}
|
||||
}
|
||||
|
||||
internal bool CrossesRay(Vector point)
|
||||
/// <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)
|
||||
{
|
||||
// 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
|
||||
@@ -466,7 +530,6 @@ 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,7 +34,13 @@ 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
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
||||
@@ -84,6 +90,8 @@ 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}");
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,16 +519,33 @@ 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()
|
||||
{
|
||||
for (int i = Vertices.Count - 1; i > 0; i--)
|
||||
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++)
|
||||
{
|
||||
var vertex = Vertices[i];
|
||||
var nextVertex = Vertices[i - 1];
|
||||
|
||||
if (vertex == nextVertex)
|
||||
Vertices.RemoveAt(i);
|
||||
if (vertex != previous)
|
||||
Vertices[kept++] = vertex;
|
||||
|
||||
previous = vertex;
|
||||
}
|
||||
|
||||
Vertices.RemoveRange(kept, Vertices.Count - kept);
|
||||
}
|
||||
|
||||
public BoundingRectangleResult FindBestRotation()
|
||||
|
||||
@@ -47,14 +47,16 @@ namespace OpenNest.Geometry
|
||||
return new BoundingRectangleResult(angle, length, 0);
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
|
||||
BoundingRectangleResult best = null;
|
||||
|
||||
for (int i = 0; i < edgeCount; i++)
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int next = (i + 1) % n;
|
||||
var edgeX = vertices[next].X - vertices[i].X;
|
||||
@@ -94,8 +96,9 @@ 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)
|
||||
if (best == null || area < best.Area - margin)
|
||||
{
|
||||
var edgeAngle = System.Math.Atan2(edgeY, edgeX);
|
||||
best = new BoundingRectangleResult(edgeAngle, width, height);
|
||||
|
||||
@@ -264,10 +264,24 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the shape to a polygon.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public Polygon ToPolygon(int arcSegments = 1000)
|
||||
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)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@ 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;
|
||||
@@ -254,11 +259,11 @@ namespace OpenNest.Geometry
|
||||
|
||||
private bool StartsNear(int index, Vector point) =>
|
||||
TryEndpoints(entities[index], out var start, out _)
|
||||
&& start.DistanceTo(point) <= IncidenceTolerance;
|
||||
&& start.DistanceTo(point) <= JointTolerance;
|
||||
|
||||
private bool EndsNear(int index, Vector point) =>
|
||||
TryEndpoints(entities[index], out _, out var end)
|
||||
&& end.DistanceTo(point) <= IncidenceTolerance;
|
||||
&& end.DistanceTo(point) <= JointTolerance;
|
||||
|
||||
private bool InRun(int index, int first, int last)
|
||||
{
|
||||
|
||||
@@ -64,8 +64,8 @@ namespace OpenNest.Geometry
|
||||
public static SpacingOutline Build(ShapeProfile profile, Vector location, double inflateBy)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000 segments per
|
||||
// arc): arc-heavy real parts otherwise produce thousands of vertices.
|
||||
// Flatten by chord tolerance, not a fixed segment count: arc-heavy real parts
|
||||
// otherwise produce thousands of vertices.
|
||||
var region = ClipperBridge.OffsetForValidation(
|
||||
profile,
|
||||
inflateBy > Tolerance.Epsilon ? inflateBy : 0,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+80
-2
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Support;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
@@ -18,6 +19,7 @@ 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();
|
||||
@@ -30,6 +32,13 @@ 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; }
|
||||
@@ -42,12 +51,57 @@ namespace OpenNest
|
||||
|
||||
public string MadeBy { get; set; } = "";
|
||||
|
||||
public double Thickness { 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 Material Material { 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 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; }
|
||||
@@ -58,6 +112,16 @@ 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();
|
||||
@@ -80,6 +144,20 @@ 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)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,43 @@ 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,15 @@ 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>
|
||||
@@ -177,6 +186,16 @@ 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;
|
||||
|
||||
+140
-30
@@ -6,6 +6,7 @@ using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.Shapes;
|
||||
using OpenNest.Support;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
@@ -94,6 +95,9 @@ 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();
|
||||
}
|
||||
@@ -101,6 +105,30 @@ 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>
|
||||
@@ -130,7 +158,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);
|
||||
|
||||
@@ -142,6 +170,13 @@ 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>
|
||||
@@ -258,17 +293,16 @@ namespace OpenNest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void RegenerateCutOffs(CutOffSettings settings)
|
||||
{
|
||||
// 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>();
|
||||
|
||||
var saved = new Dictionary<CutOff, List<(int Index, double Start, double End)>>();
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!Parts[i].BaseDrawing.IsCutOff)
|
||||
@@ -276,44 +310,108 @@ namespace OpenNest
|
||||
|
||||
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
|
||||
if (cutoff != null)
|
||||
sequence[cutoff] = i;
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
RegenerateCutOffs(settings, sequence);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence)
|
||||
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)
|
||||
{
|
||||
var cache = BuildPerimeterCache(this);
|
||||
var placed = new List<(int Index, Part Part)>();
|
||||
var placed = new List<(int Index, int Order, Part Part)>();
|
||||
|
||||
// Regenerate and materialize each cut-off
|
||||
// Regenerate and materialize each cut-off's segments
|
||||
foreach (var cutoff in CutOffs)
|
||||
{
|
||||
cutoff.Regenerate(this, settings, cache);
|
||||
|
||||
if (cutoff.Drawing.Program.Codes.Count == 0)
|
||||
var programs = cutoff.SegmentPrograms();
|
||||
if (programs.Count == 0)
|
||||
continue;
|
||||
|
||||
var part = new Part(cutoff.Drawing);
|
||||
|
||||
if (sequence != null && sequence.TryGetValue(cutoff, out var index))
|
||||
placed.Add((index, part));
|
||||
else
|
||||
Parts.Add(part);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
// 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))
|
||||
Parts.Insert(System.Math.Clamp(index, 0, Parts.Count), part);
|
||||
}
|
||||
|
||||
@@ -386,9 +484,21 @@ namespace OpenNest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The number of times to cut the plate.
|
||||
/// The number of times to cut the plate. Lowering it drops the
|
||||
/// <see cref="StockItemCodes"/> of the sheets no longer cut.
|
||||
/// </summary>
|
||||
public int Quantity { get; set; }
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The quadrant the plate is located in.
|
||||
|
||||
@@ -236,6 +236,7 @@ namespace OpenNest
|
||||
remaining.CutOffs.Clear();
|
||||
remaining.Parts.Clear();
|
||||
remaining.Quantity = 1;
|
||||
remaining.StockItemCodes = Array.Empty<string>();
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -6,6 +6,12 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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,6 +35,16 @@ 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,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
@@ -14,6 +15,29 @@ 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
|
||||
@@ -21,11 +45,17 @@ 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;
|
||||
@@ -48,6 +78,17 @@ 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; }
|
||||
@@ -133,7 +174,7 @@ namespace OpenNest.PostSettings
|
||||
for (var i = 0; i < properties.Count; i++)
|
||||
{
|
||||
var property = properties[i];
|
||||
var kind = KindOf(property.PropertyType);
|
||||
var kind = KindOf(property.PropertyType, out var isOptional, out var columns);
|
||||
if (kind == null)
|
||||
return null;
|
||||
|
||||
@@ -146,7 +187,7 @@ namespace OpenNest.PostSettings
|
||||
section,
|
||||
setting?.Order ?? int.MaxValue,
|
||||
i,
|
||||
new PostSettingsField(property, kind.Value, setting)
|
||||
new PostSettingsField(property, kind.Value, isOptional, columns, setting)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -207,6 +248,126 @@ 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))
|
||||
|
||||
@@ -24,12 +24,16 @@ namespace OpenNest
|
||||
/// <summary>A tabbed cutout's uncut tabs: material boundary that is never cut.</summary>
|
||||
public static readonly Layer Tab = new Layer("TAB") { Color = Color.Orange };
|
||||
|
||||
/// <summary>A supplied blank's shown-only outside edge: material boundary that is never cut.</summary>
|
||||
public static readonly Layer DisplayPerimeter = new Layer("DISPLAY PERIMETER") { Color = Color.DarkCyan };
|
||||
|
||||
/// <summary>
|
||||
/// True when an entity converted from a part program describes part material. Rapids
|
||||
/// and scribe/etch marks are excluded: marks are only on the surface, so they never
|
||||
/// bound material and must not affect nesting, collision, area, or validation. Break
|
||||
/// cuts are excluded too: they run through material that stays attached at tabs. Tabs are
|
||||
/// material: they close a tabbed cutout's boundary.
|
||||
/// material: they close a tabbed cutout's boundary. A display perimeter is material: it is the
|
||||
/// supplied blank's edge.
|
||||
/// </summary>
|
||||
public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe && layer != Break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ 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)
|
||||
@@ -36,7 +38,7 @@ namespace OpenNest
|
||||
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
var endpt = part.Program.EndPoint() + part.Location;
|
||||
var endpt = DisplayPerimeterTail.WithoutTail(part.Program).EndPoint() + part.Location;
|
||||
info += GetTimingInfo(part.Program);
|
||||
info.TravelDistance += pos.DistanceTo(endpt);
|
||||
pos = endpt;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -194,19 +194,39 @@ 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 settings;
|
||||
return Fallback;
|
||||
}
|
||||
|
||||
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(File.ReadAllText(path));
|
||||
using var document = JsonDocument.Parse(json ?? "");
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
status = ClampSettingsStatus.Invalid;
|
||||
@@ -307,24 +327,7 @@ public sealed class ClampSettings
|
||||
/// </summary>
|
||||
public void Save(string path, int maxRetries = 3)
|
||||
{
|
||||
var dto = new Dictionary<string, object>
|
||||
{
|
||||
["version"] = CurrentVersion,
|
||||
["enabled"] = Enabled,
|
||||
["units"] = Units.ToString().ToLowerInvariant(),
|
||||
["width"] = Width,
|
||||
["depth"] = Depth,
|
||||
["removalMinutes"] = RemovalMinutes,
|
||||
["centers"] = Centers,
|
||||
};
|
||||
// Other edges are written only when used, so origin-side-only files stay unchanged.
|
||||
if (FarSideCenters.Count > 0)
|
||||
dto["farSideCenters"] = FarSideCenters;
|
||||
if (OriginEndCenters.Count > 0)
|
||||
dto["originEndCenters"] = OriginEndCenters;
|
||||
if (FarEndCenters.Count > 0)
|
||||
dto["farEndCenters"] = FarEndCenters;
|
||||
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
||||
var json = ToJson();
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
@@ -344,6 +347,29 @@ public sealed class ClampSettings
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The settings as <see cref="Save"/> writes them, for the shared copy.</summary>
|
||||
public string ToJson()
|
||||
{
|
||||
var dto = new Dictionary<string, object>
|
||||
{
|
||||
["version"] = CurrentVersion,
|
||||
["enabled"] = Enabled,
|
||||
["units"] = Units.ToString().ToLowerInvariant(),
|
||||
["width"] = Width,
|
||||
["depth"] = Depth,
|
||||
["removalMinutes"] = RemovalMinutes,
|
||||
["centers"] = Centers,
|
||||
};
|
||||
// Other edges are written only when used, so origin-side-only files stay unchanged.
|
||||
if (FarSideCenters.Count > 0)
|
||||
dto["farSideCenters"] = FarSideCenters;
|
||||
if (OriginEndCenters.Count > 0)
|
||||
dto["originEndCenters"] = OriginEndCenters;
|
||||
if (FarEndCenters.Count > 0)
|
||||
dto["farEndCenters"] = FarEndCenters;
|
||||
return JsonSerializer.Serialize(dto, JsonOptions);
|
||||
}
|
||||
|
||||
private static double Scale(Units from, Units to) =>
|
||||
from == to ? 1
|
||||
: from == Units.Inches ? MillimetersPerInch
|
||||
|
||||
@@ -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.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.625, "overtravel": 0.3125, "minSegmentLength": 1.25, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.75, "overtravel": 0.375, "minSegmentLength": 1.5, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "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.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,42 +1,31 @@
|
||||
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. 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).
|
||||
/// filter. Paging, superseding and disposal follow <see cref="BrowseSession{TPage, TSort}"/>.
|
||||
/// Call from one thread (the UI thread).
|
||||
/// </summary>
|
||||
public sealed class DrawingLibraryBrowseSession : IDisposable
|
||||
public sealed class DrawingLibraryBrowseSession : BrowseSession<DrawingLibraryPage, DrawingSortField>
|
||||
{
|
||||
private readonly IDrawingLibrary _library;
|
||||
private CancellationTokenSource? _pending;
|
||||
private int _generation;
|
||||
private bool _disposed;
|
||||
|
||||
public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
|
||||
/// <param name="nest">The nest to filter to, or null to list every active drawing (File > Drawing Library).</param>
|
||||
public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest? nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
|
||||
: base(pageSize, DrawingLibraryQuery.MaxLimit, DrawingSortField.SavedAt)
|
||||
{
|
||||
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;
|
||||
PageSize = pageSize;
|
||||
MaterialName = (nest.Material?.Name ?? "").Trim();
|
||||
MaterialGrade = (nest.Material?.Grade ?? "").Trim();
|
||||
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 = double.IsFinite(nest.Thickness) && nest.Thickness > 0
|
||||
Thickness = nest != null && 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; }
|
||||
|
||||
@@ -53,148 +42,21 @@ public sealed class DrawingLibraryBrowseSession : IDisposable
|
||||
public bool IsFilteredToNest =>
|
||||
!ShowAll && (MaterialName.Length > 0 || MaterialGrade.Length > 0 || Thickness is not null);
|
||||
|
||||
/// <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();
|
||||
}
|
||||
/// <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>Lifts or restores the nest filter and returns to the first page.</summary>
|
||||
public Task<bool> SetShowAllAsync(bool showAll)
|
||||
{
|
||||
ShowAll = showAll;
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
return FirstPageAsync();
|
||||
}
|
||||
|
||||
/// <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) =>
|
||||
protected override Task<DrawingLibraryPage> QueryAsync(CancellationToken cancellationToken) =>
|
||||
_library.QueryAsync(
|
||||
new DrawingLibraryQuery
|
||||
{
|
||||
@@ -208,23 +70,4 @@ public sealed class DrawingLibraryBrowseSession : IDisposable
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class DrawingLibraryEditSession
|
||||
{
|
||||
@@ -26,42 +39,118 @@ public sealed class DrawingLibraryEditSession
|
||||
|
||||
public string SavedBy { get; }
|
||||
|
||||
/// <summary>The record to save: the listed details with new notes and settings.</summary>
|
||||
public DrawingLibraryRecord Build(string notes, DrawingNestingSettings settings) => new()
|
||||
/// <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)
|
||||
{
|
||||
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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The first rule the edit breaks, or null when it may be saved.</summary>
|
||||
public string? GetError(string notes, DrawingNestingSettings settings) =>
|
||||
DrawingLibraryRules.GetMetadataError(Build(notes, settings));
|
||||
public string? GetError(DrawingLibraryDetails details, string notes, DrawingNestingSettings settings) =>
|
||||
DrawingLibraryRules.GetMetadataError(Build(details, notes, settings));
|
||||
|
||||
/// <summary>True when saving would change nothing.</summary>
|
||||
public bool IsUnchanged(string notes, DrawingNestingSettings settings) =>
|
||||
(notes ?? "") == Listed.Notes && Equals(settings, Listed.Settings);
|
||||
public bool IsUnchanged(DrawingLibraryDetails details, string notes, DrawingNestingSettings settings) =>
|
||||
SameDetails(Build(details, notes, settings), Listed)
|
||||
&& (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(string notes, DrawingNestingSettings 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 = Build(notes, settings);
|
||||
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());
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var record = Build(details, notes, settings);
|
||||
var error = DrawingLibraryRules.GetMetadataError(record);
|
||||
if (error != null)
|
||||
throw new ArgumentException(error, nameof(settings));
|
||||
throw new ArgumentException(error, nameof(details));
|
||||
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,7 +1,7 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>One bounded page of library drawing metadata.</summary>
|
||||
public sealed class DrawingLibraryPage
|
||||
public sealed class DrawingLibraryPage : IBrowsePage
|
||||
{
|
||||
/// <summary>At most <see cref="Limit"/> records, in the requested order.</summary>
|
||||
public IReadOnlyList<DrawingLibraryRecord> Items { get; init; } = Array.Empty<DrawingLibraryRecord>();
|
||||
@@ -12,4 +12,6 @@ public sealed class DrawingLibraryPage
|
||||
public int Offset { get; init; }
|
||||
|
||||
public int Limit { get; init; }
|
||||
|
||||
int IBrowsePage.ItemCount => Items.Count;
|
||||
}
|
||||
@@ -85,6 +85,51 @@ 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)
|
||||
@@ -169,37 +214,7 @@ public sealed class DrawingLibrarySaveSession
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
var snapshot = Copy(metadata);
|
||||
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.");
|
||||
}
|
||||
}
|
||||
var matches = await DrawingLibraryDuplicates.FindMatchesAsync(_library, snapshot, cancellationToken);
|
||||
|
||||
DrawingLibraryRecord? linked = null;
|
||||
if (Drawing.Library is { } link && link.ServerKey == ServerKey
|
||||
@@ -336,11 +351,6 @@ 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,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Browse state for Nest > 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);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
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)));
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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
Reference in new issue
Block a user