7 Commits
Author SHA1 Message Date
aj c1af1d5433 fix(update): skip update checks when no Velopack locator is set
With GitHub as the default source, UpdaterService always built an
UpdateManager, which throws unless VelopackApp.Build().Run() has set a
locator. MainApp does, but WinForms test hosts constructing MainForm do
not, so 78 OpenNest.WinForms.Tests failed. Without a locator there is no
installation to update, so the service now stays idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d505a50934)
2026-10-27 18:54:40 -04:00
aj d6135bf9d0 feat(release): ship self-updating Velopack packages on GitHub Releases
Publish-Windows.ps1 now packs the smoke-tested folder with the pinned vpk
CLI (.config/dotnet-tools.json, 1.2.0 to match the Velopack package) into
velopack/: Setup.exe, Portable.zip, the full nupkg, releases.win.json and
RELEASES, checked for the full package and the portable layout and hashed
into OpenNest.vX.Y.Z.velopack.sha256. The release workflow uploads them
with the candidate.

With no feed URL set, installed copies now check GitHub Releases (full
releases only) instead of skipping update checks; a URL in Options still
selects a self-hosted feed. Deploy-OpenNest.ps1 uses the pinned vpk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 7fe4de4b6d)
2026-10-27 18:54:40 -04:00
aj 984b0419ab fix(planning): learn to reach a sheet separator from beyond the parts in its way
A sheet separator is cut after everything, so when the approach to its
pierce crossed parts already cut, the usual "cut it before them" rule
contradicted that and the search learned nothing. It fell back to the
full search, which spent the budget or found a route that climbed the
column and jumped back down past the cut parts (N26-23M).

When every "cut the blocked part first" rule would contradict a required
order, learn the other way round: cut the part the approach left from
before the parts it crossed, so the tool arrives from beyond them. A
separator blocked this way now ends the attempt at once, like any
cutoff whose rule can be learned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit f4317bc52f)
2026-10-27 18:54:40 -04:00
aj 86b78393d6 fix(ui): move the saved-nests Find bar above the grid
At the bottom of the form the search box read as part of the Details
panel. It now sits under the Nests header, next to the list it filters,
and comes first in the tab order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 207f126b9e)
2026-10-27 18:54:40 -04:00
aj 7474eaa989 fix(cutting): route a part's holes from the end of the segment cut before it
On P230323-02 plate 7 the frame part cut after a cut-off segment took its
upper small hole first, so the rapid up from the segment's end at the
bottom of the sheet crossed the rapid from the lower hole to the window.
The free search plans parts as if segments were absent, so the part's hole
route was ranked from where the tool was before the segment (the previous
part's end, top right), not from where it really is.

After CutOffInsertion places the segments, each holed part cut straight
after one now keeps its outside entry and order but takes the hole route
and preferred hole entries planned from the segment's end, when that
chain has an entry for every hole and passes the checks. The search's
look-through of segments keeps ranking outside entries from the
pre-segment point (so routes stay as planned without the lines) but plans
hole routes from the real position, so a kept-order replan of an applied
plan picks the same holes. Plates without cut-offs are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0ede9443de)
2026-10-27 18:54:40 -04:00
aj 242ef88a97 fix(ui): keep Draw Support visible while inspecting a cutting plan
The support overlay was drawn inside the live-plate-only paint block, and
the Plan Cutting preview plate never copied the support bed, so opening
the cut planner hid the grid points and each part's support marks. The
overlay now follows the displayed plate, the preview carries the bed, and
tab-point clearance falls back to the live plate's tab size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 58b8467c39)
2026-10-27 18:54:40 -04:00
aj 5cd7d4132c fix(ui): place bend lines in the program frame
Drawing.Bends stay in the DXF's source frame while CadImporter moves the
program so its first pierce sits at the origin (Source.Offset). The plate
renderer added part.Location to raw bend points, so every bend and etch
mark was shifted by its drawing's pierce offset, visible on the drawing
library set preview and on plates alike. Remove Source.Offset before
rotating and placing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit eedff85904)
2026-10-27 18:54:40 -04:00
236 changed files with 487 additions and 11237 deletions

No files matched your search

+1 -3
View File
@@ -19,11 +19,9 @@ jobs:
matrix:
project:
- OpenNest.Tests
- OpenNest.Desktop.Tests
- OpenNest.Engine.Tests
- OpenNest.IO.Tests
- OpenNest.Server.Tests
- OpenNest.FrontEnd.Tests
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
@@ -72,7 +70,7 @@ jobs:
windows-desktop:
# WinForms tests need a Windows desktop runtime; the Linux job can only
# cross-compile them. FrontEnd.Tests also run here, beside the desktop build.
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop.
runs-on: windows-2022
timeout-minutes: 20
defaults:
-128
View File
@@ -1,128 +0,0 @@
name: Development build
# Every master push whose cross-platform tests pass becomes the rolling
# `dev-build` pre-release: X.Y.Z-dev.N, where X.Y.Z is the minor after the
# newest release tag and N the commit count. Installed copies take it only
# with Tools > Options > Include development builds.
on:
workflow_run:
workflows: ['Cross-platform tests']
types: [completed]
branches: [master]
permissions:
contents: read
concurrency:
group: dev-build
cancel-in-progress: true
jobs:
package:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: windows-2022
timeout-minutes: 30
defaults:
run:
shell: pwsh
env:
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Resolve development version
id: version
run: |
$releases = git tag --list 'v*' | Where-Object { $_ -match '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } |
ForEach-Object { [version]$_.Substring(1) } | Sort-Object
if (-not $releases) { throw 'No vX.Y.Z release tag found.' }
$latest = @($releases)[-1]
$count = (git rev-list --count $env:SOURCE_SHA).Trim()
if ($LASTEXITCODE -ne 0) { throw 'Cannot count commits.' }
$version = "$($latest.Major).$($latest.Minor + 1).0-dev.$count"
Write-Host "Development version $version (after v$latest)"
"version=$version" >> $env:GITHUB_OUTPUT
- name: Publish and verify Windows package
run: ./scripts/Publish-Windows.ps1 -Version ${{ steps.version.outputs.version }}
- name: Upload development build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: OpenNest-dev-build
path: |
artifacts/*.zip
artifacts/*.sha256
artifacts/velopack/OpenNest-win-Setup.exe
artifacts/velopack/OpenNest-win-Portable.zip
artifacts/velopack/*-full.nupkg
artifacts/velopack/releases.win.json
artifacts/velopack/RELEASES
if-no-files-found: error
retention-days: 7
compression-level: 0
publish:
needs: package
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
actions: read
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
VERSION: ${{ needs.package.outputs.version }}
steps:
- name: Skip if master has moved on
id: current
run: |
head=$(gh api "repos/$GH_REPO/commits/master" --jq .sha)
if [ "$head" != "$SOURCE_SHA" ]; then
echo "master is at $head; its own run publishes the newer build."
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- name: Download development build
if: steps.current.outputs.skip != 'true'
run: gh run download "$GITHUB_RUN_ID" -n OpenNest-dev-build -D dev
- name: Verify checksums
if: steps.current.outputs.skip != 'true'
working-directory: dev
run: |
sha256sum -c ./*.zip.sha256
(cd velopack && sha256sum -c "../OpenNest.v$VERSION.velopack.sha256")
- name: Replace the dev-build pre-release
if: steps.current.outputs.skip != 'true'
working-directory: dev
run: |
if gh release view dev-build >/dev/null 2>&1; then
gh release delete dev-build --cleanup-tag --yes
fi
cat > notes.md <<EOF
Automatic development build of master at $SOURCE_SHA.
It may contain unfinished features. Installed copies receive it only with
**Tools > Options > Include development builds**; full releases are unaffected.
EOF
gh release create dev-build ./*.zip ./*.sha256 velopack/* \
--target "$SOURCE_SHA" --prerelease --latest=false \
--title "OpenNest $VERSION (development build, ${SOURCE_SHA:0:8})" --notes-file notes.md
- name: Read back the pre-release
if: steps.current.outputs.skip != 'true'
run: |
gh release view dev-build --json isPrerelease,isDraft,tagName,assets \
--jq '"prerelease=\(.isPrerelease) draft=\(.isDraft) assets=\(.assets|length)"' | tee readback.txt
grep -qx 'prerelease=true draft=false assets=8' readback.txt
[ "$(gh api "repos/$GH_REPO/git/ref/tags/dev-build" --jq .object.sha)" = "$SOURCE_SHA" ]
+1 -1
View File
@@ -68,7 +68,7 @@ jobs:
# mini dump in TestResults, instead of holding the runner until the
# job timeout.
run: |
foreach ($project in @('OpenNest.Tests', 'OpenNest.Desktop.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
}
+4 -10
View File
@@ -22,15 +22,13 @@ dotnet build OpenNest.sln
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj -c Release
dotnet test OpenNest.Desktop.Tests/OpenNest.Desktop.Tests.csproj -c Release
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj -c Release
# Windows runtime tests
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
```
Only Plan Cutting changes need the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); never run it for nesting engines, fills, UI, posts or file formats. Limit it to the areas the change touches with `OPENNEST_PROBLEM_NESTS_CHECKS` (table in that doc); no plate may rank below its baseline. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
After changing planning, contour preparation or geometry checks, run the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); no plate may rank below its baseline. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
@@ -40,9 +38,8 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). WinForms `Properties.Settings` persist to `%APPDATA%\OpenNest\user-settings.json` through `AppDataSettingsProvider`, never inside the Velopack install folder. 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.Desktop` (net8.0): toolkit-neutral desktop logic shared by the WinForms app and `OpenNest.Avalonia`, the cross-platform desktop in progress. It must not reference WinForms/WindowsDesktop, `System.Drawing.Common` or Avalonia (`OpenNest.Desktop.Tests` guards this, including transitive packages); put toolkit-specific rendering, dialogs and settings behind adapters in the front ends.
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection and the Python sketch-search prototype (`sketch-search/README.md`). Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
- `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
@@ -51,18 +48,15 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- `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.
- `Plate.Outline`/`NestPlateStock.Outline` (`SheetOutline`) make a sheet irregular; `Size` stays its bounding box. Only Irregular fills it (`PackBoundary` eroded by the largest edge spacing); every other engine and fill strategy must place nothing on it, validation must reject parts outside it or over its obstacles, and automatic cut-offs refuse it. See [irregular stock](docs/irregular-stock.md).
- 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.
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. Every post must raise before a marked rapid or refuse the nest.
- 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.
Read the relevant contract before changing its behavior:
- [Nest file format](docs/nest-file-format.md)
- [Irregular stock](docs/irregular-stock.md): outlined sheets, what honors the outline and what refuses it
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
- [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
@@ -76,6 +70,6 @@ Read the relevant contract before changing its behavior:
- [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): GitHub Releases by default, opt-in rolling `dev-build` pre-release from green master pushes, the nest server's `/updates` mirror first in Database mode (GitHub fallback), `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, `UpdateFeedUrl` for a self-hosted feed, release Velopack assets 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.
@@ -11,7 +11,6 @@
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.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" />
+2 -34
View File
@@ -132,26 +132,14 @@ static class NestConsole
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|| options.OutlineFile != null || options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
|| options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
{
Console.Error.WriteLine(
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size, --outline or --template."
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size or --template."
);
return 1;
}
if (options.OutlineFile != null && !options.AutoNest)
{
Console.Error.WriteLine("Error: --outline needs --autonest; single-plate fill strategies fill rectangles only.");
return 1;
}
if (options.OutlineFile != null && !File.Exists(options.OutlineFile))
{
Console.Error.WriteLine($"Error: Outline file not found: {options.OutlineFile}");
return 1;
}
if (options.InputFiles.Count == 0)
{
PrintUsage();
@@ -209,12 +197,6 @@ static class NestConsole
ApplyTemplate(plate, options);
ApplyOverrides(plate, options);
if (!options.AutoNest && PlateFillService.Refusal(plate) is { } refusal)
{
Console.Error.WriteLine($"Error: {refusal} Use --autonest.");
return 2;
}
var drawing = ResolveDrawing(nest, options);
if (drawing == null)
@@ -294,9 +276,6 @@ static class NestConsole
if (Size.TryParse(args[++i], out var sz))
o.PlateSize = sz;
break;
case "--outline" when i + 1 < args.Length:
o.OutlineFile = args[++i];
break;
case "--check-overlaps":
o.CheckOverlaps = true;
break;
@@ -547,13 +526,6 @@ static class NestConsole
if (options.PlateSize.HasValue && !hasDxfOnly)
plate.Size = options.PlateSize.Value;
if (options.OutlineFile != null)
{
// The outline is the sheet: its bounds replace any size given.
plate.Outline = SheetOutline.FromGeometry(CadImporter.Import(options.OutlineFile).Entities);
Console.WriteLine($"Outline: {options.OutlineFile} ({plate.Outline})");
}
}
static Drawing ResolveDrawing(Nest nest, Options options)
@@ -1024,9 +996,6 @@ static class NestConsole
--quantity <n> Max parts to place (default: 0 = unlimited)
--spacing <value> Override part spacing
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
--outline <dxf> Make the plate an irregular sheet: the file's largest closed contour is
the sheet, other closed contours are obstacles; sets the size. Only
--autonest with the Irregular (or Default) engine nests on it
--output <path> Output nest file path (default: <input>-result.nest)
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
--autonest Validated whole-job nesting (one sheet unless --max-sheets); replaces only after acceptance
@@ -1068,7 +1037,6 @@ static class NestConsole
public int Quantity;
public double? Spacing;
public Size? PlateSize;
public string OutlineFile;
public bool CheckOverlaps;
public bool NoSave;
public bool KeepParts;
-2
View File
@@ -544,8 +544,6 @@ public static class AutomaticCutOffPlanner
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
"Sheet length and width must be positive and finite.", nameof(plate));
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
// Strips are planned across a rectangle's known span; an outlined sheet gets manual cut-offs only.
Require(plate.Outline == null, "Automatic cut-offs need a rectangular sheet; this plate has an outline.", nameof(plate));
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
Nonnegative(plate.EdgeSpacing.Bottom)),
@@ -70,11 +70,9 @@ internal enum SupportEntries
/// <summary>
/// One automatic start candidate: the owned contour choice plus its preference kind and a
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
/// owner; nothing here exposes or mutates the underlying shape. <paramref name="CornerReach"/>
/// is how far a corner fillet's joint lies from the sharp corner it rounds (0 otherwise), so
/// ranking can count it on both of that corner's sides.
/// owner; nothing here exposes or mutates the underlying shape.
/// </summary>
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
{
/// <summary>
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
@@ -107,18 +107,16 @@ internal static class ContourEntryRanking
if (target != null)
{
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
// sides within tolerance, a fillet's joint within its reach of the corner it
// rounds); count how many of them are facing sides.
// sides within tolerance); count how many of them are facing sides.
var left = p.X - minX;
var right = maxX - p.X;
var bottom = p.Y - minY;
var top = maxY - p.Y;
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
if (facingRight && right <= near) facing++;
if (!facingRight && left <= near) facing++;
if (facingTop && top <= near) facing++;
if (!facingTop && bottom <= near) facing++;
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++;
}
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
@@ -41,10 +41,8 @@ public static class ExecutionMotionReader
var pos = frame;
var first = true;
var countBefore = moves.Count;
var raised = HeadRaise.RaisedMask(current.Codes);
for (var codeIndex = 0; codeIndex < current.Codes.Count; codeIndex++)
foreach (var code in current.Codes)
{
var code = current.Codes[codeIndex];
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit.");
@@ -108,8 +106,7 @@ public static class ExecutionMotionReader
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve, raised[codeIndex],
ReferenceEquals(current, program) ? codeIndex : null));
moves.Add(new(start, end, rapid, layer, curve));
pos = end;
first = false;
}
@@ -121,16 +121,11 @@ public sealed class LeadMaterialSnapshot
? "A display perimeter must enclose every other contour."
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
}
// A ring encloses a point only inside the union of its curves' extents (each at least
// the curve's own reach), so a part with thousands of holes asks parity only where it can hold.
var ringExtents = rings.Select(ring => ring.Aggregate(PostVerificationGeometry.Extent.None,
(extent, curve) => extent.Union(curve.Extent))).ToArray();
bool Encloses(int ring, Vector point) => ringExtents[ring].Contains(point) && Inside(point, rings[ring], token);
var outer = -1;
for (var i = 0; i < rings.Count; i++)
{
token.ThrowIfCancellationRequested();
if (Enumerable.Range(0, rings.Count).All(j => i == j || Encloses(i, rings[j][0].Start)))
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token)))
{
if (outer >= 0)
throw new ArgumentException("Material perimeter is ambiguous.");
@@ -147,11 +142,10 @@ public sealed class LeadMaterialSnapshot
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 holeRings = Enumerable.Range(0, rings.Count).Where(i => i != outer).ToArray();
var holes = holeRings.Select(i => rings[i]).ToArray();
for (var i = 0; i < holeRings.Length; i++)
for (var j = i + 1; j < holeRings.Length; j++)
if (Encloses(holeRings[j], holes[i][0].Start) || Encloses(holeRings[i], holes[j][0].Start))
var holes = rings.Where((_, i) => i != outer).ToArray();
for (var i = 0; i < holes.Length; i++)
for (var j = i + 1; j < holes.Length; j++)
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
throw new ArgumentException("Nested cutouts are not supported material.");
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
@@ -294,25 +294,13 @@ public static class LeadPathValidator
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
var contacts = curve.Contacts(boundary, out var overlap);
if (overlap || contacts.Any(p => permittedJoint is not { } joint || !AtJoint(p, joint, boundary)))
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
}
// With all other boundary contacts excluded, the connected open path
// has constant material membership. Use the native arc midpoint, not
// the chord midpoint or endpoints (which can both lie in scrap).
return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
// A lead tangent to the boundary it joins (an arc lead-in meeting a straight edge) has a
// double root at the joint that rounding splits into two contacts a few 1e-8 apart; one
// within JointTolerance of a joint the boundary itself passes through is still the joint.
static bool AtJoint(Vector point, Vector joint, PostVerificationGeometry.Curve boundary)
{
var distance = point.DistanceTo(joint);
return distance <= PostVerificationGeometry.Epsilon
|| distance <= JointTolerance && boundary.Contains(joint);
}
}
// As SlideContact's: far below any kerf, far above a tangent root's rounding drift.
private const double JointTolerance = 1e-6;
}
@@ -38,31 +38,20 @@ public sealed class OwnedExecution
public sealed class ExecutionMotion
{
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
PostVerificationGeometry.Curve curve, bool headRaised = false, int? code = null)
PostVerificationGeometry.Curve curve)
{
Start = start;
End = end;
Rapid = rapid;
Layer = layer;
Curve = curve;
HeadRaised = headRaised;
Code = code;
}
public Vector? Start { get; }
public Vector End { get; }
public bool Rapid { get; }
public LayerType Layer { get; }
/// <summary>A rapid made with the head raised over parts already cut (<see cref="HeadRaise"/>).</summary>
public bool HeadRaised { get; }
/// <summary>
/// The index of the code this motion runs in the read program's own codes; null for a motion
/// inside a sub-program and for the rapid a program that opens with a cut is given.
/// </summary>
public int? Code { get; }
public double Length => Curve?.Length ?? 0;
internal PostVerificationGeometry.Curve Curve { get; }
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve, HeadRaised, Code);
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
}
@@ -602,7 +602,7 @@ public sealed class PreparedContours
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
=> CataloguePoints(shape, token)
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind, p.CornerReach))
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
.ToList();
/// <summary>
@@ -763,14 +763,12 @@ public sealed class PreparedContours
/// <summary>
/// The preferred catalogue points for one contour: for each entity, its convex corner
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
/// plus tangent line/arc joints. The joints of a corner fillet (see <see cref="FilletReach"/>)
/// are that corner's convex corners. Reflex, cusp and collinear-split vertices contribute
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
/// nothing; a single whole circle yields no points at all.
/// </summary>
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind, double CornerReach)> CataloguePoints(
Shape shape, CancellationToken token)
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
{
var found = new List<(int, Vector, AutomaticEntryKind, double)>();
var found = new List<(int, Vector, AutomaticEntryKind)>();
var count = shape.Entities.Count;
if (count == 1 && shape.Entities[0] is Circle)
{
@@ -794,65 +792,29 @@ public sealed class PreparedContours
switch (corner.Kind)
{
case ContourCuttingStrategy.CornerKind.Convex:
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
break;
case ContourCuttingStrategy.CornerKind.Smooth
when entity is Line && Next(i) is Arc:
// A line leaving into an arc: the tangent joint. The reverse travel
// order classifies the same joint from the arc, matched below.
found.Add(Joint(i, vertex, (i + 1) % count));
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
break;
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
// of a plain straight edge are not preferred automatic starts here.
case ContourCuttingStrategy.CornerKind.Smooth
when entity is Arc && Next(i) is Line:
found.Add(Joint(i, vertex, i));
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
break;
}
}
if (entity is Line line)
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
}
return found;
Entity Next(int index) => shape.Entities[(index + 1) % count];
(int, Vector, AutomaticEntryKind, double) Joint(int entityOrdinal, Vector vertex, int arcOrdinal) =>
FilletReach(shape, arcOrdinal) is { } reach
? (entityOrdinal, vertex, AutomaticEntryKind.ConvexCorner, reach)
: (entityOrdinal, vertex, AutomaticEntryKind.TangentJoint, 0);
}
/// <summary>
/// How far the joints of the arc at <paramref name="arcOrdinal"/> lie from the sharp corner it
/// rounds, when it is a corner fillet: tangent to straight edges at both ends, turning less
/// than a half circle the convex way, and reaching back along each edge no further than the
/// edge is long. A fillet's joints start the cut at the corner, where the mismatch when the
/// cut closes is least seen, instead of on a straight edge. Null for any other arc.
/// </summary>
private static double? FilletReach(Shape shape, int arcOrdinal)
{
var count = shape.Entities.Count;
if (shape.Entities[arcOrdinal] is not Arc arc
|| shape.Entities[(arcOrdinal + count - 1) % count] is not Line before
|| shape.Entities[(arcOrdinal + 1) % count] is not Line after)
return null;
var sweep = arc.SweepAngle();
if (sweep >= System.Math.PI - OpenNest.Math.Tolerance.Epsilon)
return null;
if (!IsSmooth(before.EndPoint, before) || !IsSmooth(arc.EndPoint(), arc))
return null;
var winding = ContourCuttingStrategy.DetermineWinding(shape);
if (ContourCuttingStrategy.ClassifyTurn((before.EndPoint - before.StartPoint).Normalize(),
(after.EndPoint - after.StartPoint).Normalize(), winding) != ContourCuttingStrategy.CornerKind.Convex)
return null;
var reach = arc.Radius * System.Math.Tan(sweep / 2);
return reach <= System.Math.Min(before.Length, after.Length) ? reach : null;
bool IsSmooth(Vector vertex, Entity entity) =>
ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)
&& corner.Kind == ContourCuttingStrategy.CornerKind.Smooth;
}
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
@@ -54,11 +54,9 @@ public sealed class ReleasedContourState
/// As <see cref="Check(OwnedExecution, Vector?, int, bool, CancellationToken)"/>, but a
/// finding whose offending motion <paramref name="isFixed"/> owns (by motion index) is
/// returned apart: a fixed emission is accepted as given and only reported. The checker
/// state advances exactly as it would without ownership. Crossings made by a rapid with the
/// head raised are returned apart as well, as Raised: they are the raise's reason, not a fault.
/// state advances exactly as it would without ownership.
/// </summary>
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed,
IReadOnlyList<PostVerificationFinding> Raised) Check(
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check(
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
CancellationToken token)
{
@@ -69,10 +67,8 @@ public sealed class ReleasedContourState
moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>();
var fixedFindings = new List<PostVerificationFinding>();
var raisedFindings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings,
raisedFindings: raisedFindings);
return (findings.AsReadOnly(), fixedFindings.AsReadOnly(), raisedFindings.AsReadOnly());
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
}
/// <summary>
@@ -101,7 +97,7 @@ public sealed class ReleasedContourState
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
bool proveOpenCuts = false, int contoursBefore = 0, List<PostVerificationFinding> raisedFindings = null)
bool proveOpenCuts = false, int contoursBefore = 0)
{
var current = new CutGroup();
var unfinished = new List<CutGroup>();
@@ -170,22 +166,16 @@ public sealed class ReleasedContourState
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))
continue;
var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
obstacle.Part, (move.HeadRaised ? "Head-raised" : "Direct") + " XY rapid crosses or touches "
+ $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}.")
{
Location = (start + move.End) * 0.5,
RapidStart = start,
RapidEnd = move.End,
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
};
// The head clears a cut part on a raised rapid; the crossing is the raise's reason.
if (move.HeadRaised)
raisedFindings?.Add(crossing);
else
Add(crossing, owned);
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}.")
{
Location = (start + move.End) * 0.5,
RapidStart = start,
RapidEnd = move.End,
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
}, owned);
}
}
else if (move.Layer == LayerType.Leadin)
@@ -867,18 +867,10 @@ namespace OpenNest.CNC.CuttingStrategy
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
/// not a corner.
/// </summary>
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding) =>
ClassifyTurn(corner.TangentIn, corner.TangentOut, winding);
/// <summary>
/// The corner a contour of <paramref name="winding"/> would make turning from travel
/// direction <paramref name="tangentIn"/> to <paramref name="tangentOut"/>, classified
/// exactly as a shared vertex is (see <see cref="TryClassifyAutomaticStartCorner"/>).
/// </summary>
internal static CornerKind ClassifyTurn(Vector tangentIn, Vector tangentOut, RotationType winding)
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding)
{
var cross = tangentIn.X * tangentOut.Y - tangentIn.Y * tangentOut.X;
var dot = tangentIn.DotProduct(tangentOut);
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
var turn = winding == RotationType.CCW ? cross : -cross;
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
-104
View File
@@ -1,104 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.CNC
{
/// <summary>
/// A rapid the head must make raised because it passes over parts already cut, which may have
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing: a
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>. Rapid checks accept
/// a crossing on a marked rapid. Posts write their machine's head raise before every marked rapid,
/// and refuse to post a marked program when they have none, since the rapid is unsafe head down.
/// See docs/cutting-planner.md.
/// </summary>
public static class HeadRaise
{
/// <summary>The comment immediately before a rapid made with the head raised.</summary>
public const string Marker = "HEAD RAISE";
public static bool IsMarker(ICode code) => code is Comment { Value: Marker };
/// <summary>Whether <paramref name="program"/>'s own codes hold a marker.</summary>
public static bool IsMarked(Program program)
{
if (program?.Codes == null)
return false;
foreach (var code in program.Codes)
if (IsMarker(code))
return true;
return false;
}
/// <summary>
/// For each of <paramref name="codes"/>, whether it is a rapid made with the head raised. A
/// marker must be followed directly by a rapid; anything else is a malformed program
/// (<see cref="ArgumentException"/>), since a post could raise before the wrong move.
/// </summary>
public static bool[] RaisedMask(IReadOnlyList<ICode> codes)
{
ArgumentNullException.ThrowIfNull(codes);
var mask = new bool[codes.Count];
for (var i = 0; i < codes.Count; i++)
{
if (!IsMarker(codes[i]))
continue;
if (i + 1 >= codes.Count || codes[i + 1] is not RapidMove)
throw new ArgumentException("A head-raise marker is not followed by a rapid.");
mask[i + 1] = true;
}
return mask;
}
/// <summary>
/// A copy of <paramref name="program"/> with a marker before each of its own codes at
/// <paramref name="rapids"/>, which must be rapids not marked already.
/// </summary>
public static Program Mark(Program program, IReadOnlyCollection<int> rapids)
{
ArgumentNullException.ThrowIfNull(program);
ArgumentNullException.ThrowIfNull(rapids);
var marked = RaisedMask(program.Codes);
var copy = (Program)program.Clone();
for (var i = copy.Codes.Count - 1; i >= 0; i--)
{
if (!rapids.Contains(i))
continue;
if (copy.Codes[i] is not RapidMove || marked[i])
throw new ArgumentException("Only an unmarked rapid can be marked for a head raise.");
copy.Codes.Insert(i, new Comment(Marker));
}
return copy;
}
/// <summary>
/// Refuses a nest a post cannot write, before any output is created: a marked rapid needs
/// the post's head-raise code, and its markers must be well formed.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCode, 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 (!hasCode)
throw new InvalidOperationException(
$"{name} has rapids that must raise the head over parts already cut, but the {postName} "
+ "post has no head-raise code. Enter it in the post's configuration before posting.");
try
{
RaisedMask(part.Program.Codes);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
}
}
}
}
}
}
+12 -64
View File
@@ -262,8 +262,7 @@ 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 lockedFarEdge = LockedFarEdge(plate);
var overtravel = lockedFarEdge is { High: true } ? 0 : settings.Overtravel;
var overtravel = LockedFarEdge(plate) is { High: true } ? 0 : settings.Overtravel;
double lineStart,
lineEnd,
@@ -282,13 +281,6 @@ namespace OpenNest
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
}
// An outlined sheet is cut only where the line crosses material: each crossing is a
// span of its own, started at the material's edge and overtravelled past the far
// one, within the user's limits.
var spans = plate.Outline == null
? new List<(double Start, double End)> { (lineStart, lineEnd) }
: MaterialSpans(plate, cutPosition, lineStart, lineEnd, settings.Overtravel, lockedFarEdge);
var exclusions = new List<(double Start, double End)>();
foreach (var part in plate.Parts)
@@ -333,71 +325,27 @@ namespace OpenNest
}
var segments = new List<(double Start, double End)>();
foreach (var (spanStart, spanEnd) in spans)
var current = lineStart;
foreach (var ex in merged)
{
var current = spanStart;
var clampedStart = System.Math.Max(ex.Start, lineStart);
var clampedEnd = System.Math.Min(ex.End, lineEnd);
foreach (var ex in merged)
{
var clampedStart = System.Math.Max(ex.Start, spanStart);
var clampedEnd = System.Math.Min(ex.End, spanEnd);
if (clampedStart > current)
segments.Add((current, clampedStart));
if (clampedStart > current)
segments.Add((current, clampedStart));
current = System.Math.Max(current, clampedEnd);
}
if (current < spanEnd)
segments.Add((current, spanEnd));
current = System.Math.Max(current, clampedEnd);
}
if (current < lineEnd)
segments.Add((current, lineEnd));
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
return segments;
}
/// <summary>
/// Where the line at <paramref name="cutPosition"/> crosses the sheet's outlined material,
/// in ascending order along the axis and clipped to the line's limits. Each span starts at
/// the material edge and runs <paramref name="overtravel"/> past the far one unless a limit
/// or a head-locked far edge ends it there.
/// </summary>
private List<(double Start, double End)> MaterialSpans(Plate plate, double cutPosition,
double lineStart, double lineEnd, double overtravel, (double Edge, bool High)? lockedFarEdge)
{
var vertical = Axis == CutOffAxis.Vertical;
var crossings = new List<double>();
foreach (var path in plate.Outline.Material(plate.Outline.PlacementOrigin(plate.Quadrant)))
{
for (var i = 0; i < path.Count; i++)
{
var a = path[i];
var b = path[(i + 1) % path.Count];
var (aAcross, aAlong) = vertical ? (a.x, a.y) : (a.y, a.x);
var (bAcross, bAlong) = vertical ? (b.x, b.y) : (b.y, b.x);
// Half-open on the across axis, so a line through a vertex counts one edge.
if (aAcross <= cutPosition == bAcross <= cutPosition)
continue;
crossings.Add(aAlong + (bAlong - aAlong) * (cutPosition - aAcross) / (bAcross - aAcross));
}
}
crossings.Sort();
var spans = new List<(double Start, double End)>();
for (var i = 0; i + 1 < crossings.Count; i += 2)
{
var start = System.Math.Max(crossings[i], lineStart);
var end = crossings[i + 1];
var limited = EndLimit != null && end >= EndLimit.Value
|| lockedFarEdge is var (edge, high) && high && end == edge;
end = System.Math.Min(limited ? end : end + overtravel, lineEnd);
if (end > start)
spans.Add((start, end));
}
return spans;
}
private static readonly List<(double Start, double End)> EmptyExclusions = new();
private List<(double Start, double End)> GetPartExclusions(
-274
View File
@@ -1,274 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
/// <summary>The limits one cut-off takes when a <see cref="CutOffEdit"/> is applied.</summary>
public sealed record CutOffLimitChange(CutOff CutOff, double? StartLimit, double? EndLimit);
/// <summary>
/// A worked-out trim, extend or swap: the new limits of each cut-off it changes, or why it was
/// refused. Nothing changes until <see cref="Apply"/>; the caller then regenerates the plate's
/// cut-offs and places their segments in the cutting order.
/// </summary>
public sealed class CutOffEdit
{
private CutOffEdit(IReadOnlyList<CutOffLimitChange> changes, string refusal, string notice)
{
Changes = changes;
Refusal = refusal;
Notice = notice;
}
public IReadOnlyList<CutOffLimitChange> Changes { get; }
/// <summary>Why the edit cannot be made; null when it can.</summary>
public string Refusal { get; }
/// <summary>Something the operator should know once it is made, such as a head-locked overcut turning off.</summary>
public string Notice { get; }
public bool Succeeded => Refusal == null;
internal static CutOffEdit Refused(string reason) => new(System.Array.Empty<CutOffLimitChange>(), reason, null);
internal static CutOffEdit Of(IReadOnlyList<CutOffLimitChange> changes, string notice) => new(changes, null, notice);
public void Apply()
{
if (!Succeeded)
throw new System.InvalidOperationException(Refusal);
foreach (var change in Changes)
{
change.CutOff.StartLimit = change.StartLimit;
change.CutOff.EndLimit = change.EndLimit;
}
}
}
/// <summary>
/// Trims and extends cut-offs against the cut-offs that cross them, by changing only their
/// start and end limits (see docs/automatic-scrap-cutoffs.md, "Trim and extend"). A trim stops
/// on the crossing line's centre, inside its kerf; an extend runs to the next crossing line
/// beyond the current end, or to the sheet edge, which clears the limit.
/// </summary>
public static class CutOffTrim
{
public const string NotOnPlate = "That cut-off is not on this plate.";
public const string NothingToTrimTo = "No cut-off crosses this line to trim it to; delete it instead.";
public const string MiddlePiece = "Trimming the middle piece would split the line in two; trim an end piece instead.";
public const string TooShort = "Trimming here would leave a line shorter than the minimum segment length.";
public const string AtSheetEdge = "This end already runs to the sheet edge.";
public const string NotAJunction = "These cut-offs do not meet with one running through and the other stopping at it.";
public const string OvercutOff = "The head-locked overcut is off while this line stops short of the far edge.";
public const string OvercutOn = "The head-locked overcut is back on at the far edge.";
/// <summary>
/// Removes the piece of <paramref name="target"/> picked at <paramref name="pick"/>, between
/// the crossing cut-offs on either side of it (or the line's end): the limit on that side
/// moves to the crossing line.
/// </summary>
public static CutOffEdit Trim(Plate plate, CutOff target, Vector pick, CutOffSettings settings)
{
if (!plate.CutOffs.Contains(target))
return CutOffEdit.Refused(NotOnPlate);
var (start, end) = NominalSpan(plate, target);
var at = System.Math.Clamp(Along(target, pick), start, end);
var crossings = Crossings(plate, target)
.Where(c => c > start + Tolerance.Epsilon && c < end - Tolerance.Epsilon)
.ToList();
if (crossings.Count == 0)
return CutOffEdit.Refused(NothingToTrimTo);
var below = crossings.Where(c => c <= at).DefaultIfEmpty(double.NaN).Max();
var above = crossings.Where(c => c > at).DefaultIfEmpty(double.NaN).Min();
if (!double.IsNaN(below) && !double.IsNaN(above))
return CutOffEdit.Refused(MiddlePiece);
var (newStart, newEnd) = double.IsNaN(above)
? (target.StartLimit, (double?)below)
: ((double?)above, target.EndLimit);
var remaining = double.IsNaN(above) ? below - start : end - above;
if (remaining < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
return CutOffEdit.Refused(TooShort);
return Single(plate, target, newStart, newEnd);
}
/// <summary>
/// Lengthens the end of <paramref name="target"/> nearer <paramref name="pick"/> to the next
/// crossing cut-off beyond it, or to the sheet edge when there is none. A cut-off sitting at
/// the current end is skipped, so picking again extends one more step.
/// </summary>
public static CutOffEdit Extend(Plate plate, CutOff target, Vector pick)
{
if (!plate.CutOffs.Contains(target))
return CutOffEdit.Refused(NotOnPlate);
var (start, end) = NominalSpan(plate, target);
var at = Along(target, pick);
var high = System.Math.Abs(at - end) <= System.Math.Abs(at - start);
return ExtendEnd(plate, target, high, out var limit)
? Single(plate, target, high ? target.StartLimit : limit, high ? limit : target.EndLimit)
: CutOffEdit.Refused(AtSheetEdge);
}
/// <summary>
/// Swaps which of two meeting cut-offs runs through: the one that stopped at the junction
/// extends past it (as <see cref="Extend"/> does), and the one that ran through is trimmed
/// there, losing the side away from the plate's origin. Only the origin-side sheet edge is
/// certain, so the trimmed line keeps starting there.
/// </summary>
public static CutOffEdit Swap(Plate plate, CutOff first, CutOff second, CutOffSettings settings)
{
if (!plate.CutOffs.Contains(first) || !plate.CutOffs.Contains(second))
return CutOffEdit.Refused(NotOnPlate);
if (Junction(plate, first, second) is not var (through, stopper, point))
return CutOffEdit.Refused(NotAJunction);
var (start, end) = NominalSpan(plate, through);
var farHigh = CutOff.FarEdgeIsHigh(through.Axis, plate.Quadrant);
var remaining = farHigh ? point - start : end - point;
if (remaining < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
return CutOffEdit.Refused(TooShort);
var throughChange = farHigh
? new CutOffLimitChange(through, through.StartLimit, point)
: new CutOffLimitChange(through, point, through.EndLimit);
var (stopStart, stopEnd) = NominalSpan(plate, stopper);
var stopperPoint = Line(through);
var high = System.Math.Abs(stopperPoint - stopEnd) <= System.Math.Abs(stopperPoint - stopStart);
if (!ExtendEnd(plate, stopper, high, out var limit))
return CutOffEdit.Refused(NotAJunction);
var stopperChange = high
? new CutOffLimitChange(stopper, stopper.StartLimit, limit)
: new CutOffLimitChange(stopper, limit, stopper.EndLimit);
var notices = new[] { Notice(plate, throughChange), Notice(plate, stopperChange) }
.Where(n => n != null).Distinct().ToList();
return CutOffEdit.Of(new[] { stopperChange, throughChange },
notices.Count == 0 ? null : string.Join(" ", notices));
}
/// <summary>
/// The two cut-offs meeting within <paramref name="tolerance"/> of <paramref name="point"/>,
/// one running through the junction and the other stopping at it; null when there is none.
/// </summary>
public static (CutOff Through, CutOff Stopper)? JunctionAt(Plate plate, Vector point, double tolerance)
{
if (plate?.CutOffs == null)
return null;
(CutOff, CutOff)? best = null;
var bestDistance = double.MaxValue;
foreach (var vertical in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical))
foreach (var horizontal in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Horizontal))
{
if (Junction(plate, vertical, horizontal) is not var (through, stopper, _))
continue;
var distance = new Vector(vertical.Position.X, horizontal.Position.Y).DistanceTo(point);
if (distance <= tolerance && distance < bestDistance)
{
best = (through, stopper);
bestDistance = distance;
}
}
return best;
}
/// <summary>
/// Where <paramref name="cutOff"/>'s line runs along its axis: its limits, or the sheet
/// edges where it has none. Overtravel is not part of the nominal line.
/// </summary>
public static (double Start, double End) NominalSpan(Plate plate, CutOff cutOff)
{
var sheet = plate.BoundingBox(includeParts: false);
var vertical = cutOff.Axis == CutOffAxis.Vertical;
return (cutOff.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
cutOff.EndLimit ?? (vertical ? sheet.Top : sheet.Right));
}
private static (CutOff Through, CutOff Stopper, double Point)? Junction(Plate plate, CutOff first, CutOff second)
{
if (first == second || first.Axis == second.Axis)
return null;
var firstAt = Line(second);
var secondAt = Line(first);
var (firstStart, firstEnd) = NominalSpan(plate, first);
var (secondStart, secondEnd) = NominalSpan(plate, second);
if (!Within(firstAt, firstStart, firstEnd) || !Within(secondAt, secondStart, secondEnd))
return null;
var firstStops = AtEnd(firstAt, firstStart, firstEnd);
var secondStops = AtEnd(secondAt, secondStart, secondEnd);
if (firstStops == secondStops)
return null;
return firstStops ? (second, first, secondAt) : (first, second, firstAt);
}
// The limit an end takes when extended: the next crossing line beyond it, or null for the
// sheet edge. False when that end already runs to the sheet edge.
private static bool ExtendEnd(Plate plate, CutOff target, bool high, out double? limit)
{
limit = null;
if ((high ? target.EndLimit : target.StartLimit) == null)
return false;
var (start, end) = NominalSpan(plate, target);
var crossings = Crossings(plate, target);
if (high)
{
var next = crossings.Where(c => c > end + Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Min();
limit = double.IsNaN(next) ? null : next;
}
else
{
var next = crossings.Where(c => c < start - Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Max();
limit = double.IsNaN(next) ? null : next;
}
return true;
}
// Where the perpendicular cut-offs whose lines reach this one cross it, along its axis.
private static List<double> Crossings(Plate plate, CutOff target)
{
var line = Line(target);
return plate.CutOffs
.Where(other => other != target && other.Axis != target.Axis)
.Where(other =>
{
var (start, end) = NominalSpan(plate, other);
return Within(line, start, end);
})
.Select(Line)
.ToList();
}
private static CutOffEdit Single(Plate plate, CutOff target, double? start, double? end)
{
var change = new CutOffLimitChange(target, start, end);
return CutOffEdit.Of(new[] { change }, Notice(plate, change));
}
private static string Notice(Plate plate, CutOffLimitChange change)
{
var cutOff = change.CutOff;
if (!(cutOff.HeadLockOvercut > 0))
return null;
var high = CutOff.FarEdgeIsHigh(cutOff.Axis, plate.Quadrant);
var before = (high ? cutOff.EndLimit : cutOff.StartLimit) == null;
var after = (high ? change.EndLimit : change.StartLimit) == null;
return before == after ? null : after ? OvercutOn : OvercutOff;
}
// The cut-off's own coordinate across its axis: X for a vertical line, Y for a horizontal one.
private static double Line(CutOff cutOff) =>
cutOff.Axis == CutOffAxis.Vertical ? cutOff.Position.X : cutOff.Position.Y;
private static double Along(CutOff cutOff, Vector point) =>
cutOff.Axis == CutOffAxis.Vertical ? point.Y : point.X;
private static bool Within(double value, double start, double end) =>
value >= start - Tolerance.Epsilon && value <= end + Tolerance.Epsilon;
private static bool AtEnd(double value, double start, double end) =>
System.Math.Abs(value - start) <= Tolerance.Epsilon || System.Math.Abs(value - end) <= Tolerance.Epsilon;
}
}
+7 -74
View File
@@ -31,17 +31,11 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
}
// Sampling can hide a crossing or tangency between curves. Reject native
// contour contact before asking the polygon approximation about containment.
// A contact lies within both entities' bounds, so shapes whose bounds are clear by more
// than any native query's allowance cannot touch: a part with thousands of holes does
// not query every pair.
var boxes = shapes.Select(shape => shape.BoundingBox).ToList();
for (var i = 0; i < shapes.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
for (var j = 0; j < i; j++)
{
if (ClearOfNativeQueries(boxes[i], boxes[j]))
continue;
shapes[i].Intersects(shapes[j], out var intersections);
if (intersections.Count > 0)
throw new ArgumentException("Native material contours cross or touch.");
@@ -50,70 +44,23 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
var outer = ordered[0];
var holes = ordered.Skip(1).ToList();
var outerBounds = BoundsOf(outer);
var holeBounds = holes.Select(BoundsOf).ToList();
for (var i = 0; i < holes.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
if (BoundariesTouch(outer, outerBounds, holes[i], holeBounds[i], cancellationToken)
|| !Inside(outer, outerBounds, holes[i].Vertices[0]))
if (BoundariesTouch(outer, holes[i], cancellationToken)
|| !Inside(outer, holes[i].Vertices[0]))
throw new ArgumentException("Contours must have one outer with strictly internal holes.");
for (var j = 0; j < i; j++)
{
// Exact polygon tests: holes whose vertex bounds are apart can neither touch
// nor contain each other's vertices.
if (!Overlap(holeBounds[i], holeBounds[j]))
continue;
if (BoundariesTouch(holes[i], holeBounds[i], holes[j], holeBounds[j], cancellationToken)
|| Inside(holes[i], holeBounds[i], holes[j].Vertices[0])
|| Inside(holes[j], holeBounds[j], holes[i].Vertices[0]))
if (BoundariesTouch(holes[i], holes[j], cancellationToken)
|| Inside(holes[i], holes[j].Vertices[0])
|| Inside(holes[j], holes[i].Vertices[0]))
throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
}
}
return new OverlapMaterial(outer, holes);
}
/// <summary>
/// The gap beyond which no native intersection query counts two entities as touching: ten
/// times the 0.00001 bounding-box allowance of native intersections, as the planner's
/// <c>PostVerificationGeometry.Extent.ClearMargin</c>.
/// </summary>
private const double NativeClearMargin = 1e-3;
// True only when both boxes are finite and apart by more than the native allowance on some axis.
private static bool ClearOfNativeQueries(Box a, Box b) =>
IsFinite(a) && IsFinite(b)
&& (a.Right + NativeClearMargin < b.Left || b.Right + NativeClearMargin < a.Left
|| a.Top + NativeClearMargin < b.Bottom || b.Top + NativeClearMargin < a.Bottom);
private static bool IsFinite(Box box) => double.IsFinite(box.Left) && double.IsFinite(box.Right)
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top);
/// <summary>Exact vertex bounds of a sampled polygon, for skipping segment and winding tests.</summary>
private readonly record struct Bounds(double MinX, double MinY, double MaxX, double MaxY)
{
internal bool Contains(Vector point) =>
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
}
private static Bounds BoundsOf(Polygon polygon)
{
double minX = double.PositiveInfinity, minY = double.PositiveInfinity,
maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
foreach (var vertex in polygon.Vertices)
{
minX = System.Math.Min(minX, vertex.X);
minY = System.Math.Min(minY, vertex.Y);
maxX = System.Math.Max(maxX, vertex.X);
maxY = System.Math.Max(maxY, vertex.Y);
}
return new(minX, minY, maxX, maxY);
}
// Closed-interval overlap: two segments that touch share a point inside both bounds.
private static bool Overlap(Bounds a, Bounds b) =>
a.MaxX >= b.MinX && b.MaxX >= a.MinX && a.MaxY >= b.MinY && b.MaxY >= a.MinY;
internal OverlapMaterial Transform(double rotation, Vector offset) =>
new(TransformPolygon(Outer, rotation, offset),
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
@@ -224,23 +171,13 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
throw new ArgumentException("Material contour could not be completely triangulated.");
}
// Exact: a segment of a whose bounds are clear of b's bounds touches no segment of b.
private static bool BoundariesTouch(Polygon a, Bounds aBounds, Polygon b, Bounds bBounds,
CancellationToken cancellationToken)
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken)
{
if (!Overlap(aBounds, bBounds))
return false;
for (var i = 0; i + 1 < a.Vertices.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var start = a.Vertices[i];
var end = a.Vertices[i + 1];
var segment = new Bounds(System.Math.Min(start.X, end.X), System.Math.Min(start.Y, end.Y),
System.Math.Max(start.X, end.X), System.Math.Max(start.Y, end.Y));
if (!Overlap(segment, bBounds))
continue;
for (var j = 0; j + 1 < b.Vertices.Count; j++)
if (SegmentsTouch(start, end, b.Vertices[j], b.Vertices[j + 1]))
if (SegmentsTouch(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1]))
return true;
}
return false;
@@ -263,10 +200,6 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
&& point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y);
// Boundary contact is rejected before this winding-number test is used for topology.
// A point outside the polygon's vertex bounds has winding zero.
private static bool Inside(Polygon polygon, Bounds bounds, Vector point) =>
bounds.Contains(point) && Inside(polygon, point);
private static bool Inside(Polygon polygon, Vector point)
{
var winding = 0;
@@ -68,10 +68,6 @@ internal static class PostVerificationGeometry
return IsClearOf(other, ClearMargin + reach + SpanRounding * span);
}
/// <summary>True when <paramref name="point"/> lies within the extent, its edges included.</summary>
internal bool Contains(Vector point) =>
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
/// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary>
internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit
&& (y > MaxY + ClearMargin || y < MinY - ClearMargin);
@@ -401,9 +397,9 @@ internal static class PostVerificationGeometry
points.Add(point);
// Existing exact line/ray contact semantics guard native queries which
// suppress very short or nearly parallel intersections. Uncertainty refuses.
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other, points))
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other))
throw new NotSupportedException("Native contact query is numerically uncertain.");
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this, points))
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this))
throw new NotSupportedException("Native contact query is numerically uncertain.");
return points;
@@ -419,35 +415,16 @@ internal static class PostVerificationGeometry
/// endpoint (both stop half-way when both endpoints are contained): a line meeting a
/// tangent arc at their shared vertex is otherwise rediscovered there as a contact the
/// native query missed, because rounding can drop the tangent root of its quadratic.
/// For the same reason a ray hit within <see cref="RaySeamTolerance"/> of a
/// <paramref name="recorded"/> contact is that contact: an arc lead ending tangent on a
/// straight edge, mid-edge, has its end recorded and its double root dropped.
/// </summary>
private static bool ExactLineContact(Curve line, Curve curve, IReadOnlyList<Vector> recorded)
private static bool ExactLineContact(Curve line, Curve curve)
{
var direction = (line.End - line.Start) * (1 / line.Length);
var startRecorded = curve.Contains(line.Start);
var endRecorded = curve.Contains(line.End);
var forward = endRecorded ? startRecorded ? line.Length / 2 : 0 : line.Length;
var backward = startRecorded ? endRecorded ? line.Length / 2 : 0 : line.Length;
return (forward > 0 && Unrecorded(line.Start, direction, forward))
|| (backward > 0 && Unrecorded(line.End, direction * -1, backward));
bool Unrecorded(Vector origin, Vector ray, double length)
{
var hits = new List<double>();
curve.ContactAfterStart(origin, ray, length, hits);
foreach (var distance in hits)
{
var hit = origin + ray * distance;
var known = false;
foreach (var point in recorded)
known |= point.DistanceTo(hit) <= RaySeamTolerance;
if (!known)
return true;
}
return false;
}
return (forward > 0 && curve.ContactAfterStart(line.Start, direction, forward))
|| (backward > 0 && curve.ContactAfterStart(line.End, direction * -1, backward));
}
+1 -6
View File
@@ -41,11 +41,6 @@ namespace OpenNest.Geometry
// the joint's tangents; otherwise the neighbour reads as a second boundary there.
internal const double JointTolerance = 1e-6;
// Loops are recovered with ShapeBuilder's chaining tolerance: a source contour that
// closes within it (PEP rounding leaves 1e-6 gaps) is a loop everywhere else, and an
// unrecovered loop leaves every contact with the whole part unresolved and blocking.
internal const double ChainTolerance = Tolerance.ChainTolerance;
private readonly List<Entity> entities;
private readonly int[] loopOf;
private readonly int[] previous;
@@ -378,7 +373,7 @@ namespace OpenNest.Geometry
}
}
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= ChainTolerance;
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance;
private static double SignedArea(List<Entity> entities, int first, int last)
{
+32 -120
View File
@@ -98,9 +98,6 @@ namespace OpenNest
// 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>();
// An outline is a particular sheet; any other size is a rectangle again.
if (outline != null && (outline.Size.Width != value.Width || outline.Size.Length != value.Length))
outline = null;
size = value;
ResolveClampRail();
}
@@ -108,26 +105,6 @@ namespace OpenNest
private Size size;
/// <summary>
/// The sheet's material when it is not a rectangle (see <see cref="SheetOutline"/>), in
/// plate coordinates; null for a rectangular sheet. Setting one makes <see cref="Size"/> its
/// bounding box, and setting any other size drops it. Only the Irregular whole-job engine
/// nests on an outlined sheet; cut-offs are trimmed to it, automatic cut-offs refuse it, and
/// <see cref="Area"/> is its material area.
/// </summary>
public SheetOutline Outline
{
get { return outline; }
set
{
outline = value;
if (value != null)
Size = value.Size;
}
}
private SheetOutline outline;
private static bool SameSheet(Size a, Size b) =>
a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
@@ -326,13 +303,11 @@ namespace OpenNest
public void RegenerateCutOffs(CutOffSettings settings)
{
var saved = new Dictionary<CutOff, List<(int Index, double Start, double End)>>();
var places = new List<int>();
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (!Parts[i].BaseDrawing.IsCutOff)
continue;
places.Add(i);
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
if (cutoff != null)
{
@@ -368,7 +343,7 @@ namespace OpenNest
indices[k] = previous = best >= 0 ? best : previous;
}
return indices;
}, _ => places);
});
}
/// <summary>
@@ -378,78 +353,38 @@ namespace OpenNest
/// from it are added at the end. Callers must remove existing cut-off parts first.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence) =>
RegenerateCutOffs(settings, null, 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 at its smallest index.
/// consecutively from its smallest index.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> sequence) =>
RegenerateCutOffs(settings, sequence, null);
/// <summary>
/// Places each cut-off's regenerated segments where its saved segments were, among the
/// parts saved around them: <paramref name="places"/> lists every saved segment's place;
/// <paramref name="firstPlaces"/> gives only the first, for files that recorded no more
/// (their segments were consecutive, but how many is unknown). Settings other than the
/// saved ones can regenerate a different number of segments; the parts keep their
/// places relative to the cut-offs either way. Where only a first place is known the
/// count is a guess, so a cut-off that splits the sheet goes last, as sequencing puts it.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> places,
IReadOnlyDictionary<CutOff, int> firstPlaces) =>
RegenerateCutOffs(settings, (cutoff, extents) =>
{
if (places != null && places.TryGetValue(cutoff, out var indices) && indices is { Count: > 0 })
{
if (indices.Count != extents.Count)
return Enumerable.Repeat(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;
}
return firstPlaces != null && firstPlaces.TryGetValue(cutoff, out var first)
? Enumerable.Repeat(first, extents.Count).ToArray() : null;
}, counts =>
{
var saved = new HashSet<int>();
foreach (var list in places?.Values ?? [])
saved.UnionWith(list ?? []);
if (firstPlaces == null)
return saved;
// A cut-off saved by its first place alone held consecutive places, as many as it
// has segments now unless that runs into the next cut-off's first place.
var starts = saved.Concat(firstPlaces.Values).Distinct().Order().ToArray();
foreach (var (cutoff, first) in firstPlaces)
{
var next = starts.FirstOrDefault(start => start > first, int.MaxValue);
var count = System.Math.Max(1, System.Math.Min(counts.GetValueOrDefault(cutoff, 1), next - first));
saved.UnionWith(Enumerable.Range(first, count));
}
return saved;
}, cutoff => firstPlaces != null && firstPlaces.ContainsKey(cutoff));
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 saved place for each segment in program order, from its
// segments' extents along its axis (NaN when unmeasurable), or null to append them.
// savedPlaces gives every place cut-off segments held in the saved order, from each
// cut-off's regenerated segment count; the parts held the others, in their order.
// separateLast says which cut-offs go after everything when they split the sheet.
// 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,
Func<IReadOnlyDictionary<CutOff, int>, IEnumerable<int>> savedPlaces,
Func<CutOff, bool> separateLast = null)
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence)
{
var cache = BuildPerimeterCache(this);
var sheet = BoundingBox(includeParts: false);
var placed = new List<(int Index, int Order, Part Part)>();
var separating = new List<Part>();
var counts = new Dictionary<CutOff, int>();
var partCount = Parts.Count;
// Regenerate and materialize each cut-off's segments
foreach (var cutoff in CutOffs)
@@ -464,34 +399,20 @@ namespace OpenNest
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);
counts[cutoff] = parts.Length;
for (var k = 0; k < parts.Length; k++)
{
// A line splitting the sheet comes after every part and every other cut-off.
if (separateLast?.Invoke(cutoff) == true && CutOff.SeparatesSheet(parts[k], cutoff, sheet))
separating.Add(parts[k]);
else if (indices != null && k < indices.Count)
if (indices != null && k < indices.Count)
placed.Add((indices[k], placed.Count, parts[k]));
else
Parts.Add(parts[k]);
}
}
// Saved places count every cut-off segment then on the plate, which may now be more or
// fewer. A segment goes after the parts saved before it and the segments placed before
// it, so a changed count never shifts a later cut-off or part. Segments sharing a place
// keep their program order.
var saved = (savedPlaces?.Invoke(counts) ?? []).Distinct().Order().ToArray();
var inserted = 0;
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenBy(p => p.Order))
{
var savedBefore = Array.BinarySearch(saved, index);
savedBefore = savedBefore < 0 ? ~savedBefore : savedBefore;
var partsBefore = System.Math.Clamp(index - savedBefore, 0, partCount);
Parts.Insert(partsBefore + inserted++, part);
}
foreach (var part in separating)
Parts.Add(part);
// Lowest index first: each insert then lands on its saved index, because every part
// sequenced before it is already in place. Segments sharing an index keep their
// program order (a later insert at the same index lands before an earlier one).
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenByDescending(p => p.Order))
Parts.Insert(System.Math.Clamp(index, 0, Parts.Count), part);
}
/// <summary>
@@ -535,13 +456,10 @@ namespace OpenNest
// Leads are cutting paths in scrap, not boundary edges. Chaining them
// into the outline can make a closed part appear open and replace its
// recesses with a convex hull. A cut-off may cross a lead, so the open-
// contour hull also leaves them out: hulling a lead's pierce point would
// stop cut-offs far short of the part.
// recesses with a convex hull. Keep the full geometry for the existing
// conservative fallback when the material contour really is open.
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
e.Layer != SpecialLayers.Leadout).ToList();
if (outline.Count == 0)
return null;
var profile = new Geometry.ShapeProfile(outline);
if (profile.Perimeter.IsClosed())
@@ -551,7 +469,7 @@ namespace OpenNest
return perimeter;
}
var points = outline.CollectPoints();
var points = entities.CollectPoints();
if (points.Count < 3)
return null;
@@ -608,9 +526,7 @@ namespace OpenNest
{
const double oneAndHalfPI = System.Math.PI * 1.5;
var turned = Outline?.Rotated90(rotationDirection);
Size = new Size(Size.Length, Size.Width);
Outline = turned;
// After Size swap above, new Size.Width = old Length (old X extent),
// new Size.Length = old Width (old Y extent).
@@ -716,12 +632,10 @@ namespace OpenNest
}
Rotate(System.Math.PI, centerpt);
Outline = Outline?.Rotated180();
}
else
{
Rotate(System.Math.PI);
Outline = Outline?.Rotated180();
Quadrant = (Quadrant + 2) % 4;
if (Quadrant == 0)
@@ -927,8 +841,7 @@ namespace OpenNest
/// </example>
public void AutoSize(double roundingFactor = 1.0)
{
// An outlined sheet is a particular piece of material; it cannot be resized to fit.
if (Parts.Count == 0 || Outline != null)
if (Parts.Count == 0)
return;
var bounds = Parts.GetBoundingBox();
@@ -977,7 +890,7 @@ namespace OpenNest
/// </summary>
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
{
if (Parts.Count == 0 || Outline != null)
if (Parts.Count == 0)
return default;
var bounds = Parts.GetBoundingBox();
@@ -1029,13 +942,12 @@ namespace OpenNest
}
/// <summary>
/// Gets the area of the top surface of the plate: the material inside its
/// <see cref="Outline"/> when it has one, otherwise the whole rectangle.
/// Gets the area of the top surface of the plate.
/// </summary>
/// <returns></returns>
public double Area()
{
return Outline?.Area ?? Size.Width * Size.Length;
return Size.Width * Size.Length;
}
/// <summary>
-1
View File
@@ -133,7 +133,6 @@ namespace OpenNest
var followsDefaults = trailing != null
&& trailing.Parts.Count == 0
&& trailing.CutOffs.Count == 0
&& trailing.Outline == null
&& trailing.Size.Width == defaults.Size.Width
&& trailing.Size.Length == defaults.Size.Length
&& trailing.Quadrant == defaults.Quadrant
+1 -5
View File
@@ -38,14 +38,10 @@ public sealed class PlateRemnant
/// (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. A plate
/// with an <see cref="Plate.Outline"/> reports none.
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks.
/// </summary>
public static PlateRemnant Expected(Plate plate, double minimumDimension)
{
// No rectangular strip is guaranteed inside an outlined sheet.
if (plate.Outline != null)
return null;
// A part with no material (marks only) bounds nothing.
var bounds = plate.Parts
.Where(p => !p.BaseDrawing.IsCutOff && p.Program != null)
-293
View File
@@ -1,293 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// The material of a sheet that is not a rectangle: a remnant, a drop, or a sheet with a
/// burned or damaged area. A closed polygon in plate coordinates (the sheet's lower-left
/// frame, as <see cref="Plate.ClampZones"/>) with optional obstacle polygons for areas inside
/// it that must stay empty. The polygon lies inside the true material and every obstacle
/// polygon contains its true area, so chording never adds room. Its bounding box starts at
/// the origin and is the sheet's <see cref="Size"/>; rectangular code paths keep using that
/// box. Immutable.
/// </summary>
public sealed class SheetOutline : IEquatable<SheetOutline>
{
/// <summary>Chord deviation when importing arcs; the outline is also eroded, and each
/// obstacle inflated, by this much so the stored polygons never reach past the true edges.</summary>
public const double ChordTolerance = SpacingOutline.ChordTolerance;
private const int Precision = ClipperBridge.Precision;
private const double ArcTolerance = 0.001;
private const double MiterLimit = 2.0;
private readonly PathD outline;
private readonly PathsD obstacles;
private readonly PathsD material;
/// <summary>
/// Builds an outline from its vertices and optional obstacles, each a simple polygon of at
/// least three finite vertices with a positive area. Winding is normalized and the whole
/// is translated so the outline's bounds start at the origin; an obstacle wholly outside
/// the outline is dropped.
/// </summary>
/// <exception cref="ArgumentException">A polygon is missing, degenerate, nonfinite or
/// self-intersecting.</exception>
public SheetOutline(IEnumerable<Vector> points, IEnumerable<IEnumerable<Vector>> obstacles = null)
{
ArgumentNullException.ThrowIfNull(points);
var path = SimplePolygon(points, "outline");
var box = Clipper.GetBounds(path);
var shift = new PointD(-box.left, -box.top); // RectD top is the minimum Y.
outline = Clipper.TranslatePath(path, shift.x, shift.y);
Size = new Size(box.Height, box.Width);
this.obstacles = new PathsD();
if (obstacles != null)
{
var index = 0;
foreach (var obstacle in obstacles)
{
index++;
var obstaclePath = Clipper.TranslatePath(SimplePolygon(obstacle, $"obstacle {index}"), shift.x, shift.y);
var inside = Clipper.Intersect(new PathsD { outline }, new PathsD { obstaclePath }, FillRule.NonZero, Precision);
if (inside.Sum(Clipper.Area) > 0)
this.obstacles.Add(obstaclePath);
}
}
material = Clipper.Difference(new PathsD { outline }, this.obstacles, FillRule.NonZero, Precision);
Area = material.Sum(Clipper.Area);
if (!(Area > 0))
throw new ArgumentException("The obstacles leave no material inside the outline.", nameof(obstacles));
Points = outline.Select(p => new Vector(p.x, p.y)).ToArray();
Obstacles = this.obstacles
.Select(o => (IReadOnlyList<Vector>)o.Select(p => new Vector(p.x, p.y)).ToArray())
.ToArray();
}
/// <summary>Vertices of the outline, counterclockwise, in plate coordinates.</summary>
public IReadOnlyList<Vector> Points { get; }
/// <summary>Vertices of each obstacle, counterclockwise, in plate coordinates.</summary>
public IReadOnlyList<IReadOnlyList<Vector>> Obstacles { get; }
/// <summary>The bounding box of the outline: the plate's size.</summary>
public Size Size { get; }
/// <summary>Material area: the outline less its obstacles.</summary>
public double Area { get; }
/// <summary>
/// Where the plate's lower-left corner lies in the placement frame of its parts for
/// <paramref name="quadrant"/>: -Length on X in quadrants 2 and 3, -Width on Y in 3 and 4,
/// exactly as <see cref="ClampZoneFrame"/> and the engine stock translate.
/// </summary>
public Vector PlacementOrigin(int quadrant) =>
new(quadrant is 2 or 3 ? -Size.Length : 0, quadrant is 3 or 4 ? -Size.Width : 0);
/// <summary>
/// The outline less its obstacles, translated by <paramref name="origin"/>: outer paths
/// positive, holes negative.
/// </summary>
public PathsD Material(Vector origin) => Translate(material, origin);
/// <summary>
/// The material eroded by <paramref name="inset"/> (round joins) and translated by
/// <paramref name="origin"/>: where a part may lie when it must keep <paramref name="inset"/>
/// from every edge and obstacle. Empty when nothing is left. A nonpositive inset returns
/// the material itself.
/// </summary>
public PathsD Usable(double inset, Vector origin)
{
if (!(inset > 0))
return Material(origin);
var eroded = Clipper.InflatePaths(material, -inset, JoinType.Round, EndType.Polygon,
MiterLimit, Precision, ArcTolerance);
eroded.RemoveAll(p => p.Count < 3);
return Translate(eroded, origin);
}
/// <summary>
/// Whether a region lies inside the material eroded by <paramref name="inset"/>, allowing
/// <paramref name="slack"/> outside it: nothing of <paramref name="region"/> (outer paths
/// positive, holes negative, in the frame of <paramref name="origin"/>) is left after
/// subtracting the usable area grown by the slack. The test is exact on the stored polygons;
/// it never loosens for arcs.
/// </summary>
public bool Holds(PathsD region, double inset, Vector origin, double slack)
{
var usable = Usable(inset, origin);
if (slack > 0)
{
usable = Clipper.InflatePaths(usable, slack, JoinType.Miter, EndType.Polygon,
MiterLimit, Precision, ArcTolerance);
}
var outside = Clipper.Difference(region, usable, FillRule.NonZero, Precision);
return outside.Sum(p => System.Math.Abs(Clipper.Area(p))) <= 0;
}
/// <summary>This outline turned a quarter turn about the origin, re-normalized to start
/// at the origin, as <see cref="Plate.Rotate90"/> turns the sheet.</summary>
public SheetOutline Rotated90(RotationType direction)
{
Vector Turn(Vector p) => direction == RotationType.CW ? new Vector(p.Y, -p.X) : new Vector(-p.Y, p.X);
return new SheetOutline(Points.Select(Turn), Obstacles.Select(o => o.Select(Turn)));
}
/// <summary>This outline turned half a turn, re-normalized to start at the origin.</summary>
public SheetOutline Rotated180() =>
new(Points.Select(p => new Vector(-p.X, -p.Y)), Obstacles.Select(o => o.Select(p => new Vector(-p.X, -p.Y))));
/// <summary>
/// An outline from imported geometry: the largest closed contour becomes the outline and
/// every other closed contour an obstacle. Arcs are chorded at <see cref="ChordTolerance"/>,
/// then the outline is eroded and each obstacle inflated by that much, so the stored
/// polygons stay within the true edges. Marks and open contours are ignored.
/// </summary>
/// <exception cref="ArgumentException">No closed contour with a positive area.</exception>
public static SheetOutline FromGeometry(IEnumerable<Entity> entities)
{
ArgumentNullException.ThrowIfNull(entities);
var material = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList();
ShapeProfile profile;
try
{
profile = material.Count == 0 ? null : new ShapeProfile(material);
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
{
throw new ArgumentException("The geometry has no closed outline.", nameof(entities), exception);
}
if (profile?.Perimeter == null)
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
var perimeter = ClipperBridge.ToPath(ClipperBridge.Flatten(profile.Perimeter, ChordTolerance, circumscribe: false), positive: true);
if (perimeter.Count < 3 || !(Clipper.Area(perimeter) > 0))
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
var outer = Clipper.InflatePaths(new PathsD { perimeter },
-ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
var outline = outer.Where(p => p.Count >= 3).OrderByDescending(Clipper.Area).FirstOrDefault()
?? throw new ArgumentException("The outline is too thin to hold material.", nameof(entities));
var obstacles = new List<IEnumerable<Vector>>();
foreach (var cutout in profile.Cutouts)
{
var hole = ClipperBridge.ToPath(ClipperBridge.Flatten(cutout, ChordTolerance, circumscribe: false), positive: true);
if (hole.Count < 3 || !(Clipper.Area(hole) > 0))
continue;
var grown = Clipper.InflatePaths(new PathsD { hole },
ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
foreach (var path in grown.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
obstacles.Add(path.Select(p => new Vector(p.x, p.y)).ToList());
}
return new SheetOutline(outline.Select(p => new Vector(p.x, p.y)), obstacles);
}
public bool Equals(SheetOutline other)
{
if (other is null)
return false;
if (ReferenceEquals(this, other))
return true;
if (Points.Count != other.Points.Count || Obstacles.Count != other.Obstacles.Count)
return false;
for (var i = 0; i < Points.Count; i++)
if (!Same(Points[i], other.Points[i]))
return false;
for (var k = 0; k < Obstacles.Count; k++)
{
if (Obstacles[k].Count != other.Obstacles[k].Count)
return false;
for (var i = 0; i < Obstacles[k].Count; i++)
if (!Same(Obstacles[k][i], other.Obstacles[k][i]))
return false;
}
return true;
}
public override bool Equals(object obj) => Equals(obj as SheetOutline);
public override int GetHashCode()
{
var hash = new System.HashCode();
hash.Add(Points.Count);
hash.Add(Obstacles.Count);
foreach (var p in Points)
{
hash.Add(BitConverter.DoubleToInt64Bits(p.X));
hash.Add(BitConverter.DoubleToInt64Bits(p.Y));
}
return hash.ToHashCode();
}
public override string ToString() =>
$"{Size} outline, {Points.Count} vertices" + (Obstacles.Count == 0 ? "" : $", {Obstacles.Count} obstacle(s)");
/// <summary>Exact bits: two outlines are the same stock only when nothing moved.</summary>
private static bool Same(Vector a, Vector b) =>
BitConverter.DoubleToInt64Bits(a.X) == BitConverter.DoubleToInt64Bits(b.X)
&& BitConverter.DoubleToInt64Bits(a.Y) == BitConverter.DoubleToInt64Bits(b.Y);
private static PathsD Translate(PathsD paths, Vector origin) =>
origin.X == 0 && origin.Y == 0
? new PathsD(paths.Select(p => new PathD(p)))
: Clipper.TranslatePaths(paths, origin.X, origin.Y);
/// <summary>A counterclockwise copy without the closing or repeated vertices, after
/// checking it is one simple polygon of positive area.</summary>
private static PathD SimplePolygon(IEnumerable<Vector> points, string what)
{
ArgumentNullException.ThrowIfNull(points, nameof(points));
var path = new PathD();
foreach (var p in points)
{
if (!double.IsFinite(p.X) || !double.IsFinite(p.Y))
throw new ArgumentException($"The {what} has a nonfinite vertex.", nameof(points));
if (path.Count > 0 && path[^1].x == p.X && path[^1].y == p.Y)
continue;
path.Add(new PointD(p.X, p.Y));
}
if (path.Count > 1 && path[0].x == path[^1].x && path[0].y == path[^1].y)
path.RemoveAt(path.Count - 1);
if (path.Count < 3)
throw new ArgumentException($"The {what} needs at least three distinct vertices.", nameof(points));
var area = Clipper.Area(path);
if (!(System.Math.Abs(area) > 0))
throw new ArgumentException($"The {what} has no area.", nameof(points));
if (area < 0)
path.Reverse();
// Start at the lowest-left vertex so equal polygons list their vertices alike.
var start = 0;
for (var i = 1; i < path.Count; i++)
if (path[i].y < path[start].y || path[i].y == path[start].y && path[i].x < path[start].x)
start = i;
if (start != 0)
path = new PathD(path.Skip(start).Concat(path.Take(start)));
// A simple polygon unions to itself: one path of the same area. A figure eight or a
// bow tie does not, and a sheet edge that crosses itself is not a sheet.
var union = Clipper.Union(new PathsD { path }, new PathsD(), FillRule.NonZero, Precision);
var scale = System.Math.Pow(10, -Precision);
if (union.Count != 1 || System.Math.Abs(Clipper.Area(union[0]) - System.Math.Abs(area)) > scale * (path.Count + 1) * Perimeter(path))
throw new ArgumentException($"The {what} must be a simple polygon that does not cross itself.", nameof(points));
return path;
}
private static double Perimeter(PathD path)
{
var length = 0.0;
for (var i = 0; i < path.Count; i++)
{
var a = path[i];
var b = path[(i + 1) % path.Count];
length += System.Math.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
}
return length;
}
}
}
-8
View File
@@ -79,14 +79,6 @@ public interface INestRepository
Task<int> AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not allocate nest names.");
/// <summary>
/// The ledger item codes on the plates of every open (Draft, Quote or To Be Cut) nest,
/// except <paramref name="excludeNestId"/> when it is not empty.
/// </summary>
Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not list stock item codes.");
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
+6 -15
View File
@@ -44,8 +44,7 @@ public enum LibraryInsertKind
/// Decides how one library drawing revision enters a nest, before anything changes:
/// a new exact copy carrying its library link, more quantity on the copy already there,
/// or a refusal. Material, grade or thickness that differ from the nest still allow the
/// add, but the drawing's cut pins are dropped (D3); a blank material or grade, or a zero
/// thickness, on the record suits any nest and is never a difference. Different units, a different revision
/// add, but the drawing's cut pins are dropped (D3). Different units, a different revision
/// of the same drawing, or another drawing with the same name refuse it.
/// </summary>
public sealed class LibraryDrawingInsert
@@ -76,7 +75,7 @@ public sealed class LibraryDrawingInsert
/// <summary>
/// For a new copy, true when the library drawing's cut pins come with it: the library
/// material, grade and thickness match the nest's or are unset. Otherwise the copy has no pins. An
/// material, grade and thickness match the nest's. Otherwise the copy has no pins. An
/// added quantity leaves the existing copy as it is.
/// </summary>
public bool KeepPins { get; }
@@ -141,12 +140,7 @@ public sealed class LibraryDrawingInsert
var drawing = package.Drawing;
drawing.Name = record.Name;
drawing.Customer = record.Customer;
// A field the record leaves unset means any material, so the copy takes the nest's.
var nestMaterial = nest.Material ?? new Material();
drawing.Material = new Material(
IsSet(record.MaterialName) ? record.MaterialName : nestMaterial.Name,
IsSet(record.MaterialGrade) ? record.MaterialGrade : nestMaterial.Grade,
drawing.Material?.Density ?? 0);
drawing.Material = new Material(record.MaterialName, record.MaterialGrade, drawing.Material?.Density ?? 0);
(record.Settings ?? new DrawingNestingSettings()).ApplyTo(drawing);
drawing.Quantity.Required = quantity;
drawing.Library = link;
@@ -183,24 +177,21 @@ public sealed class LibraryDrawingInsert
private static LibraryDrawingInsert Refuse(Nest nest, string reason) =>
new(nest, LibraryInsertKind.Refused, null, 0, keepPins: false, Array.Empty<string>(), reason);
// A blank material or grade, or a zero thickness, on the record suits any nest.
private static List<string> Differences(Nest nest, DrawingLibraryRecord record)
{
var differences = new List<string>();
var material = nest.Material ?? new Material();
if (IsSet(record.MaterialName) && SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
if (SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
differences.Add($"material {Text(record.MaterialName)} (nest {Text(material.Name)})");
if (IsSet(record.MaterialGrade) && SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
if (SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
differences.Add($"grade {Text(record.MaterialGrade)} (nest {Text(material.Grade)})");
var thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness);
var nestThickness = DrawingLibraryRules.NormalizeThickness(nest.Thickness);
if (thickness != 0 && thickness != nestThickness)
if (thickness != nestThickness)
differences.Add($"thickness {Number(thickness)} (nest {Number(nestThickness)})");
return differences;
}
private static bool IsSet(string? value) => SharedListNames.Key(value).Length > 0;
private static string Describe(DrawingLibraryRecord record) =>
string.IsNullOrWhiteSpace(record.Customer)
? $"library drawing '{record.Name}' revision {record.Revision}"
-19
View File
@@ -73,25 +73,6 @@ public static class MachineCuttingDefaults
return true;
}
/// <summary>
/// Whether <paramref name="parameters"/> are stamped with the machine, material, grade and
/// thickness <see cref="ApplyTo(CuttingParameters, MachineConfig?, Nest)"/> would record for
/// the nest: they started from the machine's settings for it, and any lead edits since are
/// the user's choice for that same job, which the machine's settings must not replace.
/// </summary>
public static bool IsRecordedFor(CuttingParameters parameters, MachineConfig? machine, Nest nest)
{
ArgumentNullException.ThrowIfNull(parameters);
ArgumentNullException.ThrowIfNull(nest);
if (Find(machine, nest) == null)
return false;
var tolerance = ThicknessToleranceInches * Scale(Units.Inches, nest.Units);
return string.Equals(parameters.MachineName, machine!.Name, StringComparison.Ordinal)
&& string.Equals(parameters.MaterialName, nest.Material.Name, StringComparison.Ordinal)
&& string.Equals(parameters.Grade ?? "", nest.Material.Grade ?? "", StringComparison.Ordinal)
&& System.Math.Abs(parameters.Thickness - nest.Thickness) <= tolerance;
}
/// <summary>
/// Replaces the part clearance, overtravel, minimum segment and direction of
/// <paramref name="settings"/> with the machine's for the nest; the head-lock choice is kept.
@@ -1,95 +0,0 @@
namespace OpenNest.Data.MaterialLedger;
/// <summary>A plate's item code that is no longer the nest's to use, and why.</summary>
/// <param name="PlateNumber">The plate's 1-based position in the nest.</param>
public sealed record LedgerCodeConflict(int PlateNumber, string ItemCode, string Reason);
/// <summary>
/// The save and post re-check of a nest's <see cref="Plate.StockItemCodes"/>: finds codes the
/// ledger no longer has free for this nest or that other open nests are coded with, and takes
/// them off the plates when the programmer chooses to. Pure; the caller fetches the inputs.
/// </summary>
public static class LedgerCodeCheck
{
/// <summary>True when any plate has a coded sheet.</summary>
public static bool HasCodes(IEnumerable<Plate> plates) =>
(plates ?? throw new ArgumentNullException(nameof(plates))).Any(plate => plate.StockItemCodes.Count > 0);
/// <param name="plates">The nest's plates in order.</param>
/// <param name="freeItems">The ledger's free stock for the nest's material, after every hold.</param>
/// <param name="ownHolds">
/// The ledger's holds for this nest: an open one keeps its sheet for this nest, so the
/// sheet counts as available here although the ledger no longer lists it as free.
/// </param>
/// <param name="inUse">Other open nests' codes; an unknown answer checks the ledger alone.</param>
/// <returns>One conflict per plate and code, in plate order then the plate's code order.</returns>
public static IReadOnlyList<LedgerCodeConflict> Find(IList<Plate> plates, IEnumerable<LedgerStockItem> freeItems,
IEnumerable<LedgerHold>? ownHolds, LedgerCodesInUse inUse)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(freeItems);
ArgumentNullException.ThrowIfNull(inUse);
var available = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var item in freeItems.Where(item => item != null && item.Quantity > 0))
available[item.ItemCode] = available.GetValueOrDefault(item.ItemCode) + item.Quantity;
foreach (var hold in (ownHolds ?? Array.Empty<LedgerHold>()).Where(hold => hold != null && LedgerHoldStatuses.IsOpen(hold.Status)))
available[hold.ItemCode] = available.GetValueOrDefault(hold.ItemCode) + 1;
// A bundle's code repeats once per sheet across every plate it is used on.
var needed = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var code in plates.SelectMany(plate => plate.StockItemCodes))
needed[code] = needed.GetValueOrDefault(code) + 1;
var reasons = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var (code, count) in needed)
{
var parts = new List<string>();
var free = available.GetValueOrDefault(code);
if (free == 0)
parts.Add("not free in MaterialLedger");
else if (free < count)
parts.Add($"only {free} of the {count} sheets needed free in MaterialLedger");
var nests = inUse.NestsUsing(code);
if (nests.Count > 0)
parts.Add((nests.Count == 1 ? "on nest " : "on nests ") + string.Join(", ", nests));
if (parts.Count > 0)
reasons[code] = Capitalize(string.Join("; ", parts));
}
var conflicts = new List<LedgerCodeConflict>();
for (var i = 0; i < plates.Count; i++)
{
foreach (var code in plates[i].StockItemCodes.Distinct(StringComparer.Ordinal))
{
if (reasons.TryGetValue(code, out var reason))
conflicts.Add(new LedgerCodeConflict(i + 1, code, reason));
}
}
return conflicts;
}
/// <summary>
/// Takes every copy of <paramref name="codes"/> off the plates, keeping the layout and the
/// other codes in order; the sheets left uncoded are matched by size in ML. Returns how
/// many sheets lost their code.
/// </summary>
public static int Remove(IEnumerable<Plate> plates, IEnumerable<string> codes)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(codes);
var remove = codes.ToHashSet(StringComparer.Ordinal);
var removed = 0;
foreach (var plate in plates)
{
var kept = plate.StockItemCodes.Where(code => !remove.Contains(code)).ToList();
if (kept.Count == plate.StockItemCodes.Count)
continue;
removed += plate.StockItemCodes.Count - kept.Count;
plate.StockItemCodes = kept;
}
return removed;
}
private static string Capitalize(string text) => text.Length == 0 ? text : char.ToUpperInvariant(text[0]) + text[1..];
}
@@ -1,70 +0,0 @@
namespace OpenNest.Data.MaterialLedger;
/// <summary>
/// Takes item codes already on other open nests out of ledger stock rows, so two nests are
/// not planned on the same sheet before ML holds exist for either. Pure: rows are rebuilt,
/// never changed.
/// </summary>
public static class LedgerCodeFilter
{
/// <summary>
/// <paramref name="rows"/> without <paramref name="codesInUse"/>: every copy of a code
/// leaves the row's <see cref="LedgerStockRow.ItemCodes"/> and
/// <see cref="LedgerStockRow.ItemLocations"/>, its quantity drops by the copies removed, and
/// a row left with none is dropped. Row ids and order are kept. <paramref name="removed"/>
/// counts the sheets and drops taken out.
/// </summary>
public static IReadOnlyList<LedgerStockRow> Exclude(IEnumerable<LedgerStockRow> rows,
IEnumerable<string> codesInUse, out int removed)
{
ArgumentNullException.ThrowIfNull(rows);
ArgumentNullException.ThrowIfNull(codesInUse);
var exclude = codesInUse as IReadOnlySet<string> ?? codesInUse.ToHashSet(StringComparer.Ordinal);
removed = 0;
var kept = new List<LedgerStockRow>();
foreach (var row in rows)
{
if (row == null)
continue;
var codes = row.ItemCodes.Where(code => !exclude.Contains(code)).ToList();
var dropped = row.ItemCodes.Count - codes.Count;
if (dropped == 0)
{
kept.Add(row);
continue;
}
removed += dropped;
if (codes.Count == 0)
continue;
var itemLocations = row.ItemLocations
.Where(pair => !exclude.Contains(pair.Key))
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
// A location stays listed while an item left in the row is there, or when the row
// never said which item it held.
var locations = row.Locations
.Where(location => itemLocations.ContainsValue(location)
|| !row.ItemLocations.Values.Contains(location, StringComparer.Ordinal))
.ToList();
kept.Add(row with
{
Quantity = codes.Count,
ItemCodes = codes,
Locations = locations,
ItemLocations = itemLocations,
});
}
return kept;
}
/// <summary>
/// <paramref name="mapping"/> with <paramref name="codesInUse"/> taken out of its rows and
/// the sheets removed counted in <see cref="LedgerStockMapping.OnOtherNests"/>.
/// </summary>
public static LedgerStockMapping Exclude(LedgerStockMapping mapping, IEnumerable<string> codesInUse)
{
ArgumentNullException.ThrowIfNull(mapping);
var rows = Exclude(mapping.Rows, codesInUse, out var removed);
return mapping with { Rows = rows, OnOtherNests = mapping.OnOtherNests + removed };
}
}
@@ -1,78 +0,0 @@
using System.Net.Http;
namespace OpenNest.Data.MaterialLedger;
/// <summary>
/// The ledger item codes other open server nests are coded with, or why they could not be
/// read. ML's free counts drop only once a hold exists, so these codes are what another
/// Auto Nest run must leave out until then. Reading them never blocks: a failure is a result.
/// </summary>
public sealed record LedgerCodesInUse
{
/// <summary>The nest server gets this long to answer, so a stock load or save never waits on it.</summary>
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
private LedgerCodesInUse(IReadOnlyList<NestStockCodeUse> uses, string? unavailable)
{
Uses = uses;
Unavailable = unavailable;
Codes = uses.Select(use => use.ItemCode).ToHashSet(StringComparer.Ordinal);
}
/// <summary>One entry per code and nest; empty when <see cref="Unavailable"/>.</summary>
public IReadOnlyList<NestStockCodeUse> Uses { get; }
/// <summary>The distinct codes of <see cref="Uses"/>.</summary>
public IReadOnlySet<string> Codes { get; }
/// <summary>Why the codes could not be read; null when they were.</summary>
public string? Unavailable { get; }
public bool IsKnown => Unavailable == null;
public static LedgerCodesInUse Known(IEnumerable<NestStockCodeUse> uses) =>
new((uses ?? throw new ArgumentNullException(nameof(uses))).Where(use => use != null).ToList(), null);
public static LedgerCodesInUse Unknown(string reason) =>
new(Array.Empty<NestStockCodeUse>(), string.IsNullOrWhiteSpace(reason) ? "not available" : reason);
/// <summary>The names of the other nests using <paramref name="code"/>, in name order.</summary>
public IReadOnlyList<string> NestsUsing(string code) =>
Uses.Where(use => string.Equals(use.ItemCode, code, StringComparison.Ordinal))
.Select(use => use.NestName)
.Distinct(StringComparer.Ordinal)
.OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
/// <summary>
/// Reads the codes from <paramref name="repository"/>, leaving out the nest
/// <paramref name="excludeNestId"/> (the one being edited). A null repository is File
/// mode. Only the caller's own cancellation throws.
/// </summary>
public static async Task<LedgerCodesInUse> ReadAsync(INestRepository? repository, Guid excludeNestId,
CancellationToken cancellationToken, TimeSpan? timeout = null)
{
if (repository == null)
return Unknown("nests are saved as files");
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout ?? RequestTimeout);
try
{
return Known(await repository.GetStockCodesInUseAsync(excludeNestId, timeoutSource.Token));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Unknown("the nest server did not answer");
}
catch (NotSupportedException)
{
return Unknown("the nest server needs an update");
}
catch (Exception ex) when (ex is HttpRequestException or IOException or System.Text.Json.JsonException
or ArgumentException or InvalidOperationException)
{
return Unknown("the nest server could not be reached");
}
}
}
@@ -51,12 +51,7 @@ public sealed record LedgerStockMapping(
public int Drops => Rows.Where(r => r.IsDrop).Sum(r => r.Quantity);
public int SubstituteRows => Rows.Count(r => r.IsSubstitute);
/// <summary>Sheets and drops left out because other open nests are coded with them (<see cref="LedgerCodeFilter"/>).</summary>
public int OnOtherNests { get; init; }
/// <summary>
/// E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (3 on other nests) (2 skipped: 2 unknown shape)".
/// </summary>
/// <summary>E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (2 skipped: 2 unknown shape)".</summary>
public string Summary
{
get
@@ -69,8 +64,6 @@ public sealed record LedgerStockMapping(
var text = $"Ledger: {Plural(SheetRows, "sheet size")}, {Plural(Drops, "drop")}";
if (SubstituteRows > 0)
text += $", {Plural(SubstituteRows, "substitute row")}";
if (OnOtherNests > 0)
text += $" ({OnOtherNests} on other nests)";
return skipped.Count == 0
? text
: $"{text} ({SkippedUnknownShape + SkippedTooSmall} skipped: {string.Join(", ", skipped)})";
-18
View File
@@ -1,18 +0,0 @@
namespace OpenNest.Data;
/// <summary>
/// A ledger item code an open server nest's plates are coded with, returned by
/// <c>GET /api/nests/stock-item-codes</c>. One entry per code and nest; a bundle code used
/// for several sheets appears once.
/// </summary>
public sealed class NestStockCodeUse
{
public string ItemCode { get; init; } = "";
public Guid NestId { get; init; }
public string NestName { get; init; } = "";
/// <summary>Draft, Quote or To Be Cut: the statuses whose sheets are still to be cut.</summary>
public NestStatus Status { get; init; }
}
-13
View File
@@ -151,19 +151,6 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
return allocation.Number;
}
public async Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId,
CancellationToken cancellationToken = default)
{
var query = "api/nests/stock-item-codes" + (excludeNestId == Guid.Empty ? "" : "?exclude=" + excludeNestId.ToString("D"));
using var response = await _httpClient.GetAsync(Url(query), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new NotSupportedException("This nest server does not list stock item codes (HTTP 404). Update the nest server.");
await NestServerHttp.EnsureSuccessAsync(response, "list stock item codes", cancellationToken);
var uses = await response.Content.ReadFromJsonAsync<List<NestStockCodeUse>>(NestServerHttp.JsonOptions, cancellationToken);
return uses?.Where(use => use != null && !string.IsNullOrWhiteSpace(use.ItemCode)).ToList()
?? new List<NestStockCodeUse>();
}
public async Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
{
@@ -1,81 +0,0 @@
using System.Reflection;
using System.Text.Json;
using System.Xml.Linq;
namespace OpenNest.Desktop.Tests;
/// <summary>
/// OpenNest.Desktop is shared by the WinForms and Avalonia front ends, so it must stay free of
/// any UI toolkit: no WinForms/WindowsDesktop framework, System.Drawing.Common or Avalonia.
/// </summary>
public class DesktopDependencyTests
{
private static readonly string[] ForbiddenPrefixes =
[
"System.Windows.Forms",
"Microsoft.WindowsDesktop",
"WindowsBase",
"PresentationCore",
"PresentationFramework",
"System.Drawing.Common",
"Avalonia",
];
[Fact]
public void DesktopAssembly_ReferencesNoUiToolkit()
{
var assembly = Assembly.Load(new AssemblyName("OpenNest.Desktop"));
var references = assembly.GetReferencedAssemblies().Select(a => a.Name!);
Assert.Empty(references.Where(IsForbidden));
}
[Fact]
public void DesktopProject_TargetsPlainNet8WithoutUiFrameworks()
{
var project = XDocument.Load(Path.Combine(DesktopProjectDirectory(), "OpenNest.Desktop.csproj"));
var elements = project.Descendants().ToList();
var targets = elements
.Where(e => e.Name.LocalName is "TargetFramework" or "TargetFrameworks")
.SelectMany(e => e.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToList();
Assert.Equal(["net8.0"], targets);
Assert.DoesNotContain(elements, e =>
e.Name.LocalName is "UseWindowsForms" or "UseWPF" or "UseWindowsDesktop"
&& e.Value.Trim().Equals("true", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(elements, e => e.Name.LocalName == "FrameworkReference");
var packages = elements
.Where(e => e.Name.LocalName == "PackageReference")
.Select(e => (string?)e.Attribute("Include") ?? string.Empty);
Assert.Empty(packages.Where(IsForbidden));
}
[Fact]
public void DesktopProject_RestoresNoUiToolkitPackagesTransitively()
{
var assetsPath = Path.Combine(DesktopProjectDirectory(), "obj", "project.assets.json");
Assert.True(File.Exists(assetsPath), $"Restore output not found: {assetsPath}");
using var assets = JsonDocument.Parse(File.ReadAllText(assetsPath));
var libraries = assets.RootElement.GetProperty("libraries").EnumerateObject()
.Select(library => library.Name.Split('/')[0]);
Assert.Empty(libraries.Where(IsForbidden));
}
private static bool IsForbidden(string name) =>
ForbiddenPrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
private static string DesktopProjectDirectory()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "OpenNest.sln")))
directory = directory.Parent;
Assert.NotNull(directory);
return Path.Combine(directory.FullName, "OpenNest.Desktop");
}
}
@@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.Desktop/OpenNest.Desktop.csproj" />
</ItemGroup>
</Project>
-18
View File
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Toolkit-neutral desktop logic shared by the WinForms and Avalonia front ends.
No UI toolkit: OpenNest.Desktop.Tests guards against WinForms, WindowsDesktop,
System.Drawing.Common and Avalonia references. -->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Desktop</RootNamespace>
<AssemblyName>OpenNest.Desktop</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
<ProjectReference Include="..\OpenNest.Reporting\OpenNest.Reporting.csproj" />
</ItemGroup>
</Project>
@@ -1,334 +0,0 @@
using System.Globalization;
using System.Text.RegularExpressions;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
/// <summary>The frontier packer as a cutout proposal: inserts settle against the cutout wall
/// and each other instead of landing at the first sampled point that clears.</summary>
[Collection("Cutout pipeline registry")]
public class CutoutPackerProposalTests
{
private const double Spacing = 0.3;
/// <summary>A 13.148 x 1.25 bar with a shallow notch in one long edge and eight bolt holes.</summary>
private const string BarCode = """
G91
G00X0Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X1.875Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X4.625Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X1.875Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X-11.8865Y-1.0625
G01X0Y0.75
G02X0.5Y0.5I0.5J0
G01X3.824Y0
G02X0.25Y-0.25I0J-0.25
G01X0Y-0.125
G03X0.25Y-0.25I0.25J-0
G01X3.5Y0
G03X0.25Y0.25I0J0.25
G01X0Y0.125
G02X0.25Y0.25I0.25J0
G01X3.824Y0
G02X0.5Y-0.5I0J-0.5
G01X0Y-0.75
G01X-13.148Y0
""";
/// <summary>One 12.25-wide window whose near end carries a notched tab, open to the given
/// wall height above the tab; the frame below stacks four such windows.</summary>
private static string Window(double wallHeight) => $"""
G01X0.125Y0
G01X0Y0.841458
G02X0.5Y0.5I0.5J0
G01X0Y-0.3125
G01X1.125Y0
G01X0Y0.3125
G01X1.25Y0
G01X0Y-0.3125
G01X1.125Y0
G01X0Y0.3125
G01X4Y0
G01X0Y-0.3125
G01X1.125Y0
G01X0Y0.3125
G01X1.25Y0
G01X0Y-0.3125
G01X1.125Y0
G01X0Y0.3125
G02X0.5Y-0.5I0J-0.5
G01X0Y-0.841458
G01X0.125Y0
G01X0Y{wallHeight.ToString(CultureInfo.InvariantCulture)}
G03X-0.125Y0.125I-0.125J0
G01X-12Y0
G03X-0.125Y-0.125I0J-0.125
G01X0Y-{wallHeight.ToString(CultureInfo.InvariantCulture)}
""";
/// <summary>A 14.82 x 69.72 frame with a plain 12.25 x 13.4 window and three tabbed windows
/// of rising height; only the last is tall enough to stand the bar in.</summary>
private static readonly string FrameCode = $"""
G91
G00X0Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X1.875Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X-3.9375Y2.033896
G01X12.25Y0
G01X0Y13.297
G03X-0.125Y0.125I-0.125J0
G01X-12Y0
G03X-0.125Y-0.125I0J-0.125
G01X0Y-13.297
G00X8.5625Y-2.033896
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X1.875Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X-11.4375Y16.955896
{Window(14.375)}
G00X0Y16
{Window(14.375)}
G00X0Y16
{Window(19.453)}
G00X-1.283896Y-47.797
G01X0Y68.25
G02X0.125Y0.125I0.125J0
G01X14.567791Y0
G02X0.125Y-0.125I0J-0.125
G01X0Y-68.25
G02X-0.125Y-0.125I-0.125J0
G01X-0.2615Y0
G02X-0.125Y0.125I0J0.125
G01X0Y0.051
G01X-0.323396Y0
G01X0Y-0.897396
G02X-0.5Y-0.5I-0.5J-0
G01X-12.148Y0
G02X-0.5Y0.5I0J0.5
G01X0Y0.897396
G01X-0.323396Y0
G01X0Y-0.051
G02X-0.125Y-0.125I-0.125J0
G01X-0.2615Y0
G02X-0.125Y0.125I0J0.125
""";
/// <summary>Height of the tab on a tabbed window's near end.</summary>
private const double TabHeight = 0.841458 + 0.5;
private static Program Bar() => Incremental(BarCode);
private static Program Frame() => Incremental(FrameCode);
[Fact]
public void FrameHasFourWindowsAndTheBarIsSolid()
{
var frame = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Frame()));
var bar = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Bar()));
Assert.Equal(4, frame.Cutouts.Count(c => System.Math.Abs(c.Area()) > 100));
Assert.Equal(14.818, frame.Bounds.Length, 3);
Assert.Equal(69.721, frame.Bounds.Width, 3);
Assert.Equal(13.148, bar.Bounds.Length, 3);
Assert.Equal(1.25, bar.Bounds.Width, 3);
}
[Fact]
public void TwoBarsStackAgainstAWindowWallInsteadOfScatteringAcrossIt()
{
var frame = JobBuilder.Part("PT10", Frame(), 1);
var bar = JobBuilder.Part("PT11", Bar(), 2);
var geometry = JobPartGeometry.Read(frame.Geometry);
var seated = new List<(int Hole, IReadOnlyList<NestJobPlacement> Poses)>();
for (var hole = 0; hole < geometry.Cutouts.Count; hole++)
{
if (System.Math.Abs(geometry.Cutouts[hole].Area()) < 100)
continue;
var poses = CutoutRouter.Fill(frame, hole, new[] { bar }, Spacing);
if (poses.Count == 2)
seated.Add((hole, poses));
}
var (window, stacked) = Assert.Single(seated);
AssertClear(frame, bar, stacked, Spacing);
AssertStacked(geometry.Cutouts[window], JobPartGeometry.Read(bar.Geometry), stacked);
}
[Fact]
public void PackerAloneSeatsBothBarsAgainstTheWall()
{
var frame = JobBuilder.Part("PT10", Frame(), 1);
var bar = JobBuilder.Part("PT11", Bar(), 2);
var geometry = JobPartGeometry.Read(frame.Geometry);
var tallest = Enumerable.Range(0, geometry.Cutouts.Count)
.OrderByDescending(i => System.Math.Abs(geometry.Cutouts[i].Area())).First();
var poses = CutoutPackerProposal.Fill(frame, tallest, new[] { bar }, Spacing);
Assert.Equal(2, poses.Count);
Assert.Equal(new[] { 0, 1 }, poses.Select(p => p.InstanceIndex));
AssertClear(frame, bar, poses, Spacing);
AssertStacked(geometry.Cutouts[tallest], JobPartGeometry.Read(bar.Geometry), poses);
}
[Fact]
public void PackerKeepsClearOfInsertsAlreadySeated()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 3, 2, 6);
var geometry = JobPartGeometry.Read(insert.Geometry);
var occupant = new NestJobPlacement("occupant", 0, -1.5, -1, 0);
var poses = CutoutPackerProposal.Fill(frame, 0, new[] { insert }, 0.25,
new[] { (geometry, occupant) }, null, default);
Assert.NotEmpty(poses);
foreach (var pose in poses)
Assert.True(NestLayoutCheck.Clears(geometry, occupant, geometry, pose, 0.25), $"{pose} meets the occupant");
AssertClear(frame, insert, poses, 0.25);
}
[Theory]
[InlineData("Irregular")]
[InlineData("Default")]
public void AutoNestSeatsBothBarsStackedInOneWindow(string engine)
{
var frameDrawing = new Drawing("PT10", Frame());
var barDrawing = new Drawing("PT11", Bar());
var request = new NestPipelineRequest(engine,
new[] { new NestItem { Drawing = barDrawing, Quantity = 2 }, new NestItem { Drawing = frameDrawing, Quantity = 2 } },
new[] { new NestPlateStock("sheet", new Size(60, 120), 1, Spacing) },
new NestJobOptions(maxPlates: 1));
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(NestJobStatus.Complete, result.Status);
var plate = Assert.Single(result.Plates);
var bars = plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, barDrawing)).ToArray();
var frames = plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, frameDrawing)).ToArray();
Assert.Equal(2, bars.Length);
Assert.Equal(2, frames.Length);
var frameGeometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Frame()));
var barGeometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Bar()));
// Both bars lie in one window of one frame; express them in that frame's coordinates.
var seated = new List<(Part Frame, Shape Window, IReadOnlyList<NestJobPlacement> Poses)>();
foreach (var host in frames)
{
var local = bars.Select(bar => Local(host, bar)).ToArray();
foreach (var window in frameGeometry.Cutouts.Where(c => System.Math.Abs(c.Area()) > 100))
if (local.All(pose => Inside(window, Extent(barGeometry, pose))))
seated.Add((host, window, local));
}
var (_, hole, poses) = Assert.Single(seated);
AssertStacked(hole, barGeometry, poses);
}
private static NestJobPlacement Local(Part frame, Part part)
{
var dx = part.Location.X - frame.Location.X;
var dy = part.Location.Y - frame.Location.Y;
var cosine = System.Math.Cos(-frame.Rotation);
var sine = System.Math.Sin(-frame.Rotation);
return new NestJobPlacement("PT11", 0, dx * cosine - dy * sine, dx * sine + dy * cosine,
part.Rotation - frame.Rotation);
}
private static bool Inside(Shape window, Box box)
{
var bounds = window.BoundingBox;
return box.Left > bounds.Left && box.Right < bounds.Right && box.Bottom > bounds.Bottom && box.Top < bounds.Top;
}
/// <summary>Both bars stand in the window against one wall and against each other, not in
/// the middle: each touches the wall or the other bar across the window, and the near tab
/// or the far wall along it, within the spacing plus a small tolerance.</summary>
private static void AssertStacked(Shape window, JobPartGeometry bar, IReadOnlyList<NestJobPlacement> poses)
{
const double slack = 0.05;
var hole = window.BoundingBox;
var boxes = poses.Select(p => Extent(bar, p)).ToArray();
Assert.Equal(2, boxes.Length);
foreach (var box in boxes)
{
Assert.True(Inside(window, box), $"{box} is not inside the window {hole}");
var other = boxes.Single(b => !ReferenceEquals(b, box));
var toWall = System.Math.Min(box.Left - hole.Left, hole.Right - box.Right);
var toOther = System.Math.Max(other.Left - box.Right, box.Left - other.Right);
Assert.True(toWall <= Spacing + slack || toOther <= Spacing + slack,
$"{box} floats {toWall:F3} from the walls and {toOther:F3} from the other bar");
var toEnd = System.Math.Min(box.Bottom - hole.Bottom - TabHeight, hole.Top - box.Top);
Assert.True(toEnd <= Spacing + slack, $"{box} floats {toEnd:F3} from the window ends");
}
var gap = System.Math.Max(boxes[1].Left - boxes[0].Right, boxes[0].Left - boxes[1].Right);
Assert.InRange(gap, Spacing - 1e-6, Spacing + slack);
var center = (boxes.Min(b => b.Left) + boxes.Max(b => b.Right)) / 2;
Assert.True(System.Math.Abs(center - (hole.Left + hole.Right) / 2) > 2,
"the pair sits in the middle of the window");
}
private static Box Extent(JobPartGeometry geometry, NestJobPlacement pose)
{
var shape = (Shape)geometry.Perimeter.Clone();
shape.Rotate(pose.Rotation);
var box = shape.BoundingBox;
return new Box(box.Left + pose.X, box.Bottom + pose.Y, box.Length, box.Width);
}
private static void AssertClear(NestJobPart frame, NestJobPart insert, IReadOnlyList<NestJobPlacement> poses,
double spacing)
{
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
var geometry = JobPartGeometry.Read(insert.Geometry);
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
foreach (var pose in poses)
Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, spacing), $"{pose} meets the frame");
for (var i = 0; i < poses.Count; i++)
for (var j = i + 1; j < poses.Count; j++)
Assert.True(NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing),
$"{poses[i]} meets {poses[j]}");
}
/// <summary>Incremental (G91) G-code: rapids, lines and arcs with centers relative to the start.</summary>
private static Program Incremental(string code)
{
var program = new Program();
double x = 0, y = 0;
foreach (var line in code.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var words = Regex.Matches(line, @"([A-Z])(-?\d*\.?\d+)")
.ToDictionary(m => m.Groups[1].Value[0], m => double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture));
var g = (int)words['G'];
if (g == 91)
continue;
var end = new Vector(x + words.GetValueOrDefault('X'), y + words.GetValueOrDefault('Y'));
var center = new Vector(x + words.GetValueOrDefault('I'), y + words.GetValueOrDefault('J'));
program.Codes.Add(g switch
{
0 => new RapidMove(end.X, end.Y),
1 => new LinearMove(end.X, end.Y),
2 => new ArcMove(end, center, RotationType.CW),
_ => new ArcMove(end, center, RotationType.CCW),
});
(x, y) = (end.X, end.Y);
}
return program;
}
}
@@ -1,89 +0,0 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
/// <summary>An insert with holes of its own goes into a frame's cutout when none of its holes
/// fits any part of the job (bolt holes in a strap); otherwise it stays independent, since a
/// later insert could land in its hole.</summary>
[Collection("Cutout pipeline registry")]
public class CutoutPipelineHoledInsertTests
{
private sealed class Stub(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public List<NestJob> Jobs { get; } = new();
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
Jobs.Add(job);
return solve(job);
}
}
// A 20 x 20 frame with a 12 x 14 window.
private static Program Frame() => WithSquareHoles(TestShapes.Rectangle(20, 20), (4, 3, 12, 14));
// A 13 x 1.25 strap with two 0.25 bolt holes: it fits the window only upright.
private static Program Strap()
{
var program = TestShapes.Rectangle(13, 1.25);
foreach (var x in new[] { 1.0, 12.0 })
{
program.Codes.Add(new RapidMove(x + 0.125, 0.625));
program.Codes.Add(new ArcMove(x - 0.125, 0.625, x, 0.625, RotationType.CCW));
program.Codes.Add(new ArcMove(x + 0.125, 0.625, x, 0.625, RotationType.CCW));
}
return program;
}
private static Program WithSquareHoles(Program program, params (double X, double Y, double W, double H)[] holes)
{
foreach (var (x, y, w, h) in holes)
{
program.Codes.Add(new RapidMove(x, y));
program.Codes.Add(new LinearMove(x + w, y));
program.Codes.Add(new LinearMove(x + w, y + h));
program.Codes.Add(new LinearMove(x, y + h));
program.Codes.Add(new LinearMove(x, y));
}
return program;
}
private static NestPipelineRequest Request(string engine, params Program[] programs) =>
new(engine,
programs.Select((p, i) => new NestItem { Drawing = new Drawing($"d{i}", p), Quantity = 1 }).ToArray(),
// Only the frame fits beside nothing else: the strap must go in its window.
new[] { new NestPlateStock("sheet", new Size(20.5, 20.5), 1, 0.25) });
[Theory]
[InlineData("Irregular")]
[InlineData("Default")]
public void StrapWithBoltHolesGoesInTheFrameWindow(string engine)
{
var request = Request(engine, Frame(), Strap());
Assert.Equal(NestJobStatus.Incomplete, NestPipeline.RunWithoutCutouts(request).Status);
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(new[] { request.Items[1].Drawing, request.Items[0].Drawing },
Assert.Single(result.Plates).Parts.Select(p => p.BaseDrawing));
}
[Fact]
public void InsertWhoseHoleFitsAnotherPartStaysIndependent()
{
// The 10 x 10 part's 4 x 4 hole fits the 2 x 2 part, so it is never reserved in the
// frame's window; the 2 x 2 part goes into that hole, the smaller frame, instead.
var holed = WithSquareHoles(TestShapes.Rectangle(10, 10), (3, 3, 4, 4));
var engine = new Stub(job => new NestJobResultBuilder(job).Build(NestJobStopReason.NoPlacementFound));
NestPipeline.RunCutoutTrial(engine, "stub", Request("stub", Frame(), holed, TestShapes.Rectangle(2, 2)));
Assert.Equal(new[] { "part-0", "__cutout-proxy-part-1-0" },
Assert.Single(engine.Jobs).Parts.Select(p => p.Id));
}
}
@@ -12,10 +12,9 @@ public sealed class CutoutPipelineRegistryCollection { }
[Collection("Cutout pipeline registry")]
public class CutoutPipelinePreviewTests
{
/// <param name="hole">Hole diameter: ten holds two 3 x 3 inserts, six holds exactly one.</param>
private static NestPipelineRequest Request(string engine = "Irregular", double hole = 10)
private static NestPipelineRequest Request(string engine = "Irregular")
{
var frame = new Drawing("frame", TestShapes.Ring(20, hole));
var frame = new Drawing("frame", TestShapes.Ring(20, 10));
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
return new NestPipelineRequest(engine,
new[] { new NestItem { Drawing = frame, Quantity = 1 },
@@ -113,8 +112,7 @@ public class CutoutPipelinePreviewTests
[Fact]
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
{
// One insert is reserved in the hole; the other must stay an ordinary requirement.
var request = Request("stub", hole: 6);
var request = Request("stub");
request.Items[1].Quantity = 2;
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } };
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
@@ -173,8 +171,7 @@ public class CutoutPipelinePreviewTests
[Fact]
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
{
// One insert is reserved in the hole; the other must stay an ordinary requirement.
var request = Request("stub", hole: 6);
var request = Request("stub");
request.Items[1].Quantity = 2;
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
{
@@ -276,8 +273,7 @@ public class CutoutPipelinePreviewTests
[Fact]
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
{
// One hole seat: the first requirement row fills it, the second row stays separate.
var initial = Request("stub", hole: 6);
var initial = Request("stub");
var request = initial with
{
Items = new[] { initial.Items[0], initial.Items[1],
@@ -1,247 +0,0 @@
using Clipper2Lib;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.NestingEngines.Default;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Irregular stock: a <see cref="SheetOutline"/> on the stock reaches the Irregular packer as its
/// boundary, every placement stays inside the outline and off its obstacles, the layout check and
/// the runner's validation reject one that does not, and engines that only fill rectangles place
/// nothing on it.
/// </summary>
public class IrregularStockTests
{
private const double Spacing = 0.5;
/// <summary>A 96 x 48 sheet (X by Y) missing its upper-right 48 x 24 corner.</summary>
private static SheetOutline Ell() => new(new[]
{
new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(48, 24), new Vector(48, 48), new Vector(0, 48),
});
/// <summary>A 96 x 48 sheet whose upper-right corner is cut off by a diagonal from (72, 48) to (96, 24).</summary>
private static SheetOutline Chamfered() => new(new[]
{
new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(72, 48), new Vector(0, 48),
});
/// <summary>A whole 96 x 48 sheet with a 20 x 20 burned patch in the middle.</summary>
private static SheetOutline Burned() => new(
new[] { new Vector(0, 0), new Vector(96, 0), new Vector(96, 48), new Vector(0, 48) },
new[] { new[] { new Vector(38, 14), new Vector(58, 14), new Vector(58, 34), new Vector(38, 34) } });
private static NestPlateStock Stock(SheetOutline outline, int quadrant = 1, Spacing edge = default, int? quantity = 1) =>
new("drop", outline.Size, quantity, Spacing, edge, quadrant, null, null, outline);
private static NestJobPart Square(string id, double side, int quantity) =>
new(id, PartGeometrySnapshot.FromProgram(TestShapes.Rectangle(side, side)), quantity);
private static void AssertInsideOutline(NestJob job, NestJobResult result)
{
Assert.Empty(NestLayoutCheck.Violations(job, result));
foreach (var sheet in result.Plates)
{
var usable = sheet.Stock.UsableOutline();
foreach (var pose in sheet.Placements)
{
var geometry = JobPartGeometry.Read(job.Parts.Single(p => p.Id == pose.PartId).Geometry);
var perimeter = (Shape)geometry.Perimeter.Clone();
perimeter.Rotate(pose.Rotation);
perimeter.Offset(pose.X, pose.Y);
var path = ClipperBridge.ToPath(ClipperBridge.Flatten(perimeter, 0.001, circumscribe: true), positive: true);
var outside = Clipper.Difference(new PathsD { path }, usable, FillRule.NonZero, 4);
Assert.True(outside.Sum(p => System.Math.Abs(Clipper.Area(p))) <= 0,
$"{pose.PartId} at ({pose.X:F2},{pose.Y:F2}) leaves the usable outline");
}
}
}
[Fact]
public void Stock_CarriesTheOutlineAndItsArea()
{
var stock = Stock(Ell(), quadrant: 3, edge: new Spacing(1, 0.5, 0.25, 0.75));
Assert.Equal(96 * 48 - 48 * 24, stock.Area, 6);
Assert.Equal(1, stock.OutlineInset);
Assert.Equal(new Vector(-96, -48), stock.PlacementOrigin);
var usable = stock.UsableOutline();
var bounds = Clipper.GetBounds(usable);
Assert.Equal(-95, bounds.left, 6);
Assert.Equal(-47, bounds.top, 6);
Assert.Equal(-1, bounds.right, 6);
Assert.Equal(-1, bounds.bottom, 6);
Assert.Throws<ArgumentException>(() =>
new NestPlateStock("bad", new Size(48, 100), 1, Spacing, default, 1, null, null, Ell()));
}
[Fact]
public void Mapper_CarriesTheOutlineBothWays()
{
var plate = new Plate { Outline = Ell(), Quadrant = 2 };
var stock = DrawingJobMapper.FromPlate("s", plate, null);
Assert.Equal(Ell(), stock.Outline);
var rebuilt = DrawingJobMapper.CreatePlate(stock);
Assert.Equal(Ell(), rebuilt.Outline);
Assert.Equal(plate.Size, rebuilt.Size);
Assert.Equal(2, rebuilt.Quadrant);
}
[Theory]
[InlineData(1)]
[InlineData(3)]
public void Irregular_FillsAnEllSheetWithoutEnteringTheMissingCorner(int quadrant)
{
// Six 20 x 20 squares fit: four along the 96 x 24 arm and two more on the 48 x 48 column.
var job = new NestJob([Square("sq", 20, 6)], [Stock(Ell(), quadrant)]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(6, result.Plates.Sum(p => p.Placements.Count));
AssertInsideOutline(job, result);
}
[Fact]
public void Irregular_KeepsTheLargestEdgeSpacingFromADiagonalEdge()
{
var job = new NestJob([Square("sq", 10, 40)], [Stock(Chamfered(), edge: new Spacing(0.25, 1.5, 0.25, 0.25))]);
var result = new IrregularNestingEngine().Solve(job);
// The chamfer removes a 24 x 24 triangle (288) from 4608; at 1.5 inset about 36 squares fit.
Assert.InRange(result.Plates.Sum(p => p.Placements.Count), 30, 40);
AssertInsideOutline(job, result);
}
[Fact]
public void Irregular_NestsAroundAnObstacle()
{
var job = new NestJob([Square("sq", 15, 12)], [Stock(Burned())]);
var result = new IrregularNestingEngine().Solve(job);
Assert.True(result.Plates.Sum(p => p.Placements.Count) >= 10);
AssertInsideOutline(job, result);
}
[Fact]
public void Default_UsesIrregularForAnOutlinedSheetAndRectanglesPlacesNothing()
{
var job = new NestJob([Square("sq", 20, 6)], [Stock(Ell())]);
var rectangles = new RectanglesNestingEngine().Solve(job);
Assert.Equal(0, rectangles.Plates.Sum(p => p.Placements.Count));
Assert.Equal(NestJobStopReason.NoPlacementFound, rectangles.StopReason);
var fill = new FixedStrategyNestingEngine("Fill").Solve(job);
Assert.Equal(0, fill.Plates.Sum(p => p.Placements.Count));
var chosen = new DefaultNestingEngine().Solve(job);
Assert.Equal(6, chosen.Plates.Sum(p => p.Placements.Count));
AssertInsideOutline(job, chosen);
}
[Fact]
public void Default_PrefersARectangleWhenAnOutlinedSheetIsAlsoOffered()
{
var rectangle = new NestPlateStock("sheet", new Size(48, 96), 1, Spacing);
var job = new NestJob([Square("sq", 20, 6)], [Stock(Ell()), rectangle]);
var result = new DefaultNestingEngine().Solve(job);
Assert.Equal(6, result.Plates.Sum(p => p.Placements.Count));
Assert.Empty(NestLayoutCheck.Violations(job, result));
}
[Fact]
public void LayoutCheck_RejectsAPartInTheMissingCornerOrOverAnObstacle()
{
var corner = new NestJob([Square("sq", 20, 2)], [Stock(Ell())]);
var inCorner = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, corner.Plates[0], [new NestJobPlacement("sq", 0, 1, 1, 0), new NestJobPlacement("sq", 1, 60, 26, 0)])],
[new PartFulfillment("sq", 2, 2, 0)], [new StockUsage("drop", 1, 0)]);
var violation = Assert.Single(NestLayoutCheck.Violations(corner, inCorner));
Assert.Contains("(60.00,26.00)", violation);
Assert.Contains("outline", violation);
var burned = new NestJob([Square("sq", 20, 1)], [Stock(Burned())]);
var onPatch = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, burned.Plates[0], [new NestJobPlacement("sq", 0, 30, 20, 0)])],
[new PartFulfillment("sq", 1, 1, 0)], [new StockUsage("drop", 1, 0)]);
Assert.Contains(NestLayoutCheck.Violations(burned, onPatch), v => v.Contains("obstacle"));
// A placement inside the arm, exactly at the edge, passes.
var fine = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, corner.Plates[0], [new NestJobPlacement("sq", 0, 0, 0, 0), new NestJobPlacement("sq", 1, 76, 4, 0)])],
[new PartFulfillment("sq", 2, 2, 0)], [new StockUsage("drop", 1, 0)]);
Assert.Empty(NestLayoutCheck.Violations(corner, fine));
}
[Fact]
public void Runner_RefusesACandidateOutsideTheOutline()
{
var job = new NestJob([Square("sq", 20, 1)], [Stock(Ell())]);
var candidate = new PlateCandidate([new NestJobPlacement("sq", 0, 60, 26, 0)]);
var parts = job.Parts.ToDictionary(p => p.Id);
var remaining = new Dictionary<string, int> { ["sq"] = 1 };
var exception = Assert.Throws<InvalidOperationException>(() =>
NestJobValidator.ValidateCandidate(candidate, job.Plates[0], remaining, parts));
Assert.Contains("usable stock area", exception.Message);
NestJobValidator.ValidateCandidate(new PlateCandidate([new NestJobPlacement("sq", 0, 76, 4, 0)]),
job.Plates[0], remaining, parts);
}
[Fact]
public void Pipeline_CommitsTheOutlineToThePlate()
{
var nest = new Nest("drop");
var drawing = new Drawing("sq", TestShapes.Rectangle(20, 20));
drawing.Quantity.Required = 6;
nest.Drawings.Add(drawing);
var template = new Plate { Outline = Ell(), PartSpacing = Spacing };
nest.Plates.Add(template);
var request = new NestPipelineRequest("Irregular",
[new NestItem { Drawing = drawing, Quantity = 6 }], NestStockBuilder.SinglePlate(template), new NestJobOptions());
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("\n", result.Violations));
var applied = NestPipelineCommit.ApplyToPlates(result, () => template);
var plate = Assert.Single(applied);
Assert.Equal(Ell(), plate.Outline);
Assert.Equal(6, plate.Parts.Count);
}
[Fact]
public void FillService_RefusesAnOutlinedPlate()
{
var plate = new Plate { Outline = Ell() };
var item = new NestItem { Drawing = new Drawing("sq", TestShapes.Rectangle(20, 20)), Quantity = 1 };
var exception = Assert.Throws<InvalidOperationException>(() =>
PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(), null, default));
Assert.Contains("rectangular", exception.Message);
// Callers check the refusal first and show it instead of catching the exception.
Assert.Equal(exception.Message, PlateFillService.Refusal(plate));
Assert.Null(PlateFillService.Refusal(new Plate(48, 96)));
Assert.Null(PlateFillService.Refusal(null));
}
[Fact]
public void RepeatedPlates_DoNotGroupAnOutlinedSheetWithItsRectangle()
{
var outlined = Stock(Ell(), quantity: null);
var plain = new NestPlateStock("drop", new Size(48, 96), null, Spacing);
Assert.False(RepeatedPlateGrouping.SameStock(outlined, plain));
Assert.True(RepeatedPlateGrouping.SameStock(outlined, Stock(Ell(), quantity: null)));
}
}
@@ -121,14 +121,11 @@ public sealed class CuttingPlanBatch
{
var plate = plates[index];
var budget = PlateBudget(plate.Parts.Count, budgetMultiplier);
// One capture serves both searches: the order flag and budget are search settings
// over the same owned geometry, programs and freshness record.
var captured = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
var keepOrder = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
expansionBudget: budget, confirmedParameters: confirmedParameters,
preservePartOrder: preservePartOrder), token);
var keepOrder = preservePartOrder ? captured : captured.WithPreservedOrder();
var reorder = preservePartOrder ? null
: reorderBudget is { } reorderLimit ? captured.WithSearchLimits(reorderLimit, captured.MaxEntries) : captured;
preservePartOrder: true), token);
var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), token);
var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
var fallback = reorder ?? keepOrder;
if (fallback.Failure == CuttingPlanStatus.UnsupportedGeometry)
@@ -215,10 +212,9 @@ public sealed class CuttingPlanBatch
/// <summary>
/// Safest first, then fastest: the replayed safest route from the free order and from the
/// current order, whichever breaks fewer checks (most severe first), then raises the head
/// fewer times, then has less rapid travel. A route whose planner-chosen motions pass every
/// check head-down is taken at once, even when fixed lead-ins still warn. Null when neither
/// walk finds a route.
/// current order, whichever breaks fewer checks (most severe first), then has less rapid
/// travel. A route whose planner-chosen motions pass every check is taken at once, even
/// when fixed lead-ins still warn. Null when neither walk finds a route.
/// </summary>
private static (CuttingPlanResult Result, bool FreeOrder)? SafestRoute(Entry entry, CancellationToken token)
{
@@ -229,9 +225,10 @@ public sealed class CuttingPlanBatch
{
if (CuttingPlanService.PlanSafestRoute(snapshot, token) is not { } candidate)
continue;
if (!candidate.IsSafestRouteFallback && candidate.HeadRaises == 0)
if (!candidate.IsSafestRouteFallback)
return (candidate, freeOrder);
if (best is not { } known || Safer(candidate, known.Result))
if (best is not { } known || candidate.SafestRoute < known.Result.SafestRoute
|| candidate.SafestRoute == known.Result.SafestRoute && candidate.RapidDistance < known.Result.RapidDistance)
best = (candidate, freeOrder);
}
}
@@ -242,17 +239,6 @@ public sealed class CuttingPlanBatch
return best;
}
// Fewer broken checks (a route that breaks none first), then fewer head raises, then less travel.
private static bool Safer(CuttingPlanResult candidate, CuttingPlanResult known)
{
var severity = (candidate.SafestRoute ?? default).CompareTo(known.SafestRoute ?? default);
if (severity != 0)
return severity < 0;
if (candidate.HeadRaises != known.HeadRaises)
return candidate.HeadRaises < known.HeadRaises;
return candidate.RapidDistance < known.RapidDistance;
}
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
int firstPassBudget = FirstPassBudget)
{
@@ -528,11 +514,8 @@ public sealed class CuttingPlanProposal
+ $"regenerated, {kept} kept as is; rapid travel "
+ $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}. {order}"
+ (plate.ReversedCutOffCount == 0 ? ""
: $" {Count(plate.ReversedCutOffCount, "cutoff segment")} at the far edge change direction.")
+ (result.HeadRaises == 0 ? ""
: $" No route found keeps the head down over every cut part, so {Count(result.HeadRaises, "rapid")} "
+ $"{(result.HeadRaises == 1 ? "raises" : "raise")} the head; each is listed below.");
// A ready plan's findings are advisory notes (head raises, support tabs that may not hold a part level).
: $" {Count(plate.ReversedCutOffCount, "cutoff segment")} at the far edge change direction.");
// A ready plan's findings are advisory notes (support tabs that may not hold a part level).
foreach (var line in Limit(result.Findings.Select(DescribeFinding)))
yield return line;
yield break;
@@ -120,13 +120,6 @@ public sealed class CuttingPlanSnapshot
PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters,
Dependencies, BestEffort)
{ SupportTabsSkipped = SupportTabsSkipped };
/// <summary>The same capture planned along its current part order.</summary>
internal CuttingPlanSnapshot WithPreservedOrder() =>
new(Placements, StartPoint, ExpansionBudget, Failure, Findings, Regeneration,
preservePartOrder: true, MaxEntries, ExpansionObserver, PlateState, OwnedParameters,
Dependencies, BestEffort)
{ SupportTabsSkipped = SupportTabsSkipped };
}
public sealed class FixedProgramPlacement
@@ -374,12 +367,6 @@ public sealed class CuttingPlanResult
/// <summary>Whether this is a safest-route fallback rather than a route that passed every check.</summary>
public bool IsSafestRouteFallback => SafestRoute != null;
/// <summary>
/// The rapids that raise the head over parts already cut, each listed in <see cref="Findings"/>.
/// Only a safest-route fallback marks them, when no search kept every rapid head-down.
/// </summary>
public int HeadRaises { get; init; }
/// <summary>The captured input this result was planned from; binds Apply to its freshness record.</summary>
internal CuttingPlanSnapshot Snapshot { get; set; }
}
@@ -350,13 +350,10 @@ public static class CuttingPlanService
/// <summary>
/// The safest route the greedy walk finds, independently replayed; null when there is none.
/// Its planner-chosen rapids that still cross parts already cut raise the head
/// (<see cref="RaisedRapids"/>): only here, after the strict search found no route avoiding
/// them. A route that then passes every check is Ready, listing its raises. One that does not
/// is an UNVERIFIED BestEffort carrying every violation, applicable only with explicit
/// acceptance. A route the replay cannot check completely, or with any motion into material
/// (a lead that does not fit, a cut-order or nested-lead conflict), is never offered: the
/// strict refusal stands.
/// A route that passes every check is Ready. One that does not is an UNVERIFIED BestEffort
/// carrying every violation, applicable only with explicit acceptance. A route the replay
/// cannot check completely, or with any motion into material (a lead that does not fit,
/// a cut-order or nested-lead conflict), is never offered: the strict refusal stands.
/// </summary>
internal static CuttingPlanResult PlanSafestRoute(CuttingPlanSnapshot snapshot, CancellationToken token)
{
@@ -369,14 +366,6 @@ public static class CuttingPlanService
var order = JointCuttingPlanSearch.SafestRoute(snapshot.WithSearchLimits(budget, snapshot.MaxEntries), token);
if (order == null)
return null;
try
{
order = RaisedRapids.Mark(snapshot, order, token);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
// A route that cannot be read for marking keeps its crossings as findings.
}
var result = ReplayPrograms(snapshot, order, 0, token, safestRoute: true);
if (result.Status is not (CuttingPlanStatus.Ready or CuttingPlanStatus.BestEffort)
|| result.SafestRoute is { Severe: > 0 })
@@ -408,9 +397,6 @@ public static class CuttingPlanService
// Violations inside fixed emissions: accepted as given, reported, never refused (D1a).
// Capture adds the drawing pins it could not honour; their contours were planned normally.
var warnings = new List<CuttingPlanFinding>(snapshot.Findings);
// Rapids that raise the head over cut parts are listed, never refused.
var raises = new List<CuttingPlanFinding>();
var raisedRapids = 0;
var materialOwners = snapshot.Placements.Where(p => !p.IsCutOff).ToArray();
var materials = materialOwners.Select(p => p.Material).ToArray();
foreach (var proposal in order)
@@ -447,10 +433,8 @@ public static class CuttingPlanService
findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions);
}
var isFixed = proposal.FixedMotions(execution);
var (strict, fixedFindings, raised) = checker.Check(execution, position, source.SourceOrdinal + 1,
var (strict, fixedFindings) = checker.Check(execution, position, source.SourceOrdinal + 1,
source.IsCutOff, isFixed, token);
raises.AddRange(RaisedRapids.Notes(snapshot, source, raised));
raisedRapids += RaisedRapids.Count(raised);
findings.AddRange(JointCuttingPlanSearch.Map(snapshot, strict));
warnings.AddRange(fixedFindings.Select(f => FixedLeadWarnings.FromChecker(snapshot, source, f)));
var fixedLeads = new List<FixedLeadFailure>();
@@ -480,14 +464,12 @@ public static class CuttingPlanService
}
token.ThrowIfCancellationRequested();
// Support tabs that cannot hold their part level are noted, never refused.
var notes = raises.Concat(order.SelectMany(SupportTabCapture.Notes))
.Concat(SupportTabCapture.Skipped(snapshot)).ToList();
var notes = order.SelectMany(SupportTabCapture.Notes).Concat(SupportTabCapture.Skipped(snapshot)).ToList();
if (safestRoute && findings.Count != 0 && findings.All(f => f.Kind != PostVerificationKind.Incomplete))
return new(CuttingPlanStatus.BestEffort, order, findings.Concat(warnings).Concat(notes).Distinct(),
expansions, distance)
{
SafestRoute = RouteSeverity.Of(findings.Distinct().Select(f => f.Kind)),
HeadRaises = raisedRapids,
};
if (findings.Count != 0)
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
@@ -497,11 +479,9 @@ public static class CuttingPlanService
// UNVERIFIED: never Ready, applicable only through explicit warning acceptance.
if (warnings.Count != 0)
return new(CuttingPlanStatus.BestEffort, order, warnings.Concat(notes).Distinct(), expansions,
distance, independentlyReplayed: true)
{ HeadRaises = raisedRapids };
distance, independentlyReplayed: true);
return new(CuttingPlanStatus.Ready, order, notes, expansions: expansions,
rapidDistance: distance, independentlyReplayed: true)
{ HeadRaises = raisedRapids };
rapidDistance: distance, independentlyReplayed: true);
}
private static CuttingPlanFinding DependencyFinding(CuttingPlanSnapshot snapshot,
@@ -28,14 +28,6 @@ internal static class JointCuttingPlanSearch
/// the contours are those of the blocked part and of the part cut just before it.
/// </summary>
internal const int StallExpansionsPerEntry = 8;
/// <summary>
/// Stalls' worth of expansions each search that backtracks without a stall limit (the full
/// pass along a kept order, and the sequence-free recovery and full passes once an order
/// teaches nothing) may spend before it ends. Beyond that they only retry entry combinations
/// of parts already cut, so the budget they would take goes to the lighter fallbacks instead.
/// </summary>
internal const int FallbackStalls = 2;
private const int MaximumRecoveryHoles = 30;
// The most holes recovery's search alone served; parts with more try the reading-order sweep first.
private const int SearchedRecoveryHoles = 16;
@@ -79,10 +71,6 @@ internal static class JointCuttingPlanSearch
var count = snapshot.Placements.Count;
var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1);
var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours;
// Fixed programs have no entries to retry, so their search space is small and running
// it out is a genuine refusal; only a search that regenerates programs is bounded.
int? FallbackLimit() => snapshot.Placements.Any(p => p.Prepared != null)
? (int)System.Math.Min(int.MaxValue, walk.Expansions + (long)FallbackStalls * stall) : null;
var preferEndpoints = count > 1 && maxContours > 1;
// A supplied blank alone on its sheet (its holes are the whole part) goes to contour
// recovery first; any other lone part keeps the plain search, whose routes are shorter.
@@ -103,7 +91,7 @@ internal static class JointCuttingPlanSearch
if (recovered.Order != null)
return walk.Ready(recovered.Order);
}
var kept = walk.Follow(keptSequence, null, null, expansionLimit: FallbackLimit());
var kept = walk.Follow(keptSequence, null, null);
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
}
@@ -145,12 +133,11 @@ internal static class JointCuttingPlanSearch
// A lone holed part has no part order to learn: its holes still get recovery.
if (preferEndpoints || maxContours > 1)
{
var recovery = walk.Follow(null, stall, null, contourRecovery: true,
expansionLimit: FallbackLimit());
var recovery = walk.Follow(null, stall, null, contourRecovery: true);
if (recovery.Order != null)
return walk.Ready(recovery.Order);
}
var full = walk.Follow(null, null, null, expansionLimit: FallbackLimit());
var full = walk.Follow(null, null, null);
return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted();
}
// Back up to just before the earliest part the blocked approach crossed, keep the
@@ -208,10 +195,7 @@ internal static class JointCuttingPlanSearch
}, token);
}
// When the approach crossed the part cut just before the one it left from, the tool went on
// past that part and came back across it: "cut those two the other way round", so the tool
// leaves from the nearer one (two stacked strips, cut far one first). Otherwise "cut the
// blocked part before every part whose cut contour its approach crossed", unless that
// "Cut the blocked part before every part whose cut contour its approach crossed", unless that
// would contradict an order already required. When every such rule would (a sheet separator
// is cut after everything), the blocked part cannot come earlier, so the tool must arrive
// from beyond those parts instead: "cut the part it left from before them". False when
@@ -233,10 +217,6 @@ internal static class JointCuttingPlanSearch
{
if (attempt.Blocked is not int blocked)
return [];
if (attempt.LastCut?.SourceOrdinal is int last && attempt.CutBeforeLast?.SourceOrdinal is int previous
&& attempt.Crossed.Contains(previous) && !prerequisites[previous].Contains(last)
&& !Precedes(previous, last, prerequisites))
return [(last, previous)];
var before = New(blocked);
return before.Length != 0 || attempt.LastCut?.SourceOrdinal is not int departed ? before : New(departed);
@@ -340,10 +320,9 @@ internal static class JointCuttingPlanSearch
/// further along the order; it never backtracks behind its starting node.
/// With <paramref name="prerequisites"/>, an approach into a cutoff that crosses parts
/// already cut ends the attempt at once when that teaches "cut it before them".
/// With <paramref name="expansionLimit"/>, the attempt ends once the walk's expansions reach it.
/// </summary>
internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false, bool contourRecovery = false,
HashSet<int>[] prerequisites = null, int? expansionLimit = null)
HashSet<int>[] prerequisites = null)
{
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null);
var attempt = new Attempt(sequence);
@@ -362,9 +341,6 @@ internal static class JointCuttingPlanSearch
attempt.Order = node.Order;
return attempt;
}
// A pass held to a share of the budget ends there, as a stalled one does.
if (expansionLimit is int end && Expansions >= end)
return attempt;
// A cutoff is a fixed program cut the same way wherever it goes, so replanning
// the parts before it can only change where its approach starts. Cutting it
// earlier never makes its own cut harder, and a sheet separator that cannot come
@@ -1605,9 +1581,6 @@ internal static class JointCuttingPlanSearch
/// <summary>The part cut last before the deepest boundary, or null at the start point.</summary>
internal FixedProgramPlacement LastCut => Deepest?.Order is { Length: > 0 } order ? order[^1] : null;
/// <summary>The part cut just before <see cref="LastCut"/>, or null.</summary>
internal FixedProgramPlacement CutBeforeLast => Deepest?.Order is { Length: > 1 } order ? order[^2] : null;
/// <summary>The ordinal of the part the attempt could not get past, or null.</summary>
internal int? Blocked => sequence == null || Deepest == null || Deepest.Order.Length >= sequence.Length ? null
: sequence[Deepest.Order.Length];
@@ -1,93 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// The last resort for a route no search could keep head-down: each planner-chosen rapid that
/// crosses parts already cut is marked to raise the head (<see cref="HeadRaise"/>). Only the
/// safest-route fallback uses it, after the strict search found no route avoiding every crossing,
/// so a plate that can be cut head-down never gets a raise. Rapids into or inside a fixed emission,
/// and rapids inside sub-programs, are never marked: their crossings stay findings.
/// </summary>
internal static class RaisedRapids
{
/// <summary>
/// <paramref name="order"/> with the crossing rapids of regenerated parts marked, walked as
/// replay walks it. Parts with nothing to mark keep their proposal.
/// </summary>
internal static IReadOnlyList<FixedProgramPlacement> Mark(CuttingPlanSnapshot snapshot,
IReadOnlyList<FixedProgramPlacement> order, CancellationToken token)
{
var checker = new ReleasedContourState(reportMissingLeadIns: false);
var position = snapshot.StartPoint;
var marked = new List<FixedProgramPlacement>(order.Count);
foreach (var proposal in order)
{
token.ThrowIfCancellationRequested();
var program = proposal.CopyProgram();
var execution = ExecutionMotionReader.Read(program, proposal.Location, position, token);
var placement = proposal;
var rapids = proposal.Prepared == null || proposal.IsCutOff ? []
: Crossing(checker.Copy(), proposal, execution, position, token);
if (rapids.Count != 0)
{
var raised = HeadRaise.Mark(program, rapids);
execution = ExecutionMotionReader.Read(raised, proposal.Location, position, token);
placement = proposal.Propose(raised, execution, proposal.ContourChoices, token);
}
checker.Check(execution, position, proposal.SourceOrdinal + 1, proposal.IsCutOff,
proposal.FixedMotions(execution), token);
marked.Add(placement);
position = execution.DeparturePoint;
}
return marked;
}
// The own-code indices of the planner's rapids that cross parts already cut.
private static HashSet<int> Crossing(ReleasedContourState checker, FixedProgramPlacement proposal,
OwnedExecution execution, Vector arrival, CancellationToken token)
{
var isFixed = proposal.FixedMotions(execution);
var strict = checker.Check(execution, arrival, proposal.SourceOrdinal + 1, false, isFixed, token).Strict;
var codes = new HashSet<int>();
foreach (var finding in strict)
{
if (finding.Kind != PostVerificationKind.RapidCrossing || finding.RapidEnd is not { } end)
continue;
for (var index = 0; index < execution.Motions.Count; index++)
{
var motion = execution.Motions[index];
if (motion.Rapid && motion.Code is int code && isFixed?.Invoke(index) != true
&& motion.End == end && (index == 0 ? arrival : motion.Start) == finding.RapidStart)
codes.Add(code);
}
}
return codes;
}
/// <summary>
/// One advisory note per raised rapid of <paramref name="source"/>, naming the cut parts it
/// passes over, from the replay's raised crossings (check positions are source ordinals + 1).
/// </summary>
internal static IEnumerable<CuttingPlanFinding> Notes(CuttingPlanSnapshot snapshot,
FixedProgramPlacement source, IReadOnlyList<PostVerificationFinding> raised) =>
raised.GroupBy(f => (f.RapidStart, f.RapidEnd)).Select(rapid =>
{
var parts = rapid.Select(f => f.OtherPartNumber).OfType<int>().Distinct().Order().ToArray();
var other = parts.Length == 1 ? snapshot.Placements[parts[0] - 1] : null;
var over = other != null ? "a part already cut" : $"parts {string.Join(", ", parts)}, already cut,";
return new CuttingPlanFinding(source.SourceOrdinal, source.SourcePart, other?.SourceOrdinal,
other?.SourcePart, null, $"A rapid raises the head over {over} because no route found keeps the head down.")
{ Witness = rapid.First() };
});
/// <summary>The raised rapids in <paramref name="raised"/>: crossings of one rapid count once.</summary>
internal static int Count(IEnumerable<PostVerificationFinding> raised) =>
raised.Select(f => (f.RapidStart, f.RapidEnd)).Distinct().Count();
}
@@ -58,8 +58,7 @@ public static class DrawingJobMapper
plate.EdgeSpacing,
plate.Quadrant,
cost: null,
clampZones: new List<ClampZone>(plate.ClampZones),
outline: plate.Outline
clampZones: new List<ClampZone>(plate.ClampZones)
)
{ ClampRail = plate.ClampRail };
}
@@ -127,7 +126,6 @@ public static class DrawingJobMapper
// them into the placement frame through ClampZoneFrame, exactly as the stock did.
foreach (var zone in stock.SheetClampZones)
plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds));
plate.Outline = stock.Outline;
return plate;
}
}
+2 -4
View File
@@ -377,9 +377,8 @@ internal static class ClampRemovalRecovery
var e = stock.EdgeSpacing;
var zones = string.Join(",", stock.SheetClampZones.Select(z =>
FormattableString.Invariant($"{z.Id}:{z.Bounds.X:R}/{z.Bounds.Y:R}/{z.Bounds.Length:R}/{z.Bounds.Width:R}")));
var outline = stock.Outline == null ? "" : FormattableString.Invariant($"|o{stock.Outline.GetHashCode():X}");
return FormattableString.Invariant(
$"{stock.Size.Width:R}x{stock.Size.Length:R}|q{stock.Quadrant}|{stock.PartSpacing:R}|{e.Left:R},{e.Bottom:R},{e.Right:R},{e.Top:R}|{zones}{outline}");
$"{stock.Size.Width:R}x{stock.Size.Length:R}|q{stock.Quadrant}|{stock.PartSpacing:R}|{e.Left:R},{e.Bottom:R},{e.Right:R},{e.Top:R}|{zones}");
}
private sealed class ProbeBudget(int solves)
@@ -429,8 +428,7 @@ internal static class ClampRemovalRecovery
stock.EdgeSpacing,
stock.Quadrant,
stock.Cost,
sheetZones,
stock.Outline
sheetZones
)
{ ClampRail = stock.ClampRail };
}
@@ -7,7 +7,6 @@ using Clipper2Lib;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -190,7 +189,13 @@ internal static class CutoutLatticeFill
.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).FirstOrDefault();
if (grown == null || grown.Count < 3)
return new PathsD();
return PackBoundary.InnerFit(grown, new PathsD { hole });
var reflected = new PathD(grown.Select(p => new PointD(-p.x, -p.y)));
// Clipper.MinkowskiSum rounds to two decimals; pass the job precision explicitly.
var forbidden = Clipper.Union(Minkowski.Sum(reflected, hole, true, Precision),
new PathsD { Clipper.TranslatePath(reflected, hole[0].x, hole[0].y) }, FillRule.NonZero, Precision);
var anchor = grown[0];
return Clipper.Difference(new PathsD { Clipper.TranslatePath(hole, -anchor.x, -anchor.y) }, forbidden,
FillRule.NonZero, Precision);
}
/// <summary>Drops copies that fail the production clearance check against the frame; any
@@ -1,203 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>
/// Frame-local cutout proposal that packs one closed cutout with the Irregular engine's
/// <see cref="FrontierPacker"/>, taking the cutout as the packing boundary and every insert
/// requirement at once: parts settle against the cutout wall and each other instead of at the
/// first sampled point that clears. Each of the engine's strategy variants packs the hole; the
/// variant seating the most copies wins, ties going to the tighter layout. Every pose is then
/// certified with <see cref="NestLayoutCheck.Clears"/> against the frame, the occupants and the
/// other accepted poses, exactly as the lattice and NFP proposals are. Never changes a job.
/// </summary>
internal static class CutoutPackerProposal
{
private const double MaxCoordinate = 1e8;
private const double MaxSpacing = 100;
private const double Tie = 1e-6;
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing, CancellationToken token = default) =>
Fill(frame, cutoutIndex, inserts, spacing,
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), null, token);
/// <summary>Poses in the frame's own coordinates, in packing order, clearing the frame,
/// the <paramref name="occupied"/> inserts and each other; instance indices count per
/// requirement.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing,
IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, CutoutMemo? memo,
CancellationToken token)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(inserts);
ArgumentNullException.ThrowIfNull(occupied);
token.ThrowIfCancellationRequested();
if (inserts.Count == 0 || !double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing)
return Array.Empty<NestJobPlacement>();
var frameGeometry = memo?.Read(frame.Geometry) ?? JobPartGeometry.TryRead(frame.Geometry);
if (frameGeometry == null || !WithinRange(frameGeometry.Bounds))
return Array.Empty<NestJobPlacement>();
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
var cutout = frameGeometry.Cutouts[cutoutIndex];
if (!cutout.IsClosed() || !WithinRange(cutout.BoundingBox)
|| spacing * 2 >= System.Math.Min(cutout.BoundingBox.Length, cutout.BoundingBox.Width))
return Array.Empty<NestJobPlacement>();
var geometries = new Dictionary<string, JobPartGeometry>(StringComparer.Ordinal);
foreach (var insert in inserts)
{
var geometry = memo?.Read(insert.Geometry) ?? JobPartGeometry.TryRead(insert.Geometry);
if (geometry == null || !WithinRange(geometry.Bounds))
return Array.Empty<NestJobPlacement>();
geometries[insert.Id] = geometry;
}
// Inscribed: the flattened cutout never extends past the real one.
var hole = CutoutLatticeFill.Positive(ClipperBridge.ToPath(
ClipperBridge.Flatten(cutout, CutoutLatticeFill.FlattenTolerance, circumscribe: false), new Vector()));
if (hole.Count < 3)
return Array.Empty<NestJobPlacement>();
var box = cutout.BoundingBox;
var stock = new NestPlateStock("cutout", new Size(box.Width, box.Length), 1, spacing);
var types = PartCatalog.Build(new NestJob(inserts, new[] { stock }));
if (types.All(t => t.Orientations.Count == 0))
return Array.Empty<NestJobPlacement>();
token.ThrowIfCancellationRequested();
var nfps = new NoFitCache(spacing);
var obstacles = occupied.Select(o => Outline(o.Geometry, o.Pose)).ToList();
var boundary = new PackBoundary(hole, obstacles, nfps);
var pairs = new Dictionary<int, IReadOnlyList<PairPose>>();
var counter = new WorkCounter();
var demand = types.Select(t => t.Part.Quantity).ToArray();
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
var preparedFrame = NestLayoutCheck.Prepare(frameGeometry, framePose, spacing);
var preparedOccupied = new NestLayoutCheck.PreparedPlacement[occupied.Count];
for (var i = 0; i < occupied.Count; i++)
{
token.ThrowIfCancellationRequested();
preparedOccupied[i] = NestLayoutCheck.Prepare(occupied[i].Geometry, occupied[i].Pose, spacing);
}
List<NestJobPlacement>? best = null;
foreach (var (axis, beta) in IrregularNestingEngine.Variants)
{
token.ThrowIfCancellationRequested();
var packer = new FrontierPacker(types, nfps, pairs, stock, axis, beta, counter, boundary: boundary);
var fill = packer.Fill(demand, token);
var poses = new List<NestJobPlacement>(fill.Parts.Count);
foreach (var placed in fill.Parts)
{
token.ThrowIfCancellationRequested();
var part = types[placed.Orientation.TypeIndex].Part;
var pose = new NestJobPlacement(part.Id, 0, System.Math.Round(placed.X, 8),
System.Math.Round(placed.Y, 8), placed.Orientation.Rotation);
if (Clears(pose, geometries[part.Id], preparedFrame, preparedOccupied, poses, geometries, spacing, memo, token))
poses.Add(pose);
}
if (poses.Count == 0)
continue;
if (best == null || poses.Count > best.Count
|| (poses.Count == best.Count && Tighter(poses, best, id => geometries[id])))
best = poses;
}
token.ThrowIfCancellationRequested();
if (best == null)
return Array.Empty<NestJobPlacement>();
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
return best.Select(p =>
{
var index = counts.GetValueOrDefault(p.PartId);
counts[p.PartId] = index + 1;
return p with { InstanceIndex = index };
}).ToArray();
}
/// <summary>True when <paramref name="candidate"/> is the more compact layout: a smaller
/// bounding box of the placed inserts, then one lower, then one further left.</summary>
internal static bool Tighter(IReadOnlyList<NestJobPlacement> candidate,
IReadOnlyList<NestJobPlacement> incumbent, Func<string, JobPartGeometry> geometry)
{
if (candidate.Count == 0)
return false;
if (incumbent.Count == 0)
return true;
var a = Envelope(candidate, geometry);
var b = Envelope(incumbent, geometry);
var areaA = a.Length * a.Width;
var areaB = b.Length * b.Width;
if (System.Math.Abs(areaA - areaB) > Tie * System.Math.Max(1, System.Math.Max(areaA, areaB)))
return areaA < areaB;
if (System.Math.Abs(a.Bottom - b.Bottom) > Tie)
return a.Bottom < b.Bottom;
return a.Left < b.Left - Tie;
}
private static Box Envelope(IReadOnlyList<NestJobPlacement> poses, Func<string, JobPartGeometry> geometry)
{
var left = double.PositiveInfinity;
var bottom = double.PositiveInfinity;
var right = double.NegativeInfinity;
var top = double.NegativeInfinity;
foreach (var pose in poses)
{
var shape = (Shape)geometry(pose.PartId).Perimeter.Clone();
shape.Rotate(pose.Rotation);
var bounds = shape.BoundingBox;
left = System.Math.Min(left, bounds.Left + pose.X);
bottom = System.Math.Min(bottom, bounds.Bottom + pose.Y);
right = System.Math.Max(right, bounds.Right + pose.X);
top = System.Math.Max(top, bounds.Top + pose.Y);
}
return new Box(left, bottom, right - left, top - bottom);
}
private static bool Clears(NestJobPlacement pose, JobPartGeometry geometry,
NestLayoutCheck.PreparedPlacement frame, NestLayoutCheck.PreparedPlacement[] occupied,
List<NestJobPlacement> accepted, IReadOnlyDictionary<string, JobPartGeometry> geometries,
double spacing, CutoutMemo? memo, CancellationToken token)
{
foreach (var other in occupied)
{
token.ThrowIfCancellationRequested();
if (!NestLayoutCheck.Clears(other, geometry, pose))
return false;
}
foreach (var other in accepted)
{
token.ThrowIfCancellationRequested();
var otherGeometry = geometries[other.PartId];
var clears = memo?.Clears(otherGeometry, other, geometry, pose, spacing)
?? NestLayoutCheck.Clears(otherGeometry, other, geometry, pose, spacing);
if (!clears)
return false;
}
token.ThrowIfCancellationRequested();
return NestLayoutCheck.Clears(frame, geometry, pose);
}
/// <summary>The occupant's perimeter at its pose, circumscribed so it never loses material.</summary>
private static (PathD Outline, double Tolerance) Outline(JobPartGeometry geometry, NestJobPlacement pose)
{
var shape = (Shape)geometry.Perimeter.Clone();
shape.Rotate(pose.Rotation);
var path = ClipperBridge.ToPath(ClipperBridge.Flatten(shape, CutoutLatticeFill.FlattenTolerance, circumscribe: true),
new Vector(pose.X, pose.Y));
return (CutoutLatticeFill.Positive(path), CutoutLatticeFill.FlattenTolerance);
}
private static bool WithinRange(Box box) =>
double.IsFinite(box.Left) && double.IsFinite(box.Right)
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
&& System.Math.Abs(box.Left) <= MaxCoordinate && System.Math.Abs(box.Right) <= MaxCoordinate
&& System.Math.Abs(box.Bottom) <= MaxCoordinate && System.Math.Abs(box.Top) <= MaxCoordinate;
}
@@ -57,12 +57,6 @@ internal sealed class CutoutPipelinePrepass
// An insert's outline cannot fit a hole of smaller area; bolt holes skip the searches.
var outline = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g
? System.Math.Abs(g.Perimeter.Area()) : double.PositiveInfinity);
// Lattice and NFP placement read a part's holes as free space, so they take as inserts
// only parts whose holes no part of the job fits by area: no later insert can land in
// one. Proxies are never solid; their frames' holes hold reserved inserts.
var smallestOutline = outline.Values.DefaultIfEmpty(double.PositiveInfinity).Min();
var solid = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g
&& g.Cutouts.All(c => System.Math.Abs(c.Area()) < smallestOutline));
var bundles = new Dictionary<string, Bundle>(StringComparer.Ordinal);
var byFrame = new Dictionary<string, List<NestJobPart>>(StringComparer.Ordinal);
// A proposal depends on the frame, the hole and the offered inserts with their local
@@ -110,10 +104,10 @@ internal sealed class CutoutPipelinePrepass
.Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id],
p.Priority, LocalRotation(p.Rotation, angle)))
.ToArray();
var flat = eligible.Where(p => solid.GetValueOrDefault(p.Id)).ToArray();
var flat = eligible.Where(p => geometry[p.Id] is { Cutouts.Count: 0 }).ToArray();
var nested = CutoutCirclePacker.RoundHole(profile.Cutouts[hole]) == null
? Array.Empty<NestJobPart>()
: eligible.Where(p => round[p.Id] && !solid.GetValueOrDefault(p.Id)).ToArray();
: eligible.Where(p => round[p.Id] && geometry[p.Id]!.Cutouts.Count > 0).ToArray();
if (flat.Length == 0 && nested.Length == 0)
continue;
foreach (var pose in Propose(frame, hole, flat, nested))
+4 -17
View File
@@ -10,8 +10,7 @@ namespace OpenNest.Engine.Jobs.Cutouts;
/// <remarks>Area ratio selects search order, not placement permission. The small-insert path
/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search;
/// the large-insert path tries NFP first. A round hole whose inserts are all round gets only the
/// circle-in-circle proposal for the whole list. Every other hole is also packed by
/// <see cref="CutoutPackerProposal"/>, which wins with more copies or an equal count packed tighter.
/// circle-in-circle proposal for the whole list.
/// All accepted poses are clearance-checked.
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
internal static class CutoutRouter
@@ -33,8 +32,7 @@ internal static class CutoutRouter
Fill(frame, cutoutIndex, inserts, spacing, null, memo, token);
/// <summary>Optional internal diagnostic receives only poses from the chosen proposal,
/// after indexing; true marks a retained lattice seed, false a residual NFP, circle-packer or
/// frontier-packer pose.</summary>
/// after indexing; true marks a retained lattice seed, false a residual NFP or circle-packer pose.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing,
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default) =>
@@ -68,10 +66,9 @@ internal static class CutoutRouter
// NFP searches never placed more there, and they are slow on round inserts, most of all
// when demand exceeds what fits (every surplus copy is searched for and not found).
if (CutoutCirclePacker.Applies(hole, inserts))
return Observe(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, memo, token));
return Circles(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, memo, token));
var accepted = new List<NestJobPlacement>();
var geometries = new Dictionary<string, JobPartGeometry>(StringComparer.Ordinal);
var observed = new List<(NestJobPlacement Pose, bool Lattice)>();
var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
var holeArea = System.Math.Abs(hole.Area());
@@ -80,7 +77,6 @@ internal static class CutoutRouter
{
token.ThrowIfCancellationRequested();
var geometry = JobPartGeometry.Read(part.Geometry);
geometries[part.Id] = geometry;
var ratio = geometry.MaterialArea / holeArea;
// Unsupported or degenerate material cannot establish a useful routing ratio;
// NFP retains its own fail-closed checks and the bounded lattice is not tried.
@@ -152,20 +148,11 @@ internal static class CutoutRouter
}
}
token.ThrowIfCancellationRequested();
// The frontier packer sees every requirement at once and settles parts against the
// walls and each other; it replaces the sampled proposal when it seats more copies, or
// the same number more compactly.
var packed = CutoutPackerProposal.Fill(frame, cutoutIndex, inserts, spacing,
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), memo, token);
if (packed.Count > accepted.Count
|| (packed.Count == accepted.Count
&& CutoutPackerProposal.Tighter(packed, accepted, id => geometries[id])))
return Observe(packed);
foreach (var (pose, lattice) in observed)
observeAccepted?.Invoke(pose, lattice);
return accepted.ToArray();
IReadOnlyList<NestJobPlacement> Observe(IReadOnlyList<NestJobPlacement> poses)
IReadOnlyList<NestJobPlacement> Circles(IReadOnlyList<NestJobPlacement> poses)
{
foreach (var pose in poses)
observeAccepted?.Invoke(pose, false);
+3 -5
View File
@@ -15,7 +15,7 @@ public static class NestJobCost
/// </summary>
public static double NetSheetArea(NestJob job, NestJobPlateResult sheet)
{
var area = sheet.Stock.Area;
var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length;
var minimum = job.Options.MinimumSalvageDimension;
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0)
return area;
@@ -84,18 +84,16 @@ public static class NestJobCost
private static double NetSheetArea(
NestJobOptions options, NestPlateStock stock, double left, double bottom, double right, double top)
{
var area = stock.Area;
var area = stock.Size.Width * stock.Size.Length;
var minimum = options.MinimumSalvageDimension;
if (options.SalvageRate == 0 || minimum <= 0)
return area;
return area - options.SalvageRate * LargestOffcut(stock, left, bottom, right, top, minimum);
}
// No rectangular edge strip is guaranteed inside an outlined sheet, so it earns no credit.
private static double LargestOffcut(
NestPlateStock stock, double left, double bottom, double right, double top, double minimum) =>
stock.Outline == null
&& PlateRemnant.LargestOffcut(stock.WorkArea, stock.PartSpacing, left, bottom, right, top, minimum)
PlateRemnant.LargestOffcut(stock.WorkArea, stock.PartSpacing, left, bottom, right, top, minimum)
is { } offcut ? offcut.Length * offcut.Width : 0;
public static bool UsesExplicitCosts(NestJob job) =>
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using Clipper2Lib;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
@@ -121,12 +120,7 @@ internal static class NestJobPlacementValidator
foreach (var cutout in shape.Cutouts)
if (!FitsWorkArea(cutout, workArea))
return false;
if (stock.Outline == null)
return true;
// The circumscribed perimeter must lie in the outline's usable area; holes are inside it.
var perimeter = ClipperBridge.ToPath(
ClipperBridge.Flatten(shape.Perimeter, NestTolerances.ValidationOutline, circumscribe: true), positive: true);
return stock.OutlineHolds(new PathsD { perimeter }, Epsilon);
return true;
}
private static bool FitsWorkArea(Shape contour, Box workArea)
-2
View File
@@ -63,8 +63,6 @@ public sealed class NestJobRunner : INestingEngine
var stock = job.Plates[index];
if (stock.Quantity is int quantity && used[stock.Id] >= quantity)
continue;
if (stock.Outline != null)
continue; // Single-plate fill strategies fill rectangles only.
hasAvailableStock = true;
var request = new PlatePlacementRequest(
stock,
+1 -19
View File
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Converters;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
@@ -395,7 +394,6 @@ public static class NestLayoutCheck
&& expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top)
&& expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom)
&& expected.Quadrant == actual.Quadrant
&& Equals(expected.Outline, actual.Outline)
&& SameZones(expected.ClampZones, actual.ClampZones)
);
@@ -467,7 +465,6 @@ public static class NestLayoutCheck
)
{
var workArea = plate.WorkArea();
var stock = plate.Outline == null ? null : DrawingJobMapper.FromPlate("plate", plate, null);
foreach (var part in parts)
{
@@ -484,19 +481,6 @@ public static class NestLayoutCheck
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area "
+ $"of a {plate.Size} plate"
);
continue;
}
// An outlined sheet: the circumscribed material must lie in the usable area, which
// keeps the largest edge spacing from every edge and obstacle, within the same slack.
if (stock != null && Outline(part, 0) is { } material
&& !stock.OutlineHolds(new PathsD { ClipperBridge.ToPath(material.Perimeter, positive: true) },
NestTolerances.WorkAreaSlack))
{
result.Add(
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the outline "
+ $"of a {plate.Size} plate or over one of its obstacles"
);
}
}
}
@@ -517,9 +501,7 @@ public static class NestLayoutCheck
)
{
var workArea = plate.WorkArea();
var budget = plate.Outline == null
? workArea.Width * workArea.Length
: DrawingJobMapper.FromPlate("plate", plate, null).UsableOutline().Sum(Clipper.Area);
var budget = workArea.Width * workArea.Length;
var placedArea = parts.Sum(p => p.BaseDrawing.Area);
if (placedArea > budget + Tolerance.Epsilon)
@@ -89,7 +89,6 @@ public static class NestPipelineCommit
continue;
var plate = nextPlate();
plate.Size = proposed.Stock.Size;
plate.Outline = proposed.Stock.Outline;
plate.PartSpacing = proposed.Stock.PartSpacing;
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
plate.Quadrant = proposed.Stock.Quadrant;
+3 -51
View File
@@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
@@ -24,19 +22,16 @@ public sealed class NestPlateStock
public NestPlateStock(string id, Size size, int? quantity, double partSpacing,
Spacing edgeSpacing, int quadrant, double? cost,
IReadOnlyList<ClampZone> clampZones = null, SheetOutline outline = null)
IReadOnlyList<ClampZone> clampZones = null)
{
if (cost.HasValue && (!double.IsFinite(cost.Value) || cost.Value <= 0))
throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null.");
ArgumentException.ThrowIfNullOrWhiteSpace(id);
if (quantity < 0)
throw new ArgumentOutOfRangeException(nameof(quantity));
if (outline != null && (outline.Size.Width != size.Width || outline.Size.Length != size.Length))
throw new ArgumentException($"Stock {id}: the outline's bounds ({outline.Size}) must be the sheet size ({size}).", nameof(outline));
Cost = cost;
Id = id;
Size = size;
Outline = outline;
Quantity = quantity;
PartSpacing = partSpacing;
EdgeSpacing = edgeSpacing;
@@ -97,50 +92,8 @@ public sealed class NestPlateStock
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
public double? Cost { get; }
public string Id { get; }
/// <summary>The sheet's bounding box; the whole sheet when <see cref="Outline"/> is null.</summary>
public Size Size { get; }
/// <summary>
/// The sheet's material when it is not a rectangle, in plate coordinates (the sheet's
/// lower-left frame, as <see cref="SheetClampZones"/>); null for a rectangular sheet. Its
/// bounds are <see cref="Size"/>. Only engines that fill an outline nest on such stock;
/// the others place nothing on it. Parts keep <see cref="OutlineInset"/> from its edges
/// and obstacles. Omitted from serialized results when null, so rectangular stock
/// serializes exactly as before.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public SheetOutline Outline { get; }
/// <summary>
/// The clearance a part keeps from an outlined sheet's edges and obstacles: the largest of
/// the four edge spacings, so no side of the outline is closer than the rectangle's would be.
/// </summary>
[JsonIgnore]
public double OutlineInset =>
System.Math.Max(0, System.Math.Max(System.Math.Max(EdgeSpacing.Left, EdgeSpacing.Right),
System.Math.Max(EdgeSpacing.Top, EdgeSpacing.Bottom)));
/// <summary>The sheet's lower-left corner in the placement frame: the translation
/// <see cref="WorkArea"/> and <see cref="ClampZones"/> were given.</summary>
[JsonIgnore]
public Vector PlacementOrigin => new(workArea.X - EdgeSpacing.Left, workArea.Y - EdgeSpacing.Bottom);
/// <summary>
/// Where a part may lie on an outlined sheet, in the placement frame: the outline less its
/// obstacles, eroded by <see cref="OutlineInset"/>. Null for a rectangular sheet, whose
/// <see cref="WorkArea"/> is the answer. A fresh copy on every call.
/// </summary>
public PathsD UsableOutline() => Outline?.Usable(OutlineInset, PlacementOrigin);
/// <summary>
/// Whether <paramref name="region"/> (outer paths positive, holes negative, in the placement
/// frame) lies within the outlined sheet's usable area, allowing <paramref name="slack"/>
/// outside it. True for a rectangular sheet: its box is checked by the callers.
/// </summary>
public bool OutlineHolds(PathsD region, double slack) =>
Outline == null || Outline.Holds(region, OutlineInset, PlacementOrigin, slack);
/// <summary>Available physical sheets: null is unlimited, zero is legal but unavailable.</summary>
public int? Quantity { get; }
public double PartSpacing { get; }
@@ -153,9 +106,8 @@ public sealed class NestPlateStock
/// </summary>
public Box WorkArea => new(workArea.X, workArea.Y, workArea.Length, workArea.Width);
/// <summary>Full sheet area before edge spacing is removed: the material inside the
/// <see cref="Outline"/> when there is one.</summary>
public double Area => Outline?.Area ?? Size.Width * Size.Length;
/// <summary>Full sheet area before edge spacing is removed.</summary>
public double Area => Size.Width * Size.Length;
/// <summary>True when a width (X) by height (Y) envelope fits the work area within epsilon.</summary>
public bool Fits(double width, double height, double epsilon = 1e-9) =>
@@ -29,16 +29,6 @@ public static class PlateFillService
"Horizontal Remnant",
];
/// <summary>Why no strategy fills a plate with a sheet outline (see <see cref="Refusal"/>).</summary>
public const string OutlinedSheetMessage =
"Fill strategies need a rectangular sheet. Nest an outlined sheet with Auto Nest and the Irregular engine.";
/// <summary>
/// Why the fill strategies cannot fill <paramref name="plate"/>, or null when they can. Callers
/// show it to the user instead of calling an operation that would throw it.
/// </summary>
public static string Refusal(Plate plate) => plate?.Outline == null ? null : OutlinedSheetMessage;
public static List<Part> FillItem(
string strategy,
Plate plate,
@@ -441,10 +431,6 @@ public static class PlateFillService
private static PlateFiller RequireFiller(string strategy, Plate plate)
{
ArgumentNullException.ThrowIfNull(strategy);
ArgumentNullException.ThrowIfNull(plate);
// Every filler lays parts out in a rectangle; only Auto Nest's Irregular engine honors an outline.
if (Refusal(plate) is { } refusal)
throw new InvalidOperationException(refusal);
// An explicit empty string is an unknown strategy; only ResolveStrategy(string),
// used for caller selections, maps null/empty to Fill.
ResolveStrategy(strategy, allowEmpty: false);
@@ -40,7 +40,6 @@ internal static class RepeatedPlateGrouping
&& Bits(a.EdgeSpacing.Top) == Bits(b.EdgeSpacing.Top)
&& Bits(a.EdgeSpacing.Bottom) == Bits(b.EdgeSpacing.Bottom)
&& a.Quantity == b.Quantity
&& Equals(a.Outline, b.Outline)
&& a.Cost.HasValue == b.Cost.HasValue
&& (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value))
&& SameZones(a.ClampZones, b.ClampZones);
@@ -90,8 +90,6 @@ internal static class DiscPacker
/// <summary>True when the disc fits the work area in both directions.</summary>
public static bool Fits(NestPlateStock stock, DiscType type)
{
if (stock.Outline != null)
return false; // The lattice fills rectangles only.
var work = stock.WorkArea;
return 2 * type.Radius <= work.Right - work.Left + Epsilon
&& 2 * type.Radius <= work.Top - work.Bottom + Epsilon;
@@ -131,12 +131,9 @@ internal sealed class BarStock
var lengths = new double[types.Count];
for (var t = 0; t < types.Count; t++)
{
// A bar is a rectangle; an outlined sheet is left to engines that fill outlines.
var fitting = stock.Outline != null
? new List<BarOrientation>()
: types[t].Orientations
.Where(o => (alongX ? o.Height : o.Width) <= crossExtent + BarPacker.OverhangAllowance)
.ToList();
var fitting = types[t].Orientations
.Where(o => (alongX ? o.Height : o.Width) <= crossExtent + BarPacker.OverhangAllowance)
.ToList();
if (fitting.Count == 0)
{
fits[t] = Array.Empty<BarOrientation>();
@@ -44,10 +44,6 @@ internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList<Placed> Par
/// 2. Otherwise advance - place the part whose front advance per unit area^beta is smallest,
/// i.e. the one that buys the most material coverage for the sheet length it consumes.
/// Parts are never placed in a sequence given up front; the sheet state decides what comes next.
///
/// Given a <see cref="PackBoundary"/>, the packer fills that outline instead of the stock's
/// rectangular work area: each region starts from the orientation's inner-fit polygon of the
/// boundary less the boundary's obstacles, and everything else is unchanged.
/// </summary>
internal sealed class FrontierPacker
{
@@ -67,10 +63,7 @@ internal sealed class FrontierPacker
private readonly WorkCounter counter;
private readonly BlockCatalog? blocks;
private readonly ClampObstacles? clamps;
private readonly PackBoundary? boundary;
/// <param name="boundary">Outline to fill instead of the stock's work area; it must be
/// fitted to <paramref name="nfps"/>.</param>
public FrontierPacker(
IReadOnlyList<PartType> types,
NoFitCache nfps,
@@ -80,12 +73,9 @@ internal sealed class FrontierPacker
double beta,
WorkCounter counter,
BlockCatalog? blocks = null,
ClampObstacles? clamps = null,
PackBoundary? boundary = null
ClampObstacles? clamps = null
)
{
if (boundary != null && !ReferenceEquals(boundary.Nfps, nfps))
throw new ArgumentException("The boundary is fitted to another no-fit cache.", nameof(boundary));
this.blocks = blocks;
this.clamps = stock.ClampZones.Count == 0 ? null : clamps ?? new ClampObstacles(stock);
this.counter = counter;
@@ -95,13 +85,9 @@ internal sealed class FrontierPacker
this.stock = stock;
this.axis = axis;
this.beta = beta;
this.boundary = boundary;
work = boundary?.Bounds ?? stock.WorkArea;
work = stock.WorkArea;
}
private bool Fits(double width, double height) =>
boundary?.Fits(width, height) ?? stock.Fits(width, height);
public SheetFill Fill(IReadOnlyList<int> remaining, CancellationToken token)
{
var left = remaining.ToArray();
@@ -111,11 +97,9 @@ internal sealed class FrontierPacker
{
if (byOrientation.ContainsKey(o))
return true;
if (!Fits(o.Width, o.Height))
return false;
var region = boundary == null ? new Region(o, work, single) : new Region(o, boundary, single);
if (region.IsEmpty)
if (!stock.Fits(o.Width, o.Height))
return false;
var region = new Region(o, work, single);
if (clamps != null)
for (var i = 0; i < clamps.Count; i++)
{
@@ -126,13 +110,6 @@ internal sealed class FrontierPacker
counter.Add(1);
token.ThrowIfCancellationRequested();
}
if (boundary != null)
for (var i = 0; i < boundary.ObstacleCount; i++)
{
token.ThrowIfCancellationRequested();
region.Subtract(nfps.Get(boundary.Obstacle(i), o), 0, 0);
counter.Add(1);
}
states.Add(region);
byOrientation[o] = region;
return true;
@@ -149,7 +126,7 @@ internal sealed class FrontierPacker
foreach (var pair in typePairs)
{
// Members missing from the catalog get regions too, but only for pair placement.
if (Fits(pair.Width, pair.Height) && Track(pair.A, single: false) && Track(pair.B, single: false))
if (stock.Fits(pair.Width, pair.Height) && Track(pair.A, single: false) && Track(pair.B, single: false))
offered.Add(new PairState(pair, byOrientation[pair.A], byOrientation[pair.B]));
}
}
@@ -171,7 +148,7 @@ internal sealed class FrontierPacker
continue;
var width = members.Max(p => p.Right) - members.Min(p => p.Left);
var height = members.Max(p => p.Top) - members.Min(p => p.Bottom);
if (!Fits(width, height))
if (!stock.Fits(width, height))
continue;
foreach (var member in members)
{
@@ -364,15 +341,6 @@ internal sealed class FrontierPacker
private RectD bounds;
public Region(Orientation orientation, Box work, bool single)
: this(orientation, work, single, null) { }
/// <summary>Starts from the orientation's inner fit of the boundary. No fit slack: the
/// inner fit already lies within the bounds box, and an exact fit against a cutout wall
/// would fail the clearance check anyway.</summary>
public Region(Orientation orientation, PackBoundary boundary, bool single)
: this(orientation, boundary.Bounds, single, boundary.InnerFit(orientation)) { }
private Region(Orientation orientation, Box work, bool single, PathsD? seed)
{
Orientation = orientation;
Single = single;
@@ -385,7 +353,7 @@ internal sealed class FrontierPacker
maxX = minX;
if (maxY < minY)
maxY = minY;
free = seed ?? new PathsD
free = new PathsD
{
new PathD
{
@@ -395,8 +363,7 @@ internal sealed class FrontierPacker
new(minX - FitSlack, maxY + FitSlack),
},
};
free.RemoveAll(p => p.Count < 3);
bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
bounds = Clipper.GetBounds(free);
}
public Orientation Orientation { get; }
@@ -24,9 +24,8 @@ namespace OpenNest.Engine.NestingEngines.Irregular;
/// </summary>
public sealed class IrregularNestingEngine : INestingEngine
{
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).
/// The cutout packer proposal tries the same variants on a cutout.</summary>
internal static readonly (PackAxis Axis, double Beta)[] Variants =
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
private static readonly (PackAxis Axis, double Beta)[] Variants =
{
(PackAxis.X, 1.0),
(PackAxis.Y, 1.0),
@@ -149,7 +148,6 @@ public sealed class IrregularNestingEngine : INestingEngine
private const int MaxTail = 3;
private readonly Dictionary<double, NoFitCache> caches = new();
private readonly Dictionary<NestPlateStock, ClampObstacles> clamps = new();
private readonly Dictionary<NestPlateStock, PackBoundary?> boundaries = new();
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
private readonly Dictionary<double, BlockCatalog> blocks = new();
private readonly IReadOnlyDictionary<string, NestJobPart> parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
@@ -231,18 +229,6 @@ public sealed class IrregularNestingEngine : INestingEngine
return cache;
}
/// <summary>An outlined sheet's usable material as the packer's boundary, built once per
/// stock per solve; null for a rectangular sheet, which keeps the work-area path.</summary>
private PackBoundary? BoundaryFor(NestPlateStock stock)
{
if (stock.Outline == null)
return null;
if (!boundaries.TryGetValue(stock, out var found))
boundaries[stock] = found = new PackBoundary(stock.Outline.Material(stock.PlacementOrigin),
stock.OutlineInset, stock.WorkArea, CacheFor(stock));
return found;
}
private ClampObstacles? ClampsFor(NestPlateStock stock)
{
if (stock.ClampZones.Count == 0)
@@ -320,7 +306,7 @@ public sealed class IrregularNestingEngine : INestingEngine
continue;
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work,
BlocksFor(stock), ClampsFor(stock), BoundaryFor(stock));
BlocksFor(stock), ClampsFor(stock));
var fill = packer.Fill(remaining, token);
sheetPacked?.Invoke();
if (fill.Parts.Count > 0)
@@ -30,13 +30,9 @@ internal sealed class NoFitCache
public NoFitCache(double clearance)
{
Clearance = clearance;
halfClearance = clearance / 2;
}
/// <summary>The clearance two footprints together keep.</summary>
public double Clearance { get; }
public PathD Footprint(Orientation o) =>
footprints.GetOrAdd((o.TypeIndex, o.Index), _ => BuildFootprint(o));
@@ -1,143 +0,0 @@
#nullable enable
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>
/// A closed outline the packer fills in place of a rectangular work area: a frame's cutout in
/// frame coordinates, with the parts already seated in it as obstacles. Each orientation's
/// starting free region is its inner-fit polygon: the reference points at which its clearance
/// footprint lies inside the outline eroded by the other half of the clearance, so a part
/// keeps the full clearance from the frame exactly as it does from another part. The inner
/// fit is computed once per orientation and shared by every packing of this boundary.
/// </summary>
/// <remarks>Bound to one <see cref="NoFitCache"/>: its footprints define the inner fits and the
/// obstacle no-fit polygons, so a packer filling this boundary must use that cache.</remarks>
internal sealed class PackBoundary
{
/// <summary>Extra erosion beyond the half clearance, covering the outline's flattening and
/// the round-offset chords at its reflex corners.</summary>
private const double Margin = 0.002;
private const double ArcTolerance = 0.001;
private readonly PathsD eroded;
private readonly Orientation[] obstacles;
private readonly ConcurrentDictionary<(int, int), PathsD> innerFits = new();
/// <param name="outline">Closed polygon inscribed in the true outline, so flattening never
/// adds room.</param>
/// <param name="obstacles">Polygons circumscribing material already inside the outline, with
/// the chord deviation each was flattened with; every obstacle gets the full clearance.</param>
/// <param name="nfps">The cache whose footprints this boundary is fitted to.</param>
public PackBoundary(PathD outline, IReadOnlyList<(PathD Outline, double Tolerance)> obstacles, NoFitCache nfps)
{
Nfps = nfps;
var box = Clipper.GetBounds(outline);
Bounds = new Box(box.left, box.top, box.Width, box.Height); // Clipper RectD: top is the minimum Y.
eroded = Clipper.InflatePaths(new PathsD { Positive(outline) }, -(nfps.Clearance / 2 + Margin),
JoinType.Round, EndType.Polygon, 2.0, NoFitCache.Precision, ArcTolerance);
eroded.RemoveAll(p => p.Count < 3);
this.obstacles = new Orientation[obstacles.Count];
for (var i = 0; i < obstacles.Count; i++)
{
var path = Positive(obstacles[i].Outline);
var tolerance = obstacles[i].Tolerance;
var bounds = Clipper.GetBounds(path);
this.obstacles[i] = new Orientation
{
// Real part types are nonnegative, so obstacle footprints never alias them.
TypeIndex = -1,
Index = i,
Rotation = 0,
Outline = path,
Tolerance = tolerance,
MinX = bounds.left - tolerance,
MinY = bounds.top - tolerance,
MaxX = bounds.right + tolerance,
MaxY = bounds.bottom + tolerance,
};
}
}
/// <summary>
/// A sheet's material as the boundary: <paramref name="material"/> is its outline less its
/// obstacles (outer paths positive, holes negative) in the placement frame, and parts keep
/// <paramref name="edgeClearance"/> from every edge and obstacle, as they keep the edge
/// spacing from a rectangular sheet's edges. The outline is eroded by the edge clearance
/// less the half clearance the footprints already carry (grown when that is negative), plus
/// the margin; <paramref name="bounds"/> is the packer's work area and front origin.
/// </summary>
public PackBoundary(PathsD material, double edgeClearance, Box bounds, NoFitCache nfps)
{
Nfps = nfps;
Bounds = bounds;
var inset = edgeClearance - nfps.Clearance / 2 + Margin;
eroded = Clipper.InflatePaths(material, -inset, JoinType.Round, EndType.Polygon, 2.0,
NoFitCache.Precision, ArcTolerance);
eroded.RemoveAll(p => p.Count < 3);
obstacles = System.Array.Empty<Orientation>();
}
public NoFitCache Nfps { get; }
/// <summary>Bounds of the outline: the packer's work area and front origin.</summary>
public Box Bounds { get; }
public int ObstacleCount => obstacles.Length;
public Orientation Obstacle(int index) => obstacles[index];
/// <summary>Coarse filter against the outline's bounds; the inner fit decides.</summary>
public bool Fits(double width, double height) =>
width <= Bounds.Length + 1e-9 && height <= Bounds.Width + 1e-9;
/// <summary>A fresh copy of the orientation's starting free region.</summary>
public PathsD InnerFit(Orientation orientation) =>
new(innerFits.GetOrAdd((orientation.TypeIndex, orientation.Index),
_ => InnerFit(Nfps.Footprint(orientation), eroded)));
/// <summary>
/// Reference points t at which <paramref name="grown"/> lies inside one component of
/// <paramref name="hole"/>: its anchor vertex is inside the component (t in component - anchor)
/// and its outline never meets the component's boundary (t outside boundary + reflected
/// outline, including the pose that contains the whole component).
/// </summary>
internal static PathsD InnerFit(PathD grown, PathsD hole)
{
if (grown.Count < 3)
return new PathsD();
var reflected = new PathD(grown.Select(p => new PointD(-p.x, -p.y)));
var anchor = grown[0];
var inside = new PathsD();
PathsD? forbidden = null;
foreach (var component in hole)
{
if (component.Count < 3)
continue;
inside.Add(Clipper.TranslatePath(component, -anchor.x, -anchor.y));
// Clipper.MinkowskiSum rounds to two decimals; pass the job precision explicitly.
var sweep = Clipper.Union(Minkowski.Sum(reflected, component, true, NoFitCache.Precision),
new PathsD { Clipper.TranslatePath(reflected, component[0].x, component[0].y) },
FillRule.NonZero, NoFitCache.Precision);
forbidden = forbidden == null ? sweep : Clipper.Union(forbidden, sweep, FillRule.NonZero, NoFitCache.Precision);
}
return forbidden == null
? new PathsD()
: Clipper.Difference(inside, forbidden, FillRule.NonZero, NoFitCache.Precision);
}
private static PathD Positive(PathD path)
{
if (path.Count >= 3 && !Clipper.IsPositive(path))
{
path = new PathD(path);
path.Reverse();
}
return path;
}
}
@@ -76,8 +76,6 @@ public sealed class RectanglesNestingEngine : INestingEngine
private static bool FitsStock(NestPlateStock stock, BoxOrientation o)
{
if (stock.Outline != null)
return false; // Boxes pack rectangles only.
var work = stock.WorkArea;
return o.Width <= work.Right - work.Left + SheetPacker.OverhangAllowance
&& o.Height <= work.Top - work.Bottom + SheetPacker.OverhangAllowance;
@@ -69,12 +69,11 @@ internal static class SheetByStock
}
var trials = new List<(SheetLayout Layout, double Net, double Offcut)>();
// Box and disc packers fill rectangles only; an outlined sheet is left to engines that fill outlines.
if (preferred != null && preferred.Outline == null)
if (preferred != null)
Try(preferred, trials);
if (trials.Count == 0)
foreach (var stock in job.Plates)
if (!ReferenceEquals(stock, preferred) && stock.Outline == null)
if (!ReferenceEquals(stock, preferred))
Try(stock, trials);
if (trials.Count == 0)
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
+1 -1
View File
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.Gpu</RootNamespace>
<AssemblyName>OpenNest.Gpu</AssemblyName>
</PropertyGroup>
@@ -1,118 +0,0 @@
using System.Text.Json.Nodes;
using OpenNest.Geometry;
using static OpenNest.IO.Tests.NestArchiveJson;
namespace OpenNest.IO.Tests;
/// <summary>
/// A plate's <see cref="SheetOutline"/> survives the .nest round trip under an optional
/// <c>outline</c> key; files without it load rectangles unchanged, an empty outlined plate is
/// kept, and a malformed value loads as a rectangle with a reader warning.
/// </summary>
public class SheetOutlinePersistenceTests
{
/// <summary>A 96 x 48 sheet missing its upper-right 48 x 24 corner, with a burned 4 x 4 patch.</summary>
private static SheetOutline Ell() => new(
new[] { new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(48, 24), new Vector(48, 48), new Vector(0, 48) },
new[] { new[] { new Vector(10, 10), new Vector(14, 10), new Vector(14, 14), new Vector(10, 14) } });
private static byte[] Write(Nest nest)
{
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
return stream.ToArray();
}
private static Nest OutlinedNest()
{
var nest = new Nest("outlined");
nest.Plates.Add(new Plate { Outline = Ell(), Quadrant = 3, EdgeSpacing = new Spacing(1, 2, 3, 4) });
return nest;
}
[Fact]
public void OutlineRoundTripsWithObstaclesAndKeepsAnEmptyPlate()
{
var (reopened, warnings) = RoundTrip(Write(OutlinedNest()));
Assert.Empty(warnings);
var plate = Assert.Single(reopened.Plates);
Assert.Equal(Ell(), plate.Outline);
Assert.Equal(48, plate.Size.Width);
Assert.Equal(96, plate.Size.Length);
Assert.Equal(3, plate.Quadrant);
Assert.Equal(96 * 48 - 48 * 24 - 16, plate.Area(), 6);
}
[Fact]
public void FileShapeIsPointsAndObstaclesInPlateCoordinates()
{
var plates = NestJson(Write(OutlinedNest()))["plates"]!.AsArray();
var outline = Assert.Single(plates)!["outline"]!.AsObject();
var points = outline["points"]!.AsArray();
Assert.Equal(6, points.Count);
Assert.Equal(0, (double)points[0]!["x"]!);
Assert.Equal(0, (double)points[0]!["y"]!);
Assert.Equal(96, (double)points[1]!["x"]!);
var obstacle = Assert.Single(outline["obstacles"]!.AsArray())!.AsArray();
Assert.Equal(4, obstacle.Count);
Assert.Equal(10, (double)obstacle[0]!["x"]!);
}
[Fact]
public void RectangularPlatesWriteNoOutlineKeyAndLoadWithout()
{
var nest = new Nest("plain");
nest.Plates.Add(new Plate(48, 96));
nest.Plates[0].CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
var archive = Write(nest);
var json = NestJson(archive);
Assert.Null(Assert.Single(json["plates"]!.AsArray())!["outline"]);
var (reopened, warnings) = RoundTrip(archive);
Assert.Empty(warnings);
Assert.Null(Assert.Single(reopened.Plates).Outline);
}
[Theory]
[InlineData("\"not an object\"", "not an object")]
[InlineData("{}", "'points' is missing")]
[InlineData("{\"points\": 5}", "not an array")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": \"ten\", \"y\": 0}, {\"x\": 0, \"y\": 10}]}", "vertex 2")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": 10, \"y\": 0}]}", "three distinct vertices")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": 10, \"y\": 10}, {\"x\": 10, \"y\": 0}, {\"x\": 0, \"y\": 10}]}", "no area")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": 10, \"y\": 0}, {\"x\": 10, \"y\": 10}, {\"x\": 2, \"y\": -2}]}", "simple polygon")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": 10, \"y\": 0}, {\"x\": 10, \"y\": 10}], \"obstacles\": 3}", "'obstacles' is not an array")]
public void MalformedOutlineLoadsARectangleWithAWarning(string value, string problem)
{
var archive = Write(OutlinedNest());
var json = NestJson(archive);
var plate = Assert.Single(json["plates"]!.AsArray())!.AsObject();
plate["outline"] = JsonNode.Parse(value);
var (reopened, warnings) = RoundTrip(ReplaceNestJson(archive, json));
var loaded = Assert.Single(reopened.Plates);
Assert.Null(loaded.Outline);
Assert.Equal(48, loaded.Size.Width);
Assert.Equal(96, loaded.Size.Length);
var warning = Assert.Single(warnings);
Assert.Contains("Plate 1", warning);
Assert.Contains(problem, warning);
}
[Fact]
public void SnapshotRestoresTheOutline()
{
var nest = OutlinedNest();
var snapshot = NestSnapshot.Capture(nest);
nest.Plates[0].Outline = null;
nest.Plates[0].Size = new Size(60, 120);
var restored = snapshot.Restore(nest);
Assert.Equal(Ell(), Assert.Single(restored.Plates).Outline);
Assert.Equal(96, restored.Plates[0].Size.Length);
}
}
-139
View File
@@ -696,145 +696,6 @@ namespace OpenNest.IO
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(WriteOnlyConverter<ExpectedRemnantDto>))]
public ExpectedRemnantDto ExpectedRemnant { get; init; }
/// <summary>
/// The sheet's material when it is not a rectangle (<see cref="SheetOutline"/>), in
/// plate coordinates; omitted for a rectangular sheet, which is every sheet in older
/// files. Read leniently: a wrongly shaped value loads as a rectangle with a reader
/// warning (see <see cref="NestReader.Warnings"/>).
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(LenientSheetOutlineConverter))]
public SheetOutlineDto Outline { get; init; }
}
/// <summary>A non-rectangular sheet: its vertices and the vertices of each obstacle.</summary>
public record SheetOutlineDto
{
public List<PointDto> Points { get; init; } = new();
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<List<PointDto>> Obstacles { get; init; }
/// <summary>Why the value could not be read, when it was malformed; never written.</summary>
[JsonIgnore]
public string Problem { get; init; }
}
public record PointDto
{
public double X { get; init; }
public double Y { get; init; }
}
/// <summary>
/// Reads a sheet outline leniently: a value that is not an object, a missing or non-array
/// <c>points</c>, or a vertex that is not an object with numeric <c>x</c> and <c>y</c>
/// yields a DTO carrying the <see cref="SheetOutlineDto.Problem"/> instead of failing the
/// file; a non-array obstacle list or entry is reported the same way. Geometry is not
/// checked here: <see cref="SheetOutline"/> does that when the plate is built.
/// </summary>
public sealed class LenientSheetOutlineConverter : JsonConverter<SheetOutlineDto>
{
public override SheetOutlineDto Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
)
{
using var document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Null)
return null;
if (root.ValueKind != JsonValueKind.Object)
return new SheetOutlineDto { Problem = "it is not an object" };
if (!root.TryGetProperty("points", out var list))
return new SheetOutlineDto { Problem = "'points' is missing" };
if (!TryPointList(list, out var points, out var problem))
return new SheetOutlineDto { Problem = problem };
List<List<PointDto>> obstacles = null;
if (root.TryGetProperty("obstacles", out var entries) && entries.ValueKind != JsonValueKind.Null)
{
if (entries.ValueKind != JsonValueKind.Array)
return new SheetOutlineDto { Problem = "'obstacles' is not an array" };
obstacles = new List<List<PointDto>>();
var index = 0;
foreach (var entry in entries.EnumerateArray())
{
index++;
if (!TryPointList(entry, out var obstacle, out problem))
return new SheetOutlineDto { Problem = $"obstacle {index}: {problem}" };
obstacles.Add(obstacle);
}
}
return new SheetOutlineDto { Points = points, Obstacles = obstacles };
}
private static bool TryPointList(JsonElement list, out List<PointDto> points, out string problem)
{
points = null;
problem = null;
if (list.ValueKind != JsonValueKind.Array)
{
problem = "the vertex list is not an array";
return false;
}
points = new List<PointDto>();
foreach (var element in list.EnumerateArray())
{
if (element.ValueKind != JsonValueKind.Object
|| !TryNumber(element, "x", out var x)
|| !TryNumber(element, "y", out var y))
{
problem = $"vertex {points.Count + 1} is not an object with numeric x and y";
return false;
}
points.Add(new PointDto { X = x, Y = y });
}
return true;
}
public override void Write(
Utf8JsonWriter writer,
SheetOutlineDto value,
JsonSerializerOptions options
)
{
writer.WriteStartObject();
writer.WriteStartArray("points");
foreach (var point in value.Points)
WritePoint(writer, point);
writer.WriteEndArray();
if (value.Obstacles is { Count: > 0 })
{
writer.WriteStartArray("obstacles");
foreach (var obstacle in value.Obstacles)
{
writer.WriteStartArray();
foreach (var point in obstacle)
WritePoint(writer, point);
writer.WriteEndArray();
}
writer.WriteEndArray();
}
writer.WriteEndObject();
}
private static bool TryNumber(JsonElement point, string name, out double value)
{
value = 0;
return point.TryGetProperty(name, out var element)
&& element.ValueKind == JsonValueKind.Number
&& element.TryGetDouble(out value);
}
private static void WritePoint(Utf8JsonWriter writer, PointDto point)
{
writer.WriteStartObject();
writer.WriteNumber("x", point.X);
writer.WriteNumber("y", point.Y);
writer.WriteEndObject();
}
}
/// <summary>A plate's expected rectangular drop, with <see cref="Width"/> the shorter side.</summary>
+4 -33
View File
@@ -530,33 +530,6 @@ namespace OpenNest.IO
/// missing or invalid removal-minutes value falls back to <see cref="ClampRail.DefaultRemovalMinutes"/>.
/// </summary>
/// <summary>The saved support bed, or null when absent or invalid (a bad bed never fails the nest).</summary>
/// <summary>
/// The plate's sheet outline, or null (a rectangle) when the file has none or the value
/// is malformed or not a simple polygon; the latter adds a warning naming the plate.
/// </summary>
private SheetOutline ReadOutline(SheetOutlineDto dto, int plateId)
{
if (dto == null)
return null;
string problem = dto.Problem;
if (problem == null)
{
try
{
return new SheetOutline(
dto.Points.Select(p => new Vector(p.X, p.Y)),
dto.Obstacles?.Select(o => o.Select(p => new Vector(p.X, p.Y))));
}
catch (ArgumentException exception)
{
problem = exception.Message;
}
}
warnings.Add($"Plate {plateId}: its sheet outline could not be read ({problem}); "
+ "the plate loads as a rectangle of its saved size.");
return null;
}
private static SupportBed ReadSupportBed(SupportBedDto dto)
{
if (dto == null)
@@ -693,7 +666,6 @@ namespace OpenNest.IO
);
plate.GrainAngle = p.GrainAngle;
plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters);
plate.Outline = ReadOutline(p.Outline, p.Id);
// Clamp zones: older files have no key at all; an individually invalid
// entry is dropped rather than poisoning the whole nest.
@@ -747,8 +719,7 @@ namespace OpenNest.IO
// Cut-offs
if (p.CutOffs != null)
{
var places = new Dictionary<CutOff, IReadOnlyList<int>>();
var firstPlaces = new Dictionary<CutOff, int>();
var sequence = new Dictionary<CutOff, IReadOnlyList<int>>();
foreach (var cutoffDto in p.CutOffs)
{
@@ -768,12 +739,12 @@ namespace OpenNest.IO
plate.CutOffs.Add(cutoff);
if (cutoffDto.Segments is { Length: > 0 } segments)
places[cutoff] = segments;
sequence[cutoff] = segments;
else if (cutoffDto.Sequence is int index)
firstPlaces[cutoff] = index;
sequence[cutoff] = [index];
}
plate.RegenerateCutOffs(new CutOffSettings(), places, firstPlaces);
plate.RegenerateCutOffs(new CutOffSettings(), sequence);
}
nest.Plates.Add(plate);
+3 -18
View File
@@ -353,7 +353,6 @@ namespace OpenNest.IO
!KeepEmptyPlates
&& plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0
&& plate.CutOffs.Count == 0
&& plate.Outline == null
&& (
(plate.ClampZones.Count == 0 && plate.RemovedClamps.Count == 0)
|| DefaultsRecreateClamps(plate)
@@ -396,12 +395,12 @@ namespace OpenNest.IO
foreach (var cutoff in plate.CutOffs)
{
// Each trimmed segment is its own part with its own place in the sequence.
// Every place is written, so a reader regenerating a different number of
// segments still knows how many places the saved ones held.
var segments = CutOffSegments.Of(plate, cutoff)
.Select(segment => plate.Parts.IndexOf(segment.Part))
.Where(index => index >= 0)
.ToArray();
var consecutive = segments.Length == 0
|| segments.SequenceEqual(Enumerable.Range(segments.Min(), segments.Length));
cutoffs.Add(
new CutOffDto
@@ -412,7 +411,7 @@ namespace OpenNest.IO
StartLimit = cutoff.StartLimit,
EndLimit = cutoff.EndLimit,
Sequence = segments.Length > 0 ? segments.Min() : null,
Segments = segments.Length > 0 ? segments : null,
Segments = consecutive ? null : segments,
HeadLockOvercut = cutoff.HeadLockOvercut,
FarEdgeFirst = cutoff.FarEdgeFirst,
}
@@ -462,26 +461,12 @@ namespace OpenNest.IO
: ToDto(plate.ClampRail),
StockItemCodes = plate.StockItemCodes.Count == 0 ? null : plate.StockItemCodes.ToList(),
ExpectedRemnant = ToDto(PlateRemnant.Expected(plate, nest.MinimumRemnantDimension)),
Outline = ToDto(plate.Outline),
}
);
}
return list;
}
private static SheetOutlineDto ToDto(SheetOutline outline) =>
outline == null
? null
: new SheetOutlineDto
{
Points = outline.Points.Select(p => new PointDto { X = p.X, Y = p.Y }).ToList(),
Obstacles = outline.Obstacles.Count == 0
? null
: outline.Obstacles
.Select(o => o.Select(p => new PointDto { X = p.X, Y = p.Y }).ToList())
.ToList(),
};
private static ExpectedRemnantDto ToDto(PlateRemnant remnant) =>
remnant == null
? null
-8
View File
@@ -66,8 +66,6 @@ namespace OpenNest.Mcp.Tools
var strategy = ResolveStrategy(engine);
if (strategy == null)
return EngineError(engine);
if (PlateFillService.Refusal(plate) is { } refusal)
return $"Error: {refusal} Nothing committed.";
var countBefore = plate.Parts.Count;
var item = new NestItem { Drawing = drawing, Quantity = quantity };
@@ -123,8 +121,6 @@ namespace OpenNest.Mcp.Tools
var strategy = ResolveStrategy(engine);
if (strategy == null)
return EngineError(engine);
if (PlateFillService.Refusal(plate) is { } refusal)
return $"Error: {refusal} Nothing committed.";
var countBefore = plate.Parts.Count;
var item = new NestItem { Drawing = drawing, Quantity = quantity };
@@ -177,8 +173,6 @@ namespace OpenNest.Mcp.Tools
var strategy = ResolveStrategy(engine);
if (strategy == null)
return EngineError(engine);
if (PlateFillService.Refusal(plate) is { } refusal)
return $"Error: {refusal} Nothing committed.";
var finder = RemnantFinder.FromPlate(plate);
var remnants = finder.FindRemnants();
@@ -246,8 +240,6 @@ namespace OpenNest.Mcp.Tools
var strategy = ResolveStrategy(engine);
if (strategy == null)
return EngineError(engine);
if (PlateFillService.Refusal(plate) is { } refusal)
return $"Error: {refusal} Nothing committed.";
var parsed = ParseItems(drawingNames, quantities);
if (parsed.error != null)
@@ -22,7 +22,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
Assert.Equal(before, NestDigest(factory.DatabasePath));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -43,7 +43,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(before, NestDigest(factory.DatabasePath));
// Two distinct blobs across four revisions: the bracket's notes edit and the
// plate both reuse the bracket's first package.
@@ -80,7 +80,7 @@ public sealed class DrawingLibraryDatabaseTests
{
using var factory = new ServerFactory();
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
Execute(factory.DatabasePath, "PRAGMA user_version=15");
Execute(factory.DatabasePath, "PRAGMA user_version=14");
Assert.Throws<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
@@ -70,36 +70,6 @@ public sealed class DrawingLibraryQueryTests
Assert.Equal(page.Items.Count, page.Total);
}
[Theory]
[InlineData("materialName=Carbon%20Steel", "Any|Any thickness|Steel")]
[InlineData("materialName=Carbon%20Steel&materialGrade=A36&thickness=0.25", "Any|Any thickness|Steel")]
[InlineData("materialName=Stainless&thickness=0.25", "Any")]
[InlineData("materialName=Carbon%20Steel&thickness=0.1875", "Any|Any thickness")]
[InlineData("thickness=0", "Any|Any thickness")]
public async Task UnsetMaterialGradeOrThickness_MatchesAnyFilter(string query, string expected)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
foreach (var (name, material, grade, thickness) in new[]
{
("Any", "", "", 0.0),
("Any thickness", "Carbon Steel", "", 0.0),
("Steel", "Carbon Steel", "A36", 0.25),
})
{
var record = Record(name);
record.MaterialName = material;
record.MaterialGrade = grade;
record.Thickness = thickness;
await Upload(client, record, Package(name));
}
var page = await DrawingLibraryApiTests.Query(client, query + "&sort=name&order=asc");
Assert.Equal(expected, string.Join("|", page.Items.Select(r => r.Name)));
Assert.Equal(page.Items.Count, page.Total);
}
[Theory]
[InlineData("name", false)]
[InlineData("name", true)]
@@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items);
Assert.Equal(before, Digest(factory.DatabasePath));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests
Assert.Equal(before, Digest(factory.DatabasePath));
Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing");
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
[Fact]
+1 -1
View File
@@ -24,7 +24,7 @@ public sealed class MaterialListTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Empty(upgraded.ListMaterials());
Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade));
Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName);
@@ -74,7 +74,7 @@ public sealed class NestMaterialGradeTests
Assert.Equal("Stainless", record.Material);
Assert.Equal("", record.MaterialGrade);
Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade);
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, string material, string grade) => new()
@@ -1,200 +0,0 @@
using System.Net;
using Microsoft.Data.Sqlite;
using OpenNest.Data;
using OpenNest.Geometry;
namespace OpenNest.Server.Tests;
/// <summary>
/// <c>GET /api/nests/stock-item-codes</c> lists the ledger item codes of open nests from codes
/// stored at each archive write, so Auto Nest can leave them out before ML holds exist.
/// </summary>
public sealed class NestStockCodesTests
{
[Fact]
public async Task OpenNests_ListTheirCodesOncePerNest()
{
using var session = new Session();
var first = await session.SaveAsync("Nest A", NestStatus.Quote, ["SHEET-1", "SHEET-1"], ["DROP-7"]);
var second = await session.SaveAsync("Nest B", NestStatus.ToBeCut, ["SHEET-1"]);
await session.SaveAsync("Uncoded", NestStatus.Quote);
var uses = await session.Repository.GetStockCodesInUseAsync(Guid.Empty);
Assert.Equal(
[("DROP-7", first.Id, "Nest A"), ("SHEET-1", first.Id, "Nest A"), ("SHEET-1", second.Id, "Nest B")],
uses.Select(u => (u.ItemCode, u.NestId, u.NestName)).ToArray());
Assert.Equal(NestStatus.ToBeCut, uses[2].Status);
}
[Fact]
public async Task Exclude_LeavesOutTheNestBeingEdited()
{
using var session = new Session();
var editing = await session.SaveAsync("Editing", NestStatus.Quote, ["SHEET-1"]);
await session.SaveAsync("Other", NestStatus.Quote, ["SHEET-2"]);
var uses = await session.Repository.GetStockCodesInUseAsync(editing.Id);
Assert.Equal(["SHEET-2"], uses.Select(u => u.ItemCode));
}
[Fact]
public async Task CutAndDeletedNests_AreNotOpen()
{
using var session = new Session();
var draft = await session.SaveAsync("Draft", NestStatus.Draft, ["D-1"]);
var cut = await session.SaveAsync("Cut", NestStatus.Quote, ["C-1"]);
var deleted = await session.SaveAsync("Deleted", NestStatus.Quote, ["X-1"]);
cut.Status = NestStatus.HasBeenCut;
await session.Repository.UpdateMetadataAsync(cut.Id, cut, cut.Revision);
await session.Repository.DeleteAsync(deleted.Id, deleted.Revision);
Assert.Equal(["D-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
Assert.Equal(draft.Id, Assert.Single(await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).NestId);
// Recovering a nest brings its codes back without rereading the archive.
var gone = (await session.Repository.GetDeletedMetadataAsync(deleted.Id))!;
await session.Repository.RecoverAsync(deleted.Id, gone.Revision);
Assert.Equal(["D-1", "X-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
}
[Fact]
public async Task SavingAndRestoring_ReplaceTheStoredCodes()
{
using var session = new Session();
var record = await session.SaveAsync("Nest", NestStatus.Quote, ["OLD-1"]);
var nest = Nest("Nest", NestStatus.Quote, ["NEW-1"]);
var updated = await session.Repository.UpdateFileAsync(record.Id, NestApiTests.Archive(nest),
Record(record.Id, nest), record.Revision);
Assert.Equal(["NEW-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
await session.Repository.RestoreAsync(record.Id, 1, updated.Revision);
Assert.Equal(["OLD-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
}
[Fact]
public async Task InvalidExclude_IsABadRequest()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync("/api/nests/stock-item-codes?exclude=nope");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task OlderServer_IsNotSupported()
{
using var http = new HttpClient(new StatusHandler(HttpStatusCode.NotFound)) { BaseAddress = new Uri("http://old/") };
using var repository = new RemoteNestRepository(http, "http://old/");
await Assert.ThrowsAsync<NotSupportedException>(() => repository.GetStockCodesInUseAsync(Guid.Empty));
}
[Fact]
public void V13Upgrade_ReadsTheCodesOfEveryStoredNest()
{
using var factory = new ServerFactory();
var open = Guid.NewGuid();
var deleted = Guid.NewGuid();
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
var nest = Nest("Open", NestStatus.Quote, ["SHEET-1"]);
database.Insert(open, Record(open, nest), NestApiTests.Archive(nest));
var gone = Nest("Gone", NestStatus.Quote, ["SHEET-2"]);
var record = database.Insert(deleted, Record(deleted, gone), NestApiTests.Archive(gone));
database.SetDeleted(deleted, true, record.Revision);
}
Execute(factory.DatabasePath, "DROP TABLE nest_stock_codes; PRAGMA user_version=13;");
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
Assert.Equal(["SHEET-1"], database.StockCodesInUse(null).Select(u => u.ItemCode));
var record = database.Get(deleted, deleted: true)!;
database.SetDeleted(deleted, false, record.Revision);
Assert.Equal(["SHEET-1", "SHEET-2"], database.StockCodesInUse(null).Select(u => u.ItemCode));
}
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static Nest Nest(string name, NestStatus status, params string[][] plateCodes)
{
var nest = NestApiTests.SyntheticNest();
nest.Name = name;
nest.Status = status;
var drawing = nest.Drawings.Single();
nest.Plates.Clear();
foreach (var codes in plateCodes.DefaultIfEmpty(Array.Empty<string>()))
{
var plate = new Plate(new Size(48, 96)) { Quantity = System.Math.Max(codes.Length, 1) };
plate.Parts.Add(new Part(drawing, new Vector(2, 3)));
plate.StockItemCodes = codes;
nest.Plates.Add(plate);
}
return nest;
}
private static NestRecord Record(Guid id, Nest nest) => new()
{
Id = id,
Name = nest.Name,
Status = nest.Status,
DateCreated = nest.DateCreated,
DateModified = nest.DateCreated,
};
private static void Execute(string path, string sql)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
private static object? Scalar(string path, string sql)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
return command.ExecuteScalar();
}
private sealed class Session : IDisposable
{
private readonly ServerFactory factory = new();
private readonly HttpClient client;
public Session()
{
client = factory.CreateClient();
Repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
}
public RemoteNestRepository Repository { get; }
public Task<NestRecord> SaveAsync(string name, NestStatus status, params string[][] plateCodes)
{
var nest = Nest(name, status, plateCodes);
return Repository.UploadAsync(NestApiTests.Archive(nest), Record(Guid.NewGuid(), nest));
}
public void Dispose()
{
Repository.Dispose();
client.Dispose();
factory.Dispose();
}
}
private sealed class StatusHandler(HttpStatusCode status) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(status));
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ public sealed class NestUnitsTests
Assert.Equal([Units.Millimeters, Units.Millimeters], upgraded.History(metric, 0, 10)!.Items.Select(r => r.Units));
Assert.Equal(Units.Inches, upgraded.Get(inch)!.Units);
Assert.Equal(Units.Inches, upgraded.Get(unreadable)!.Units);
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, Units units) => new()
+1 -19
View File
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
namespace OpenNest.Server.Tests;
@@ -11,19 +10,15 @@ namespace OpenNest.Server.Tests;
/// Hosts the production routes in memory against a private, disposable SQLite file.
/// The production <see cref="NestDatabase"/> registration is replaced before anything
/// resolves it, so tests never open the default data/nests.db or an OPENNEST_DB path.
/// The update mirror never refreshes on its own and reaches GitHub only through
/// <c>updateHandler</c> (by default a handler that fails every request); tests refresh it explicitly.
/// Disposing the factory disposes the host (and its database) before deleting the directory.
/// </summary>
public sealed class ServerFactory : WebApplicationFactory<global::Program>
{
private bool _cleanedUp;
private NestDatabase? _database;
private readonly HttpMessageHandler? _updateHandler;
public ServerFactory(string? databasePath = null, HttpMessageHandler? updateHandler = null)
public ServerFactory(string? databasePath = null)
{
_updateHandler = updateHandler;
DirectoryPath = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(DirectoryPath);
DatabasePath = databasePath ?? Path.Combine(DirectoryPath, "nests.db");
@@ -41,13 +36,6 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
services.RemoveAll<NestDatabase>();
// Register an externally owned instance; this factory closes it before deleting its file.
services.AddSingleton(_database = new NestDatabase(DatabasePath, pooling: false));
services.RemoveAll<UpdateMirror>();
var hosted = services.Where(d => d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(UpdateMirrorService)).ToList();
foreach (var descriptor in hosted)
services.Remove(descriptor);
services.AddSingleton(_ => new UpdateMirror(
Path.Combine(DirectoryPath, "updates"), new HttpClient(_updateHandler ?? new OfflineHandler())));
});
}
@@ -62,10 +50,4 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
if (Directory.Exists(DirectoryPath))
Directory.Delete(DirectoryPath, recursive: true);
}
private sealed class OfflineHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
throw new HttpRequestException("Tests never reach GitHub.");
}
}
@@ -151,7 +151,7 @@ public sealed class SetReleaseDrawingTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
var kept = upgraded.GetRelease(releaseId)!;
Assert.Empty(kept.Drawings);
Assert.Equal(((Guid?)kit.Id, drawing, 2), (kept.Nests[0].Items.Single().SetId, kept.Nests[0].Items.Single().DrawingId, kept.Nests[0].Items.Single().Quantity));
+1 -1
View File
@@ -294,7 +294,7 @@ public sealed class SetReleaseTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'set_release%' OR name LIKE 'ix_set_release%'"));
var release = upgraded.GetRelease(releaseId)!;
Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone));
+1 -1
View File
@@ -67,7 +67,7 @@ public sealed class SharedSettingsTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Empty(upgraded.ListSharedSettings());
}
-305
View File
@@ -1,305 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
namespace OpenNest.Server.Tests;
/// <summary>
/// The desktop update feed the server mirrors from GitHub (<c>/updates/{channel}/</c>): which
/// release each channel serves, verified downloads, cleanup, restarts and the HTTP contract.
/// GitHub is a synthetic handler; nothing here reaches the network.
/// </summary>
public sealed class UpdateMirrorTests
{
private const string ReleasesUrl = "https://api.github.com/repos/ajisaacs/OpenNest/releases?per_page=10";
[Fact]
public async Task Refresh_MirrorsNothingUntilAClientAsksForAChannel()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
await mirror.RefreshAsync();
Assert.Empty(github.Requests);
Assert.NotNull(mirror.GetStatus().LastChecked);
}
[Fact]
public async Task Api_ServesTheStableFeedAndPackageAfterTheFirstRequestWakesARefresh()
{
var github = new FakeGitHub()
.WithRelease("dev-build", "0.7.0-dev.2020", prerelease: true)
.WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var factory = new ServerFactory(updateHandler: github);
using var client = factory.CreateClient();
var mirror = factory.Services.GetRequiredService<UpdateMirror>();
var woken = 0;
mirror.RefreshRequested += () => woken++;
// Velopack appends its own query; the route ignores it.
var before = await client.GetAsync("/updates/stable/releases.win.json?arch=x64&localVersion=0.6.0");
Assert.Equal(HttpStatusCode.ServiceUnavailable, before.StatusCode);
Assert.Equal(1, woken);
await mirror.RefreshAsync();
var feed = await client.GetFromJsonAsync<JsonElement>("/updates/stable/releases.win.json");
var asset = Assert.Single(feed.GetProperty("Assets").EnumerateArray().ToList());
Assert.Equal("0.6.1", asset.GetProperty("Version").GetString());
Assert.Equal("OpenNest-0.6.1-full.nupkg", asset.GetProperty("FileName").GetString());
Assert.Equal(FakeGitHub.Hash("0.6.1"), asset.GetProperty("SHA256").GetString());
Assert.Equal(FakeGitHub.Package("0.6.1"), await client.GetByteArrayAsync("/updates/stable/OpenNest-0.6.1-full.nupkg"));
// The development build is never fetched for a stable-only shop.
Assert.DoesNotContain(github.Requests, r => r.Contains("dev-build"));
Assert.Equal(1, github.Requests.Count(r => r.StartsWith("https://api.github.com/", StringComparison.Ordinal)));
var status = await client.GetFromJsonAsync<JsonElement>("/api/updates");
Assert.Equal("0.6.1", status.GetProperty("stable").GetString());
Assert.Equal(JsonValueKind.Null, status.GetProperty("dev").ValueKind);
Assert.Equal(JsonValueKind.Null, status.GetProperty("lastError").ValueKind);
}
[Fact]
public async Task Api_RefusesUnknownChannelsAndFilesTheFeedDoesNotName()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var factory = new ServerFactory(updateHandler: github);
using var client = factory.CreateClient();
var mirror = factory.Services.GetRequiredService<UpdateMirror>();
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/beta/releases.win.json")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/dev/OpenNest-0.6.1-full.nupkg")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/stable/OpenNest-0.6.0-full.nupkg")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/stable/..%2F..%2Fnests.db")).StatusCode);
}
[Fact]
public async Task Dev_ServesTheHigherOfTheDevelopmentBuildAndTheNewestRelease()
{
var github = new FakeGitHub()
.WithRelease("dev-build", "0.7.0-dev.2020", prerelease: true)
.WithRelease("v0.6.1", "0.6.1", prerelease: false)
.WithRelease("v0.5.1", null, prerelease: true); // Older pre-release with no feed.
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
mirror.GetFeed(UpdateMirror.Dev);
await mirror.RefreshAsync();
Assert.Equal("0.7.0-dev.2020", mirror.GetStatus().Dev);
// A full release published before the next development build outranks it.
github.Clear()
.WithRelease("v0.7.0", "0.7.0", prerelease: false)
.WithRelease("dev-build", "0.7.0-dev.2020", prerelease: true);
await mirror.RefreshAsync();
Assert.Equal("0.7.0", mirror.GetStatus().Dev);
Assert.Null(mirror.GetStatus().Stable);
Assert.Equal(new[] { "OpenNest-0.7.0-full.nupkg" }, PackageFiles(directory.Path));
}
[Fact]
public async Task Refresh_DownloadsEachPackageOnceAndDeletesSupersededOnes()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
await mirror.RefreshAsync();
Assert.Equal(1, github.Requests.Count(r => r.EndsWith(".nupkg", StringComparison.Ordinal)));
github.Clear().WithRelease("v0.6.2", "0.6.2", prerelease: false).WithRelease("v0.6.1", "0.6.1", prerelease: false);
await mirror.RefreshAsync();
Assert.Equal("0.6.2", mirror.GetStatus().Stable);
Assert.Equal(new[] { "OpenNest-0.6.2-full.nupkg" }, PackageFiles(directory.Path));
Assert.Null(mirror.GetPackagePath(UpdateMirror.Stable, "OpenNest-0.6.1-full.nupkg"));
}
[Fact]
public async Task Refresh_RejectsAPackageThatDoesNotMatchItsFeedAndKeepsTheCurrentOne()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
github.Clear().WithRelease("v0.6.2", "0.6.2", prerelease: false, corrupt: true);
await mirror.RefreshAsync();
var status = mirror.GetStatus();
Assert.Equal("0.6.1", status.Stable);
Assert.Contains("SHA-256", status.LastError);
Assert.Equal(new[] { "OpenNest-0.6.1-full.nupkg" }, PackageFiles(directory.Path));
}
[Fact]
public async Task Refresh_KeepsServingWhenGitHubIsUnreachable()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
github.Clear();
await mirror.RefreshAsync();
Assert.NotNull(mirror.GetStatus().LastError);
Assert.NotNull(mirror.GetFeed(UpdateMirror.Stable));
}
[Fact]
public async Task Restart_ServesTheSavedFeedAndKeepsRefreshingIt()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var first = new UpdateMirror(directory.Path, new HttpClient(github));
first.GetFeed(UpdateMirror.Stable);
await first.RefreshAsync();
github.Clear().WithRelease("v0.6.2", "0.6.2", prerelease: false);
var restarted = new UpdateMirror(directory.Path, new HttpClient(github));
Assert.Equal("0.6.1", restarted.GetStatus().Stable);
Assert.NotNull(restarted.GetPackagePath(UpdateMirror.Stable, "OpenNest-0.6.1-full.nupkg"));
await restarted.RefreshAsync();
Assert.Equal("0.6.2", restarted.GetStatus().Stable);
}
[Fact]
public async Task Refresh_StopsMirroringAChannelNobodyHasAskedForInAWeek()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var now = DateTimeOffset.Parse("2026-10-28T12:00:00Z");
var mirror = new UpdateMirror(directory.Path, new HttpClient(github), clock: () => now);
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
var requests = github.Requests.Count;
now += UpdateMirror.WantedFor + TimeSpan.FromMinutes(1);
await mirror.RefreshAsync();
Assert.Equal(requests, github.Requests.Count);
}
[Theory]
[InlineData("0.7.0", "0.6.1", 1)]
[InlineData("0.7.0-dev.2020", "0.6.1", 1)]
[InlineData("0.7.0", "0.7.0-dev.2020", 1)]
[InlineData("0.7.0-dev.10", "0.7.0-dev.9", 1)]
[InlineData("0.7.0-dev.9", "0.7.0-dev", 1)]
[InlineData("0.7.0-beta", "0.7.0-alpha", 1)]
[InlineData("0.7.0-alpha", "0.7.0-1", 1)]
[InlineData("0.7.0+abc", "0.7.0", 0)]
[InlineData("0.10.0", "0.9.0", 1)]
[InlineData("0.6.1", "not-a-version", 1)]
public void SemanticVersion_FollowsSemVerPrecedence(string higher, string lower, int expected)
{
Assert.Equal(expected, SemanticVersion.Compare(higher, lower));
Assert.Equal(-expected, SemanticVersion.Compare(lower, higher));
}
[Theory]
[InlineData(null, true)]
[InlineData("", true)]
[InlineData("on", true)]
[InlineData("off", false)]
[InlineData(" OFF ", false)]
public void Options_UpdatesAreOnUnlessTurnedOff(string? value, bool expected) =>
Assert.Equal(expected, ServerOptions.UpdatesEnabled(value));
private static string[] PackageFiles(string directory) =>
Directory.GetFiles(Path.Combine(directory, "packages")).Select(Path.GetFileName).OfType<string>().ToArray();
private sealed class TempDirectory : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "opennest-updates-" + Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(Path))
Directory.Delete(Path, recursive: true);
}
}
/// <summary>The GitHub release list, feeds and packages for synthetic versions, newest release first.</summary>
private sealed class FakeGitHub : HttpMessageHandler
{
private readonly List<object> _releases = new();
private readonly Dictionary<string, byte[]> _files = new(StringComparer.Ordinal);
public List<string> Requests { get; } = new();
public static byte[] Package(string version) => Encoding.UTF8.GetBytes($"package {version}");
public static string Hash(string version) => Convert.ToHexString(SHA256.HashData(Package(version)));
public FakeGitHub Clear()
{
_releases.Clear();
_files.Clear();
return this;
}
/// <param name="version">The feed's version, or null for a release without a Velopack feed.</param>
public FakeGitHub WithRelease(string tag, string? version, bool prerelease, bool corrupt = false)
{
var assets = new List<object>();
if (version is not null)
{
var package = $"OpenNest-{version}-full.nupkg";
var feed = JsonSerializer.Serialize(new
{
Assets = new[]
{
new { PackageId = "OpenNest", Version = version, Type = "Full", FileName = package, SHA1 = "00", SHA256 = Hash(version), Size = (long)Package(version).Length },
},
});
assets.Add(Asset(tag, "releases.win.json", Encoding.UTF8.GetBytes(feed)));
var bytes = Package(version);
assets.Add(Asset(tag, package, corrupt ? bytes.Select(b => (byte)(b ^ 1)).ToArray() : bytes));
}
assets.Add(Asset(tag, $"OpenNest.{tag}.win-x64.zip", new byte[] { 1 }));
_releases.Add(new { tag_name = tag, draft = false, prerelease, assets });
return this;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
Requests.Add(url);
if (url == ReleasesUrl)
{
Assert.Contains("OpenNest-Server", request.Headers.UserAgent.ToString());
if (_releases.Count == 0)
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden));
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(_releases) });
}
return Task.FromResult(_files.TryGetValue(url, out var bytes)
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }
: new HttpResponseMessage(HttpStatusCode.NotFound));
}
private object Asset(string tag, string name, byte[] contents)
{
var url = $"https://github.com/ajisaacs/OpenNest/releases/download/{tag}/{name}";
_files[url] = contents;
return new { name, browser_download_url = url };
}
}
}
+5 -22
View File
@@ -87,6 +87,7 @@ internal static class NestArchive
.OrderBy(p => p.Name, StringComparer.Ordinal)
.ToList();
// NestReader: codes end at the first blank entry and at the plate's quantity.
var platesOut = plates
.Select((p, index) =>
{
@@ -97,7 +98,10 @@ internal static class NestArchive
Width = System.Math.Min(size.Width, size.Length),
Length = System.Math.Max(size.Width, size.Length),
Quantity = p.Quantity,
StockItemCodes = PlateCodes(p).ToList(),
StockItemCodes = (p.StockItemCodes ?? new())
.TakeWhile(code => !string.IsNullOrWhiteSpace(code))
.Take(System.Math.Max(p.Quantity, 0))
.ToList(),
ExpectedRemnant = ReadRemnant(p.ExpectedRemnant),
};
})
@@ -117,27 +121,6 @@ internal static class NestArchive
return null;
}
/// <summary>
/// The distinct ledger item codes the archive's plates are coded with, in plate order, by
/// the same rules as <see cref="ReadSheets"/>; empty when the bytes are unreadable.
/// </summary>
public static IReadOnlyList<string> ReadStockItemCodes(byte[] file)
{
if (Read(file, out var nest) is not null)
return Array.Empty<string>();
var drawingIds = (nest.Drawings ?? new()).Select(d => d.Id).ToHashSet();
return NestPlates(nest, drawingIds)
.SelectMany(p => PlateCodes(p))
.Distinct(StringComparer.Ordinal)
.ToList();
}
// NestReader: codes end at the first blank entry and at the plate's quantity.
private static IEnumerable<string> PlateCodes(PlateJson plate) =>
(plate.StockItemCodes ?? new())
.TakeWhile(code => !string.IsNullOrWhiteSpace(code))
.Take(System.Math.Max(plate.Quantity, 0));
/// <summary>The archive's units, for backfilling records stored before they were kept; inches when unreadable.</summary>
public static Units ReadUnits(byte[] file) => Read(file, out var nest) is null ? UnitsOf(nest) : Units.Inches;
+3 -4
View File
@@ -138,10 +138,9 @@ public sealed partial class NestDatabase
if (!query.IncludeArchived) conditions.Add("archived=0");
if (query.NormalizedSearch.Length > 0) conditions.Add("(" + DrawingSearchPredicate + ")");
if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("customerKey=$customer");
// A drawing saved without a material, grade or thickness (0) suits any, so it matches every filter value.
if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("(materialKey=$material OR materialKey='')");
if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("(gradeKey=$grade OR gradeKey='')");
if (query.Thickness is not null) conditions.Add("(thickness=$thickness OR thickness=0)");
if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("materialKey=$material");
if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("gradeKey=$grade");
if (query.Thickness is not null) conditions.Add("thickness=$thickness");
if (query.CutType is not null) conditions.Add("cutType=$cutType");
if (query.CutFirst is not null) conditions.Add("cutFirst=$cutFirst");
var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions);
@@ -1,94 +0,0 @@
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server;
// The ledger item codes each nest's current archive is coded with, kept beside the record at
// every archive write so listing the codes in use never reopens an archive.
public sealed partial class NestDatabase
{
// v14: one row per distinct code of a nest's current archive.
private const string StockCodeSchema = """
CREATE TABLE IF NOT EXISTS nest_stock_codes (
id TEXT NOT NULL,
code TEXT NOT NULL,
PRIMARY KEY (id, code)
);
CREATE INDEX IF NOT EXISTS ix_nest_stock_codes_code ON nest_stock_codes(code);
""";
/// <summary>
/// The item codes on the current archives of every non-deleted Draft, Quote or To Be Cut
/// nest except <paramref name="excludeId"/>, ordered by code then nest name.
/// </summary>
public IReadOnlyList<NestStockCodeUse> StockCodesInUse(Guid? excludeId)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = """
SELECT c.code, n.id, n.name, n.status FROM nest_stock_codes c
JOIN nests n ON n.id = c.id
WHERE n.deleted = 0 AND n.status IN ('Draft', 'Quote', 'ToBeCut') AND n.id <> $exclude
ORDER BY c.code, n.name COLLATE NOCASE, n.id
""";
command.Parameters.AddWithValue("$exclude", (excludeId ?? Guid.Empty).ToString());
var uses = new List<NestStockCodeUse>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
uses.Add(new NestStockCodeUse
{
ItemCode = reader.GetString(0),
NestId = Guid.Parse(reader.GetString(1)),
NestName = reader.GetString(2),
Status = Enum.TryParse<NestStatus>(reader.GetString(3), true, out var status) ? status : NestStatus.Quote,
});
}
return uses;
}
}
// Called only under _sync with the caller's transaction, whenever a nest's current archive changes.
private void WriteStockCodes(Guid id, byte[] file, SqliteTransaction transaction)
{
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "DELETE FROM nest_stock_codes WHERE id = $id";
command.Parameters.AddWithValue("$id", id.ToString());
command.ExecuteNonQuery();
InsertStockCodes(command, id, NestArchive.ReadStockItemCodes(file));
}
private static void InsertStockCodes(SqliteCommand command, Guid id, IEnumerable<string> codes)
{
command.CommandText = "INSERT OR IGNORE INTO nest_stock_codes (id, code) VALUES ($id, $code)";
command.Parameters.Clear();
command.Parameters.AddWithValue("$id", id.ToString());
var code = command.Parameters.Add("$code", SqliteType.Text);
foreach (var value in codes)
{
code.Value = value;
command.ExecuteNonQuery();
}
command.Parameters.Clear();
}
// v14: reads each current archive once, deleted ones too so a recovered nest keeps its codes.
private static void AddStockCodes(SqliteCommand command)
{
command.CommandText = StockCodeSchema;
command.ExecuteNonQuery();
var codes = new List<(Guid Id, IReadOnlyList<string> Codes)>();
command.CommandText = "SELECT id, file FROM nests";
using (var reader = command.ExecuteReader())
{
while (reader.Read())
codes.Add((Guid.Parse(reader.GetString(0)), NestArchive.ReadStockItemCodes((byte[])reader.GetValue(1))));
}
foreach (var (id, nestCodes) in codes.Where(c => c.Codes.Count > 0))
InsertStockCodes(command, id, nestCodes);
command.CommandText = "PRAGMA user_version=14";
command.ExecuteNonQuery();
}
}
+1 -5
View File
@@ -162,7 +162,6 @@ public sealed partial class NestDatabase : IDisposable
""";
AddRecordParameters(command, id, record, file, updateFile: true);
command.ExecuteNonQuery();
WriteStockCodes(id, file, transaction);
Snapshot(id, transaction);
transaction.Commit();
return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
@@ -221,7 +220,6 @@ public sealed partial class NestDatabase : IDisposable
command.Parameters.AddWithValue("$revision", checked(currentRevision + 1));
command.Parameters.AddWithValue("$source", (object?)sourceRevision ?? DBNull.Value);
command.ExecuteNonQuery();
WriteStockCodes(id, file, transaction);
Snapshot(id, transaction);
}
@@ -344,7 +342,7 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
if (version > 14) throw new InvalidOperationException("Database schema is newer than this server.");
if (version > 13) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -437,8 +435,6 @@ public sealed partial class NestDatabase : IDisposable
command.CommandText = SharedSettingSchema;
command.ExecuteNonQuery();
}
if (version < 14)
AddStockCodes(command);
transaction.Commit();
}
}
-15
View File
@@ -24,14 +24,6 @@ builder.Services.ConfigureHttpJsonOptions(options =>
var databasePath = ServerOptions.DatabasePath(args, Environment.GetEnvironmentVariable(ServerOptions.DatabaseVariable));
builder.Services.AddSingleton(_ => new NestDatabase(databasePath));
// Desktop update feed mirrored from GitHub beside the database; OPENNEST_UPDATES=off turns it off.
var updatesEnabled = ServerOptions.UpdatesEnabled(Environment.GetEnvironmentVariable(ServerOptions.UpdatesVariable));
if (updatesEnabled)
{
builder.Services.AddSingleton(_ => new UpdateMirror(ServerOptions.UpdatesPath(databasePath), new HttpClient()));
builder.Services.AddHostedService<UpdateMirrorService>();
}
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
var app = builder.Build();
@@ -67,11 +59,6 @@ app.MapGet("/api/nests/{id:guid}/sheets", (Guid id, NestDatabase db) =>
: Results.Ok(sheets);
});
// Ledger item codes on open nests' plates, so Auto Nest leaves them out until ML holds them.
// The nest being edited passes its own id as exclude.
app.MapGet("/api/nests/stock-item-codes", (Guid? exclude, NestDatabase db) =>
Results.Ok(db.StockCodesInUse(exclude)));
// Next generated nest name for a year; one counter per year shared by every client.
app.MapPost("/api/nest-names/next", (int year, NestDatabase db) =>
{
@@ -167,8 +154,6 @@ app.MapDrawingSets();
app.MapReleases();
app.MapMaterials();
app.MapSettings();
if (updatesEnabled)
app.MapUpdates();
app.Run();
-12
View File
@@ -4,18 +4,6 @@ namespace OpenNest.Server;
public static class ServerOptions
{
public const string DatabaseVariable = "OPENNEST_DB";
public const string UpdatesVariable = "OPENNEST_UPDATES";
/// <summary>
/// True unless <c>OPENNEST_UPDATES</c> is <c>off</c>: the server mirrors the desktop
/// app's update feed from GitHub (see <see cref="UpdateMirror"/>).
/// </summary>
public static bool UpdatesEnabled(string? value) =>
!string.Equals(value?.Trim(), "off", StringComparison.OrdinalIgnoreCase);
/// <summary>The mirrored update feed lives in an <c>updates</c> directory beside the database.</summary>
public static string UpdatesPath(string databasePath) =>
Path.Combine(Path.GetDirectoryName(Path.GetFullPath(databasePath))!, "updates");
/// <summary>
/// The database path: <c>--database=&lt;path&gt;</c> or <c>--database &lt;path&gt;</c>, then
-421
View File
@@ -1,421 +0,0 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenNest.Server;
/// <summary>
/// A caching mirror of the desktop app's Velopack feed, so the workstations of one shop check
/// for updates against their nest server instead of each calling GitHub. One refresh makes a
/// single GitHub API request (the release list), reads the feeds attached to the chosen releases
/// and downloads each channel's newest full package once, verified against the feed's size and
/// SHA-256, into <see cref="DirectoryPath"/>.
/// </summary>
/// <remarks>
/// Channels: <c>stable</c> serves the newest full release; <c>dev</c> serves whichever is higher
/// of that and the newest pre-release with a feed (the rolling development build). A channel is
/// mirrored only while clients ask for it (within <see cref="WantedFor"/>), so a server whose
/// workstations never take development builds never downloads one. Feeds are served with the
/// package file names Velopack wrote, unchanged: clients download <c>{feed URL}/{FileName}</c>.
/// </remarks>
public sealed class UpdateMirror
{
public const string Stable = "stable";
public const string Dev = "dev";
public const string FeedFileName = "releases.win.json";
public const string DefaultRepository = "ajisaacs/OpenNest";
/// <summary>How long a channel stays mirrored after the last client asked for it.</summary>
public static readonly TimeSpan WantedFor = TimeSpan.FromDays(7);
private static readonly string[] Channels = { Stable, Dev };
private static readonly JsonSerializerOptions FeedOptions = new() { PropertyNameCaseInsensitive = true };
private readonly HttpClient _http;
private readonly string _repository;
private readonly Func<DateTimeOffset> _clock;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private readonly object _stateLock = new();
private readonly Dictionary<string, ChannelFeed> _feeds = new(StringComparer.Ordinal);
private readonly Dictionary<string, DateTimeOffset> _wanted = new(StringComparer.Ordinal);
private DateTimeOffset? _lastChecked;
private string? _lastError;
public UpdateMirror(string directoryPath, HttpClient http, string repository = DefaultRepository, Func<DateTimeOffset>? clock = null)
{
DirectoryPath = directoryPath;
_http = http;
_repository = repository;
_clock = clock ?? (() => DateTimeOffset.UtcNow);
Directory.CreateDirectory(PackagesPath);
LoadSavedFeeds();
}
/// <summary>Raised when a client asks for a channel that holds no feed yet, so the next refresh need not wait.</summary>
public event Action? RefreshRequested;
public string DirectoryPath { get; }
private string PackagesPath => Path.Combine(DirectoryPath, "packages");
public static bool IsChannel(string channel) => Array.IndexOf(Channels, channel) >= 0;
/// <summary>
/// The channel's feed for a client, or null while nothing is mirrored for it (the route answers
/// 503 and the client falls back to GitHub). Every call marks the channel as wanted.
/// </summary>
public byte[]? GetFeed(string channel)
{
bool missing;
byte[]? feed;
lock (_stateLock)
{
_wanted[channel] = _clock();
feed = _feeds.TryGetValue(channel, out var current) ? current.Json : null;
missing = feed is null;
}
if (missing)
RefreshRequested?.Invoke();
return feed;
}
/// <summary>The path of a package a channel's feed currently names, or null for any other file name.</summary>
public string? GetPackagePath(string channel, string fileName)
{
lock (_stateLock)
{
if (!_feeds.TryGetValue(channel, out var feed) || feed.FileName != fileName)
return null;
}
var path = Path.Combine(PackagesPath, fileName);
return File.Exists(path) ? path : null;
}
public UpdateMirrorStatus GetStatus()
{
lock (_stateLock)
{
return new UpdateMirrorStatus
{
Stable = _feeds.TryGetValue(Stable, out var stable) ? stable.Version : null,
Dev = _feeds.TryGetValue(Dev, out var dev) ? dev.Version : null,
LastChecked = _lastChecked,
LastError = _lastError,
};
}
}
/// <summary>
/// Brings every wanted channel up to date with GitHub. Failures are recorded in
/// <see cref="GetStatus"/> and leave the feeds already mirrored in place.
/// </summary>
public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
await _refreshLock.WaitAsync(cancellationToken);
try
{
var wanted = WantedChannels();
if (wanted.Count > 0)
await RefreshChannelsAsync(wanted, cancellationToken);
lock (_stateLock)
{
_lastChecked = _clock();
_lastError = null;
}
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
lock (_stateLock)
{
_lastChecked = _clock();
_lastError = ex.Message;
}
}
finally
{
_refreshLock.Release();
}
}
private List<string> WantedChannels()
{
var now = _clock();
lock (_stateLock)
return Channels.Where(c => _wanted.TryGetValue(c, out var at) && now - at <= WantedFor).ToList();
}
private async Task RefreshChannelsAsync(List<string> channels, CancellationToken cancellationToken)
{
var releases = await GetJsonAsync<List<GitHubRelease>>(
$"https://api.github.com/repos/{_repository}/releases?per_page=10", cancellationToken) ?? new();
var published = releases.Where(r => !r.Draft && FeedAsset(r) is not null).ToList();
// The API lists the newest releases first.
var stable = published.FirstOrDefault(r => !r.Prerelease);
var preRelease = published.FirstOrDefault(r => r.Prerelease);
var feeds = new Dictionary<GitHubRelease, Candidate?>();
foreach (var channel in channels)
{
var sources = channel == Stable ? new[] { stable } : new[] { stable, preRelease };
Candidate? best = null;
foreach (var release in sources.OfType<GitHubRelease>())
{
if (!feeds.TryGetValue(release, out var candidate))
feeds[release] = candidate = await ReadCandidateAsync(release, cancellationToken);
if (candidate is not null && (best is null || SemanticVersion.Compare(candidate.Asset.Version, best.Asset.Version) > 0))
best = candidate;
}
if (best is not null)
await PublishAsync(channel, best, cancellationToken);
}
DeleteUnreferencedPackages();
}
private async Task<Candidate?> ReadCandidateAsync(GitHubRelease release, CancellationToken cancellationToken)
{
var feed = await GetJsonAsync<VelopackFeed>(FeedAsset(release)!.DownloadUrl, cancellationToken);
var full = feed?.Assets?
.Where(a => a.Type == "Full" && IsPlainFileName(a.FileName) && SemanticVersion.IsValid(a.Version))
.OrderByDescending(a => a.Version, SemanticVersion.Comparer)
.FirstOrDefault();
var package = full is null ? null : release.Assets.FirstOrDefault(a => a.Name == full.FileName);
return package is null ? null : new Candidate(full!, package.DownloadUrl);
}
private async Task PublishAsync(string channel, Candidate candidate, CancellationToken cancellationToken)
{
var asset = candidate.Asset;
var packagePath = Path.Combine(PackagesPath, asset.FileName);
if (!File.Exists(packagePath))
await DownloadPackageAsync(candidate, packagePath, cancellationToken);
// Serve only the chosen package, as the feed Velopack wrote describes it.
var json = JsonSerializer.SerializeToUtf8Bytes(new { Assets = new[] { asset } });
var feedPath = Path.Combine(DirectoryPath, channel, FeedFileName);
Directory.CreateDirectory(Path.GetDirectoryName(feedPath)!);
WriteAtomically(feedPath, json);
lock (_stateLock)
_feeds[channel] = new ChannelFeed(json, asset.FileName, asset.Version);
}
private async Task DownloadPackageAsync(Candidate candidate, string packagePath, CancellationToken cancellationToken)
{
var partial = packagePath + ".partial";
try
{
using (var response = await _http.GetAsync(candidate.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
{
response.EnsureSuccessStatusCode();
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var target = File.Create(partial);
await source.CopyToAsync(target, cancellationToken);
}
var asset = candidate.Asset;
var length = new FileInfo(partial).Length;
if (asset.Size > 0 && length != asset.Size)
throw new InvalidDataException($"{asset.FileName} is {length} bytes; its feed says {asset.Size}.");
if (!string.IsNullOrEmpty(asset.SHA256))
{
await using var stream = File.OpenRead(partial);
var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken));
if (!hash.Equals(asset.SHA256, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"{asset.FileName} does not match its feed's SHA-256.");
}
File.Move(partial, packagePath, overwrite: true);
}
finally
{
if (File.Exists(partial))
File.Delete(partial);
}
}
private void DeleteUnreferencedPackages()
{
HashSet<string> keep;
lock (_stateLock)
keep = _feeds.Values.Select(f => f.FileName).ToHashSet(StringComparer.Ordinal);
foreach (var path in Directory.EnumerateFiles(PackagesPath))
{
if (keep.Contains(Path.GetFileName(path)))
continue;
try { File.Delete(path); }
catch (IOException) { } // Still being served; the next refresh retries.
catch (UnauthorizedAccessException) { }
}
}
private void LoadSavedFeeds()
{
foreach (var channel in Channels)
{
var path = Path.Combine(DirectoryPath, channel, FeedFileName);
if (!File.Exists(path))
continue;
try
{
var json = File.ReadAllBytes(path);
var asset = JsonSerializer.Deserialize<VelopackFeed>(json, FeedOptions)?.Assets?.SingleOrDefault();
if (asset is null || !IsPlainFileName(asset.FileName) || !File.Exists(Path.Combine(PackagesPath, asset.FileName)))
continue;
_feeds[channel] = new ChannelFeed(json, asset.FileName, asset.Version);
// A restart keeps serving what clients were already taking.
_wanted[channel] = _clock();
}
catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException)
{
// An unreadable feed is rebuilt by the next refresh.
}
}
}
private async Task<T?> GetJsonAsync<T>(string url, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd("OpenNest-Server");
if (url.StartsWith("https://api.github.com/", StringComparison.Ordinal))
request.Headers.Accept.ParseAdd("application/vnd.github+json");
using var response = await _http.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonSerializer.DeserializeAsync<T>(stream, FeedOptions, cancellationToken);
}
private static GitHubAsset? FeedAsset(GitHubRelease release) =>
release.Assets.FirstOrDefault(a => a.Name == FeedFileName);
// Feed file names become paths under the packages directory and URL segments.
private static bool IsPlainFileName(string? name) =>
!string.IsNullOrEmpty(name) && name == Path.GetFileName(name) && name.IndexOfAny(Path.GetInvalidFileNameChars()) < 0
&& name.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase);
private static void WriteAtomically(string path, byte[] contents)
{
var temporary = path + ".tmp";
File.WriteAllBytes(temporary, contents);
File.Move(temporary, path, overwrite: true);
}
private sealed record ChannelFeed(byte[] Json, string FileName, string Version);
private sealed record Candidate(VelopackFeedAsset Asset, string DownloadUrl);
private sealed class GitHubRelease
{
[JsonPropertyName("draft")] public bool Draft { get; set; }
[JsonPropertyName("prerelease")] public bool Prerelease { get; set; }
[JsonPropertyName("assets")] public List<GitHubAsset> Assets { get; set; } = new();
}
private sealed class GitHubAsset
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("browser_download_url")] public string DownloadUrl { get; set; } = "";
}
private sealed class VelopackFeed
{
public List<VelopackFeedAsset>? Assets { get; set; }
}
}
/// <summary>One entry of a Velopack <c>releases.{channel}.json</c>; properties keep Velopack's names.</summary>
public sealed class VelopackFeedAsset
{
public string PackageId { get; set; } = "";
public string Version { get; set; } = "";
public string Type { get; set; } = "";
public string FileName { get; set; } = "";
public string? SHA1 { get; set; }
public string? SHA256 { get; set; }
public long Size { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NotesMarkdown { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NotesHTML { get; set; }
}
/// <summary><c>GET /api/updates</c>: the versions each channel serves and the last refresh's outcome.</summary>
public sealed class UpdateMirrorStatus
{
public string? Stable { get; init; }
public string? Dev { get; init; }
public DateTimeOffset? LastChecked { get; init; }
public string? LastError { get; init; }
}
/// <summary>SemVer 2.0 precedence for feed versions such as <c>0.7.0-dev.2020</c>; build metadata is ignored.</summary>
public static class SemanticVersion
{
public static readonly IComparer<string> Comparer = Comparer<string>.Create(Compare);
public static bool IsValid(string? version) => version is not null && TryParse(version, out _, out _);
public static int Compare(string? left, string? right)
{
if (!TryParse(left, out var leftCore, out var leftPre))
return TryParse(right, out _, out _) ? -1 : 0;
if (!TryParse(right, out var rightCore, out var rightPre))
return 1;
for (var i = 0; i < 3; i++)
{
var core = leftCore[i].CompareTo(rightCore[i]);
if (core != 0)
return core;
}
// A release outranks its pre-releases.
if (leftPre.Length == 0 || rightPre.Length == 0)
return (leftPre.Length == 0).CompareTo(rightPre.Length == 0);
for (var i = 0; i < System.Math.Min(leftPre.Length, rightPre.Length); i++)
{
var leftNumeric = long.TryParse(leftPre[i], out var leftNumber);
var rightNumeric = long.TryParse(rightPre[i], out var rightNumber);
var part = (leftNumeric, rightNumeric) switch
{
(true, true) => leftNumber.CompareTo(rightNumber),
(true, false) => -1,
(false, true) => 1,
_ => string.CompareOrdinal(leftPre[i], rightPre[i]),
};
if (part != 0)
return part;
}
return leftPre.Length.CompareTo(rightPre.Length);
}
private static bool TryParse(string? version, out long[] core, out string[] preRelease)
{
core = Array.Empty<long>();
preRelease = Array.Empty<string>();
if (string.IsNullOrEmpty(version))
return false;
var withoutMetadata = version.Split('+')[0];
var dash = withoutMetadata.IndexOf('-');
var numbers = (dash < 0 ? withoutMetadata : withoutMetadata[..dash]).Split('.');
if (numbers.Length != 3)
return false;
core = new long[3];
for (var i = 0; i < 3; i++)
{
if (!long.TryParse(numbers[i], out core[i]) || core[i] < 0)
return false;
}
preRelease = dash < 0 ? Array.Empty<string>() : withoutMetadata[(dash + 1)..].Split('.');
return preRelease.All(p => p.Length > 0);
}
}
-41
View File
@@ -1,41 +0,0 @@
namespace OpenNest.Server;
/// <summary>
/// Refreshes the <see cref="UpdateMirror"/> every <see cref="Interval"/>, and at once when a
/// client asks for a channel the mirror does not hold yet.
/// </summary>
public sealed class UpdateMirrorService : BackgroundService
{
public static readonly TimeSpan Interval = TimeSpan.FromMinutes(30);
private readonly UpdateMirror _mirror;
private readonly SemaphoreSlim _wake = new(0, 1);
public UpdateMirrorService(UpdateMirror mirror)
{
_mirror = mirror;
_mirror.RefreshRequested += Wake;
}
public override void Dispose()
{
_mirror.RefreshRequested -= Wake;
base.Dispose();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await _mirror.RefreshAsync(stoppingToken);
try { await _wake.WaitAsync(Interval, stoppingToken); }
catch (OperationCanceledException) { return; }
}
}
private void Wake()
{
try { _wake.Release(); }
catch (SemaphoreFullException) { } // A wake is already pending.
}
}
-32
View File
@@ -1,32 +0,0 @@
namespace OpenNest.Server;
/// <summary>
/// <c>/updates/{channel}/</c>: the desktop app's Velopack feed, mirrored from GitHub by
/// <see cref="UpdateMirror"/>. Velopack's <c>SimpleWebSource</c> reads
/// <c>releases.win.json</c> (ignoring the query it appends) and then the package it names. A
/// channel with nothing mirrored yet answers 503 so clients fall back to GitHub for that check.
/// </summary>
internal static class UpdateRoutes
{
public static void MapUpdates(this IEndpointRouteBuilder app)
{
app.MapGet("/api/updates", (UpdateMirror mirror) => Results.Ok(mirror.GetStatus()));
app.MapGet("/updates/{channel}/{file}", (string channel, string file, UpdateMirror mirror) =>
{
if (!UpdateMirror.IsChannel(channel))
return Results.NotFound();
if (file == UpdateMirror.FeedFileName)
{
return mirror.GetFeed(channel) is { } feed
? Results.Bytes(feed, "application/json")
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
return mirror.GetPackagePath(channel, file) is { } path
? Results.File(path, "application/octet-stream", enableRangeProcessing: true)
: Results.NotFound();
});
}
}
-4
View File
@@ -11,7 +11,3 @@ OPENNEST_HOST_PORT=8090
# Docker volume holding nests.db; change only to switch to a verified restore.
OPENNEST_DATA_VOLUME=opennest-data
# Desktop update mirror: the server checks GitHub Releases (outbound HTTPS to api.github.com
# and github.com) and serves updates to Database-mode workstations. "off" turns it off.
OPENNEST_UPDATES=on
@@ -1,108 +0,0 @@
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Posts.Cincinnati;
using OpenNest.Posts.CincinnatiCIFiber;
namespace OpenNest.Tests.Cincinnati;
/// <summary>
/// Both Cincinnati posts raise the head before a rapid Plan Cutting marked to pass over parts
/// already cut, whatever the operator's head-raise settings; the CI Fiber post refuses the nest
/// while its head-raise block is not set.
/// </summary>
public class HeadRaisePostTests
{
private const string RaiseCode = "M210 (RAISE HEAD)";
[Theory]
[InlineData(M47Mode.None)]
[InlineData(M47Mode.BlockDelete)]
[InlineData(M47Mode.Auto)]
public void ClPost_RaisesBeforeAMarkedRapid_WhateverTheM47Mode(M47Mode mode)
{
var lines = ClLines(Nest(markSecond: true), mode);
var second = lines.FindIndex(l => l.Contains("G0 X10.75 Y1"));
Assert.True(second > 0, string.Join("\n", lines));
Assert.Equal("M47", lines[second - 1]);
}
[Fact]
public void ClPost_WithoutAMarker_FollowsTheM47Mode()
{
var lines = ClLines(Nest(markSecond: false), M47Mode.None);
var second = lines.FindIndex(l => l.Contains("G0 X10.75 Y1"));
Assert.True(second > 0, string.Join("\n", lines));
Assert.DoesNotContain("M47", lines[second - 1]);
}
[Fact]
public void CiFiberPost_WritesTheRaiseBeforeOnlyTheMarkedRapid()
{
var post = new CIFiberPostProcessor(new CIFiberPostConfig { HeadRaiseCode = RaiseCode });
using var stream = new MemoryStream();
post.Post(Nest(markSecond: true), stream);
var lines = Lines(stream);
var raise = Assert.Single(lines, l => l == RaiseCode);
Assert.Equal("G0X10.75Y1", lines[lines.IndexOf(raise) + 1]);
}
[Fact]
public void CiFiberPost_WithoutARaiseCode_RefusesAMarkedNest()
{
var post = new CIFiberPostProcessor(new CIFiberPostConfig());
var refusal = Assert.Throws<InvalidOperationException>(() => post.Post(Nest(markSecond: true), new MemoryStream()));
Assert.Contains("head-raise code", refusal.Message);
post.Post(Nest(markSecond: false), new MemoryStream()); // Nothing marked: nothing to refuse.
}
// Two squares side by side, each with a line lead-in on its left; the second one's rapid marked.
private static Nest Nest(bool markSecond)
{
var nest = new Nest("RAISE");
var plate = new Plate(60, 120);
nest.Plates.Add(plate);
foreach (var x in new[] { 1.0, 11.0 })
{
var part = new Part(new Drawing("square", Square()), new Vector(x, 0));
var program = LeadIn();
if (markSecond && x > 1)
program = HeadRaise.Mark(program, [0]);
Assert.True(part.RestoreLeadInProgram(program, true));
plate.Parts.Add(part);
}
return nest;
}
private static Program Square()
{
var p = new Program();
p.MoveTo(0, 0); p.LineTo(0, 2); p.LineTo(2, 2); p.LineTo(2, 0); p.LineTo(0, 0);
return p;
}
private static Program LeadIn()
{
var p = new Program();
p.MoveTo(-0.25, 1);
p.Codes.Add(new LinearMove(0, 1) { Layer = LayerType.Leadin });
p.LineTo(0, 2); p.LineTo(2, 2); p.LineTo(2, 0); p.LineTo(0, 0); p.LineTo(0, 1);
return p;
}
private static List<string> ClLines(Nest nest, M47Mode mode)
{
var post = new CincinnatiPostProcessor(new CincinnatiPostConfig { ExteriorM47 = mode, InteriorM47 = mode });
using var stream = new MemoryStream();
post.Post(nest, stream);
return Lines(stream);
}
private static List<string> Lines(MemoryStream stream) =>
Encoding.UTF8.GetString(stream.ToArray()).Replace("\r\n", "\n").Split('\n').Select(l => l.Trim()).ToList();
}
@@ -327,11 +327,9 @@ public class AutomaticCutOffLifecycleTests
Assert.Equal(realParts[i].Location.Y, savedParts[i].GetProperty("y").GetDouble());
}
var cuts = savedPlate.GetProperty("cutOffs").EnumerateArray().ToArray();
// The line through the second part has two consecutive segments at 4 and 5. Every place
// is written, so a reload that regenerates a different count knows how many there were.
// The line through the second part has two consecutive segments at 4 and 5.
Assert.Equal(new[] { 1, 3, 4, 6 }, cuts.Select(c => c.GetProperty("sequence").GetInt32()));
Assert.Equal(new[] { "1", "3", "4,5", "6" }, cuts.Select(c =>
string.Join(",", c.GetProperty("segments").EnumerateArray().Select(e => e.GetInt32()))));
Assert.All(cuts, c => Assert.False(c.TryGetProperty("segments", out _)));
}
private static (Vector From, Vector To)[] Segments(Part part)
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff. Show more