mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-29 15:51:06 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afdf196754 | ||
|
|
35be95b843 | ||
|
|
4ae50c8a73 | ||
|
|
a0c620690e | ||
|
|
07a9739774 | ||
|
|
6a85593bd4 | ||
|
|
c798be9b95 | ||
|
|
fe216be2b4 | ||
|
|
a1d20bdeb5 | ||
|
|
41460481d6 | ||
|
|
a9396ba065 | ||
|
|
101d7b5a50 | ||
|
|
a0d063bb94 | ||
|
|
c21b5b006e | ||
|
|
55ab8ec4a5 | ||
|
|
662d3daf8c | ||
|
|
360c7136dd | ||
|
|
501d90b825 | ||
|
|
fa7a1f0fce | ||
|
|
672ed09074 | ||
|
|
e5dc67346d | ||
|
|
f22e839850 | ||
|
|
b8e8cc387d | ||
|
|
c6b6678a2f | ||
|
|
4179b53c97 | ||
|
|
c8419761e0 | ||
|
|
16bd92567c | ||
|
|
cd51f7cfda | ||
|
|
6f4e872c03 | ||
|
|
588ae5ed09 | ||
|
|
277ca36f42 | ||
|
|
8dded7f177 | ||
|
|
b65a9485c7 | ||
|
|
63915d6fb6 | ||
|
|
379e3fd433 | ||
|
|
f1468e6f57 | ||
|
|
0be9e2201a | ||
|
|
fcb61f3b3e | ||
|
|
87e0a70878 | ||
|
|
8776eb4734 | ||
|
|
fd2e9c2f56 | ||
|
|
3618b335cb | ||
|
|
7d9c64487d | ||
|
|
cc066b457e | ||
|
|
d2f814f1f9 | ||
|
|
69d8187adc | ||
|
|
4670577110 | ||
|
|
d505a50934 | ||
|
|
bc15c362e1 | ||
|
|
9b4c1f51d0 | ||
|
|
eba15791e7 | ||
|
|
7fe4de4b6d | ||
|
|
f4317bc52f | ||
|
|
207f126b9e | ||
|
|
0f1e5fb568 | ||
|
|
0ede9443de | ||
|
|
58b8467c39 | ||
|
|
eedff85904 | ||
|
|
cc16590881 | ||
|
|
8ad32e4864 | ||
|
|
7198f7a4f7 | ||
|
|
ee842c822f | ||
|
|
512b861739 |
No files matched your search
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"vpk": {
|
||||
"version": "1.2.0",
|
||||
"commands": [
|
||||
"vpk"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,11 @@ 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:
|
||||
@@ -70,7 +72,7 @@ jobs:
|
||||
|
||||
windows-desktop:
|
||||
# WinForms tests need a Windows desktop runtime; the Linux job can only
|
||||
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop.
|
||||
# cross-compile them. FrontEnd.Tests also run here, beside the desktop build.
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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" ]
|
||||
@@ -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.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Desktop.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." }
|
||||
}
|
||||
@@ -96,6 +96,11 @@ jobs:
|
||||
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: 14
|
||||
compression-level: 0
|
||||
|
||||
@@ -22,13 +22,15 @@ 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
|
||||
```
|
||||
|
||||
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`.
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -38,8 +40,9 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
|
||||
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
|
||||
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
|
||||
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
|
||||
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
|
||||
- `OpenNest.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.
|
||||
- `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.
|
||||
|
||||
## Geometry and ownership safeguards
|
||||
|
||||
@@ -48,15 +51,18 @@ 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
|
||||
@@ -70,6 +76,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): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
|
||||
- [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`.
|
||||
- [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,6 +11,7 @@
|
||||
<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" />
|
||||
|
||||
@@ -132,14 +132,26 @@ static class NestConsole
|
||||
|
||||
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|
||||
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|
||||
|| options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
|
||||
|| options.OutlineFile != null || 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 or --template."
|
||||
"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."
|
||||
);
|
||||
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();
|
||||
@@ -197,6 +209,12 @@ 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)
|
||||
@@ -276,6 +294,9 @@ 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;
|
||||
@@ -526,6 +547,13 @@ 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)
|
||||
@@ -996,6 +1024,9 @@ 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
|
||||
@@ -1037,6 +1068,7 @@ static class NestConsole
|
||||
public int Quantity;
|
||||
public double? Spacing;
|
||||
public Size? PlateSize;
|
||||
public string OutlineFile;
|
||||
public bool CheckOverlaps;
|
||||
public bool NoSave;
|
||||
public bool KeepParts;
|
||||
|
||||
@@ -544,6 +544,8 @@ 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,9 +70,11 @@ 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
|
||||
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
|
||||
|
||||
@@ -107,16 +107,18 @@ internal static class ContourEntryRanking
|
||||
if (target != null)
|
||||
{
|
||||
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
|
||||
// sides within tolerance); count how many of them are facing sides.
|
||||
// sides within tolerance, a fillet's joint within its reach of the corner it
|
||||
// rounds); 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));
|
||||
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 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++;
|
||||
}
|
||||
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
|
||||
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
|
||||
|
||||
@@ -41,8 +41,10 @@ public static class ExecutionMotionReader
|
||||
var pos = frame;
|
||||
var first = true;
|
||||
var countBefore = moves.Count;
|
||||
foreach (var code in current.Codes)
|
||||
var raised = HeadRaise.RaisedMask(current.Codes);
|
||||
for (var codeIndex = 0; codeIndex < current.Codes.Count; codeIndex++)
|
||||
{
|
||||
var code = current.Codes[codeIndex];
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
||||
@@ -106,7 +108,8 @@ 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));
|
||||
moves.Add(new(start, end, rapid, layer, curve, raised[codeIndex],
|
||||
ReferenceEquals(current, program) ? codeIndex : null));
|
||||
pos = end;
|
||||
first = false;
|
||||
}
|
||||
|
||||
@@ -121,11 +121,16 @@ 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 || Inside(rings[j][0].Start, rings[i], token)))
|
||||
if (Enumerable.Range(0, rings.Count).All(j => i == j || Encloses(i, rings[j][0].Start)))
|
||||
{
|
||||
if (outer >= 0)
|
||||
throw new ArgumentException("Material perimeter is ambiguous.");
|
||||
@@ -142,10 +147,11 @@ 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 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))
|
||||
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))
|
||||
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,13 +294,25 @@ 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
|
||||
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
|
||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint || !AtJoint(p, joint, boundary)))
|
||||
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,20 +38,31 @@ public sealed class OwnedExecution
|
||||
public sealed class ExecutionMotion
|
||||
{
|
||||
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
|
||||
PostVerificationGeometry.Curve curve)
|
||||
PostVerificationGeometry.Curve curve, bool headRaised = false, int? code = null)
|
||||
{
|
||||
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);
|
||||
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve, HeadRaised, Code);
|
||||
}
|
||||
@@ -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))
|
||||
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind, p.CornerReach))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
@@ -763,12 +763,14 @@ 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. Reflex, cusp and collinear-split vertices contribute
|
||||
/// 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
|
||||
/// nothing; a single whole circle yields no points at all.
|
||||
/// </summary>
|
||||
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
|
||||
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind, double CornerReach)> CataloguePoints(
|
||||
Shape shape, CancellationToken token)
|
||||
{
|
||||
var found = new List<(int, Vector, AutomaticEntryKind)>();
|
||||
var found = new List<(int, Vector, AutomaticEntryKind, double)>();
|
||||
var count = shape.Entities.Count;
|
||||
if (count == 1 && shape.Entities[0] is Circle)
|
||||
{
|
||||
@@ -792,29 +794,65 @@ public sealed class PreparedContours
|
||||
switch (corner.Kind)
|
||||
{
|
||||
case ContourCuttingStrategy.CornerKind.Convex:
|
||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
|
||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
|
||||
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((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||
found.Add(Joint(i, vertex, (i + 1) % count));
|
||||
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((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||
found.Add(Joint(i, vertex, i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (entity is Line line)
|
||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
|
||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
|
||||
}
|
||||
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,9 +54,11 @@ 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check(
|
||||
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed,
|
||||
IReadOnlyList<PostVerificationFinding> Raised) Check(
|
||||
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
|
||||
CancellationToken token)
|
||||
{
|
||||
@@ -67,8 +69,10 @@ public sealed class ReleasedContourState
|
||||
moves[0] = moves[0].WithStart(arrival);
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
var fixedFindings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
|
||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
|
||||
var raisedFindings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings,
|
||||
raisedFindings: raisedFindings);
|
||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly(), raisedFindings.AsReadOnly());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -97,7 +101,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)
|
||||
bool proveOpenCuts = false, int contoursBefore = 0, List<PostVerificationFinding> raisedFindings = null)
|
||||
{
|
||||
var current = new CutGroup();
|
||||
var unfinished = new List<CutGroup>();
|
||||
@@ -166,16 +170,22 @@ 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))
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
else if (move.Layer == LayerType.Leadin)
|
||||
|
||||
@@ -867,10 +867,18 @@ 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)
|
||||
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)
|
||||
{
|
||||
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
|
||||
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
|
||||
var cross = tangentIn.X * tangentOut.Y - tangentIn.Y * tangentOut.X;
|
||||
var dot = tangentIn.DotProduct(tangentOut);
|
||||
var turn = winding == RotationType.CCW ? cross : -cross;
|
||||
|
||||
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-12
@@ -262,7 +262,8 @@ namespace OpenNest
|
||||
{
|
||||
var bounds = plate.BoundingBox(includeParts: false);
|
||||
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
|
||||
var overtravel = LockedFarEdge(plate) is { High: true } ? 0 : settings.Overtravel;
|
||||
var lockedFarEdge = LockedFarEdge(plate);
|
||||
var overtravel = lockedFarEdge is { High: true } ? 0 : settings.Overtravel;
|
||||
|
||||
double lineStart,
|
||||
lineEnd,
|
||||
@@ -281,6 +282,13 @@ 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)
|
||||
@@ -325,27 +333,71 @@ namespace OpenNest
|
||||
}
|
||||
|
||||
var segments = new List<(double Start, double End)>();
|
||||
var current = lineStart;
|
||||
|
||||
foreach (var ex in merged)
|
||||
foreach (var (spanStart, spanEnd) in spans)
|
||||
{
|
||||
var clampedStart = System.Math.Max(ex.Start, lineStart);
|
||||
var clampedEnd = System.Math.Min(ex.End, lineEnd);
|
||||
var current = spanStart;
|
||||
|
||||
if (clampedStart > current)
|
||||
segments.Add((current, clampedStart));
|
||||
foreach (var ex in merged)
|
||||
{
|
||||
var clampedStart = System.Math.Max(ex.Start, spanStart);
|
||||
var clampedEnd = System.Math.Min(ex.End, spanEnd);
|
||||
|
||||
current = System.Math.Max(current, clampedEnd);
|
||||
if (clampedStart > current)
|
||||
segments.Add((current, clampedStart));
|
||||
|
||||
current = System.Math.Max(current, clampedEnd);
|
||||
}
|
||||
|
||||
if (current < spanEnd)
|
||||
segments.Add((current, spanEnd));
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,17 @@ 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.");
|
||||
@@ -44,23 +50,70 @@ 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, holes[i], cancellationToken)
|
||||
|| !Inside(outer, holes[i].Vertices[0]))
|
||||
if (BoundariesTouch(outer, outerBounds, holes[i], holeBounds[i], cancellationToken)
|
||||
|| !Inside(outer, outerBounds, holes[i].Vertices[0]))
|
||||
throw new ArgumentException("Contours must have one outer with strictly internal holes.");
|
||||
for (var j = 0; j < i; j++)
|
||||
{
|
||||
if (BoundariesTouch(holes[i], holes[j], cancellationToken)
|
||||
|| Inside(holes[i], holes[j].Vertices[0])
|
||||
|| Inside(holes[j], holes[i].Vertices[0]))
|
||||
// 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]))
|
||||
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());
|
||||
@@ -171,13 +224,23 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||
throw new ArgumentException("Material contour could not be completely triangulated.");
|
||||
}
|
||||
|
||||
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken)
|
||||
// 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)
|
||||
{
|
||||
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(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1]))
|
||||
if (SegmentsTouch(start, end, b.Vertices[j], b.Vertices[j + 1]))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -200,6 +263,10 @@ 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,6 +68,10 @@ 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);
|
||||
@@ -397,9 +401,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))
|
||||
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other, points))
|
||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this))
|
||||
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this, points))
|
||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||
return points;
|
||||
|
||||
@@ -415,16 +419,35 @@ 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)
|
||||
private static bool ExactLineContact(Curve line, Curve curve, IReadOnlyList<Vector> recorded)
|
||||
{
|
||||
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 && curve.ContactAfterStart(line.Start, direction, forward))
|
||||
|| (backward > 0 && curve.ContactAfterStart(line.End, direction * -1, backward));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,11 @@ 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;
|
||||
@@ -373,7 +378,7 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance;
|
||||
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= ChainTolerance;
|
||||
|
||||
private static double SignedArea(List<Entity> entities, int first, int last)
|
||||
{
|
||||
|
||||
+120
-32
@@ -98,6 +98,9 @@ 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();
|
||||
}
|
||||
@@ -105,6 +108,26 @@ 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;
|
||||
|
||||
@@ -303,11 +326,13 @@ 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)
|
||||
{
|
||||
@@ -343,7 +368,7 @@ namespace OpenNest
|
||||
indices[k] = previous = best >= 0 ? best : previous;
|
||||
}
|
||||
return indices;
|
||||
});
|
||||
}, _ => places);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -353,38 +378,78 @@ 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, (cutoff, extents) =>
|
||||
sequence != null && sequence.TryGetValue(cutoff, out var index)
|
||||
? Enumerable.Range(index, extents.Count).ToArray() : null);
|
||||
RegenerateCutOffs(settings, null, sequence);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="RegenerateCutOffs(CutOffSettings, IReadOnlyDictionary{CutOff, int})"/>,
|
||||
/// but each cut-off lists one index per segment in ascending order along its axis. A list
|
||||
/// whose length differs from the regenerated segment count places the segments
|
||||
/// consecutively from its smallest index.
|
||||
/// consecutively at 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 (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;
|
||||
});
|
||||
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));
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
private void RegenerateCutOffs(CutOffSettings settings,
|
||||
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence)
|
||||
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence,
|
||||
Func<IReadOnlyDictionary<CutOff, int>, IEnumerable<int>> savedPlaces,
|
||||
Func<CutOff, bool> separateLast = null)
|
||||
{
|
||||
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)
|
||||
@@ -399,20 +464,34 @@ 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++)
|
||||
{
|
||||
if (indices != null && k < indices.Count)
|
||||
// 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)
|
||||
placed.Add((indices[k], placed.Count, parts[k]));
|
||||
else
|
||||
Parts.Add(parts[k]);
|
||||
}
|
||||
}
|
||||
|
||||
// Lowest index first: each insert then lands on its saved index, because every part
|
||||
// sequenced before it is already in place. 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);
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -456,10 +535,13 @@ 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. Keep the full geometry for the existing
|
||||
// conservative fallback when the material contour really is open.
|
||||
// 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.
|
||||
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())
|
||||
@@ -469,7 +551,7 @@ namespace OpenNest
|
||||
return perimeter;
|
||||
}
|
||||
|
||||
var points = entities.CollectPoints();
|
||||
var points = outline.CollectPoints();
|
||||
if (points.Count < 3)
|
||||
return null;
|
||||
|
||||
@@ -526,7 +608,9 @@ 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).
|
||||
@@ -632,10 +716,12 @@ 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)
|
||||
@@ -841,7 +927,8 @@ namespace OpenNest
|
||||
/// </example>
|
||||
public void AutoSize(double roundingFactor = 1.0)
|
||||
{
|
||||
if (Parts.Count == 0)
|
||||
// An outlined sheet is a particular piece of material; it cannot be resized to fit.
|
||||
if (Parts.Count == 0 || Outline != null)
|
||||
return;
|
||||
|
||||
var bounds = Parts.GetBoundingBox();
|
||||
@@ -890,7 +977,7 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
|
||||
{
|
||||
if (Parts.Count == 0)
|
||||
if (Parts.Count == 0 || Outline != null)
|
||||
return default;
|
||||
|
||||
var bounds = Parts.GetBoundingBox();
|
||||
@@ -942,12 +1029,13 @@ namespace OpenNest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the area of the top surface of the plate.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public double Area()
|
||||
{
|
||||
return Size.Width * Size.Length;
|
||||
return Outline?.Area ?? Size.Width * Size.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -133,6 +133,7 @@ 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
|
||||
|
||||
@@ -38,10 +38,14 @@ 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.
|
||||
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks. A plate
|
||||
/// with an <see cref="Plate.Outline"/> reports none.
|
||||
/// </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)
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,14 @@ 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) =>
|
||||
|
||||
@@ -44,7 +44,8 @@ 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). Different units, a different revision
|
||||
/// 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
|
||||
/// of the same drawing, or another drawing with the same name refuse it.
|
||||
/// </summary>
|
||||
public sealed class LibraryDrawingInsert
|
||||
@@ -75,7 +76,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. Otherwise the copy has no pins. An
|
||||
/// material, grade and thickness match the nest's or are unset. Otherwise the copy has no pins. An
|
||||
/// added quantity leaves the existing copy as it is.
|
||||
/// </summary>
|
||||
public bool KeepPins { get; }
|
||||
@@ -140,7 +141,12 @@ public sealed class LibraryDrawingInsert
|
||||
var drawing = package.Drawing;
|
||||
drawing.Name = record.Name;
|
||||
drawing.Customer = record.Customer;
|
||||
drawing.Material = new Material(record.MaterialName, record.MaterialGrade, drawing.Material?.Density ?? 0);
|
||||
// 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);
|
||||
(record.Settings ?? new DrawingNestingSettings()).ApplyTo(drawing);
|
||||
drawing.Quantity.Required = quantity;
|
||||
drawing.Library = link;
|
||||
@@ -177,21 +183,24 @@ 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 (SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
|
||||
if (IsSet(record.MaterialName) && SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
|
||||
differences.Add($"material {Text(record.MaterialName)} (nest {Text(material.Name)})");
|
||||
if (SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
|
||||
if (IsSet(record.MaterialGrade) && 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 != nestThickness)
|
||||
if (thickness != 0 && 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}"
|
||||
|
||||
@@ -73,6 +73,25 @@ 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.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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..];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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,7 +51,12 @@ 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>E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (2 skipped: 2 unknown shape)".</summary>
|
||||
/// <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>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
@@ -64,6 +69,8 @@ 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)})";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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; }
|
||||
}
|
||||
@@ -151,6 +151,19 @@ 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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@@ -0,0 +1,18 @@
|
||||
<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>
|
||||
@@ -0,0 +1,334 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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,9 +12,10 @@ public sealed class CutoutPipelineRegistryCollection { }
|
||||
[Collection("Cutout pipeline registry")]
|
||||
public class CutoutPipelinePreviewTests
|
||||
{
|
||||
private static NestPipelineRequest Request(string engine = "Irregular")
|
||||
/// <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)
|
||||
{
|
||||
var frame = new Drawing("frame", TestShapes.Ring(20, 10));
|
||||
var frame = new Drawing("frame", TestShapes.Ring(20, hole));
|
||||
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
|
||||
return new NestPipelineRequest(engine,
|
||||
new[] { new NestItem { Drawing = frame, Quantity = 1 },
|
||||
@@ -112,7 +113,8 @@ public class CutoutPipelinePreviewTests
|
||||
[Fact]
|
||||
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
|
||||
{
|
||||
var request = Request("stub");
|
||||
// One insert is reserved in the hole; the other must stay an ordinary requirement.
|
||||
var request = Request("stub", hole: 6);
|
||||
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 =>
|
||||
@@ -171,7 +173,8 @@ public class CutoutPipelinePreviewTests
|
||||
[Fact]
|
||||
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
|
||||
{
|
||||
var request = Request("stub");
|
||||
// One insert is reserved in the hole; the other must stay an ordinary requirement.
|
||||
var request = Request("stub", hole: 6);
|
||||
request.Items[1].Quantity = 2;
|
||||
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
|
||||
{
|
||||
@@ -273,7 +276,8 @@ public class CutoutPipelinePreviewTests
|
||||
[Fact]
|
||||
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
|
||||
{
|
||||
var initial = Request("stub");
|
||||
// One hole seat: the first requirement row fills it, the second row stays separate.
|
||||
var initial = Request("stub", hole: 6);
|
||||
var request = initial with
|
||||
{
|
||||
Items = new[] { initial.Items[0], initial.Items[1],
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
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,11 +121,14 @@ public sealed class CuttingPlanBatch
|
||||
{
|
||||
var plate = plates[index];
|
||||
var budget = PlateBudget(plate.Parts.Count, budgetMultiplier);
|
||||
var keepOrder = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||
// 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,
|
||||
expansionBudget: budget, confirmedParameters: confirmedParameters,
|
||||
preservePartOrder: true), token);
|
||||
var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||
expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), token);
|
||||
preservePartOrder: preservePartOrder), token);
|
||||
var keepOrder = preservePartOrder ? captured : captured.WithPreservedOrder();
|
||||
var reorder = preservePartOrder ? null
|
||||
: reorderBudget is { } reorderLimit ? captured.WithSearchLimits(reorderLimit, captured.MaxEntries) : captured;
|
||||
var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
|
||||
var fallback = reorder ?? keepOrder;
|
||||
if (fallback.Failure == CuttingPlanStatus.UnsupportedGeometry)
|
||||
@@ -212,9 +215,10 @@ 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 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static (CuttingPlanResult Result, bool FreeOrder)? SafestRoute(Entry entry, CancellationToken token)
|
||||
{
|
||||
@@ -225,10 +229,9 @@ public sealed class CuttingPlanBatch
|
||||
{
|
||||
if (CuttingPlanService.PlanSafestRoute(snapshot, token) is not { } candidate)
|
||||
continue;
|
||||
if (!candidate.IsSafestRouteFallback)
|
||||
if (!candidate.IsSafestRouteFallback && candidate.HeadRaises == 0)
|
||||
return (candidate, freeOrder);
|
||||
if (best is not { } known || candidate.SafestRoute < known.Result.SafestRoute
|
||||
|| candidate.SafestRoute == known.Result.SafestRoute && candidate.RapidDistance < known.Result.RapidDistance)
|
||||
if (best is not { } known || Safer(candidate, known.Result))
|
||||
best = (candidate, freeOrder);
|
||||
}
|
||||
}
|
||||
@@ -239,6 +242,17 @@ 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)
|
||||
{
|
||||
@@ -447,6 +461,8 @@ public sealed class CuttingPlanProposal
|
||||
Quadrant = source.Quadrant,
|
||||
PartSpacing = source.PartSpacing,
|
||||
EdgeSpacing = source.EdgeSpacing,
|
||||
// View > Draw Support shows the proposal on the same bed.
|
||||
SupportBed = source.SupportBed,
|
||||
};
|
||||
// Machine clamps stay visible while inspecting the proposal; zones are immutable.
|
||||
foreach (var zone in source.ClampZones)
|
||||
@@ -512,8 +528,11 @@ 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.");
|
||||
// A ready plan's findings are advisory notes (support tabs that may not hold a part level).
|
||||
: $" {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).
|
||||
foreach (var line in Limit(result.Findings.Select(DescribeFinding)))
|
||||
yield return line;
|
||||
yield break;
|
||||
|
||||
@@ -120,6 +120,13 @@ 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
|
||||
@@ -367,6 +374,12 @@ 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,10 +350,13 @@ public static class CuttingPlanService
|
||||
|
||||
/// <summary>
|
||||
/// The safest route the greedy walk finds, independently replayed; null when there is none.
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static CuttingPlanResult PlanSafestRoute(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||
{
|
||||
@@ -366,6 +369,14 @@ 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 })
|
||||
@@ -397,6 +408,9 @@ 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)
|
||||
@@ -433,8 +447,10 @@ public static class CuttingPlanService
|
||||
findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions);
|
||||
}
|
||||
var isFixed = proposal.FixedMotions(execution);
|
||||
var (strict, fixedFindings) = checker.Check(execution, position, source.SourceOrdinal + 1,
|
||||
var (strict, fixedFindings, raised) = 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>();
|
||||
@@ -464,12 +480,14 @@ public static class CuttingPlanService
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
// Support tabs that cannot hold their part level are noted, never refused.
|
||||
var notes = order.SelectMany(SupportTabCapture.Notes).Concat(SupportTabCapture.Skipped(snapshot)).ToList();
|
||||
var notes = raises.Concat(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)
|
||||
@@ -479,9 +497,11 @@ 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);
|
||||
distance, independentlyReplayed: true)
|
||||
{ HeadRaises = raisedRapids };
|
||||
return new(CuttingPlanStatus.Ready, order, notes, expansions: expansions,
|
||||
rapidDistance: distance, independentlyReplayed: true);
|
||||
rapidDistance: distance, independentlyReplayed: true)
|
||||
{ HeadRaises = raisedRapids };
|
||||
}
|
||||
|
||||
private static CuttingPlanFinding DependencyFinding(CuttingPlanSnapshot snapshot,
|
||||
|
||||
@@ -28,6 +28,14 @@ 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;
|
||||
@@ -52,7 +60,7 @@ internal static class JointCuttingPlanSearch
|
||||
{
|
||||
var inserted = CutOffInsertion.Insert(snapshot, outcome.Order, attached, walk.CountExpansion, token);
|
||||
if (inserted != null)
|
||||
return walk.Ready(inserted);
|
||||
return walk.Ready(walk.FollowSegments(inserted));
|
||||
}
|
||||
catch (BudgetExceededException)
|
||||
{
|
||||
@@ -71,6 +79,10 @@ 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.
|
||||
@@ -91,7 +103,7 @@ internal static class JointCuttingPlanSearch
|
||||
if (recovered.Order != null)
|
||||
return walk.Ready(recovered.Order);
|
||||
}
|
||||
var kept = walk.Follow(keptSequence, null, null);
|
||||
var kept = walk.Follow(keptSequence, null, null, expansionLimit: FallbackLimit());
|
||||
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
|
||||
}
|
||||
|
||||
@@ -133,11 +145,12 @@ 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);
|
||||
var recovery = walk.Follow(null, stall, null, contourRecovery: true,
|
||||
expansionLimit: FallbackLimit());
|
||||
if (recovery.Order != null)
|
||||
return walk.Ready(recovery.Order);
|
||||
}
|
||||
var full = walk.Follow(null, null, null);
|
||||
var full = walk.Follow(null, null, null, expansionLimit: FallbackLimit());
|
||||
return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted();
|
||||
}
|
||||
// Back up to just before the earliest part the blocked approach crossed, keep the
|
||||
@@ -195,27 +208,43 @@ internal static class JointCuttingPlanSearch
|
||||
}, token);
|
||||
}
|
||||
|
||||
// "Cut the blocked part before every part whose cut contour its approach crossed", unless that
|
||||
// would contradict an order already required. False when nothing new was learned.
|
||||
// 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
|
||||
// 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
|
||||
// nothing new was learned.
|
||||
private static bool Learn(Attempt attempt, HashSet<int>[] prerequisites)
|
||||
{
|
||||
if (attempt.Blocked is not int blocked)
|
||||
return false;
|
||||
var learned = false;
|
||||
foreach (var crossed in attempt.Crossed.Order())
|
||||
{
|
||||
if (crossed == blocked || prerequisites[crossed].Contains(blocked) || Precedes(crossed, blocked, prerequisites))
|
||||
continue;
|
||||
prerequisites[crossed].Add(blocked);
|
||||
learned = true;
|
||||
}
|
||||
return learned;
|
||||
var rules = Rules(attempt, prerequisites).ToArray();
|
||||
foreach (var (first, then) in rules)
|
||||
prerequisites[then].Add(first);
|
||||
return rules.Length != 0;
|
||||
}
|
||||
|
||||
// True when a crossing of the blocked part's approach would teach a new order rule.
|
||||
private static bool CanLearn(Attempt attempt, HashSet<int>[] prerequisites) =>
|
||||
attempt.Blocked is int blocked && attempt.Crossed.Any(crossed => crossed != blocked
|
||||
&& !prerequisites[crossed].Contains(blocked) && !Precedes(crossed, blocked, prerequisites));
|
||||
Rules(attempt, prerequisites).Any();
|
||||
|
||||
// The rules Learn would add, as (cut first, cut then) pairs.
|
||||
private static IEnumerable<(int First, int Then)> Rules(Attempt attempt, HashSet<int>[] prerequisites)
|
||||
{
|
||||
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);
|
||||
|
||||
(int, int)[] New(int first) => attempt.Crossed.Order()
|
||||
.Where(crossed => crossed != blocked && crossed != first && !prerequisites[crossed].Contains(first)
|
||||
&& !Precedes(crossed, first, prerequisites))
|
||||
.Select(crossed => (first, crossed)).ToArray();
|
||||
}
|
||||
|
||||
// True when 'first' must already come before 'second' through the prerequisite chain.
|
||||
private static bool Precedes(int first, int second, HashSet<int>[] prerequisites)
|
||||
@@ -311,9 +340,10 @@ 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)
|
||||
HashSet<int>[] prerequisites = null, int? expansionLimit = null)
|
||||
{
|
||||
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null);
|
||||
var attempt = new Attempt(sequence);
|
||||
@@ -332,12 +362,15 @@ 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; learn that rather than stall here.
|
||||
// earlier never makes its own cut harder, and a sheet separator that cannot come
|
||||
// earlier needs the approach from elsewhere; learn that rather than stall here.
|
||||
if (prerequisites != null && sequence != null && attempt.Blocked is int blocked
|
||||
&& snapshot.Placements[blocked] is { IsCutOff: true, SeparatesSheet: false }
|
||||
&& CanLearn(attempt, prerequisites))
|
||||
&& snapshot.Placements[blocked].IsCutOff && CanLearn(attempt, prerequisites))
|
||||
return attempt;
|
||||
if (attempt.Advance(node))
|
||||
progressExpansions = Expansions;
|
||||
@@ -523,8 +556,9 @@ internal static class JointCuttingPlanSearch
|
||||
CountExpansion(source);
|
||||
var checker = node.Checker.Copy();
|
||||
if (Check(cut, cut.Execution, node.Position, checker, attempt))
|
||||
// A segment is transparent: the part after it ranks its entries and hole route
|
||||
// from where the tool was before the segment, as planned without it.
|
||||
// A segment is transparent to the part after it: that part ranks its outside entry
|
||||
// from where the tool was before the segment, as planned without it. Its hole
|
||||
// route starts where the tool is, the segment's end, as FollowSegments plans it.
|
||||
yield return new(new([.. node.Order, cut], cut.Execution.DeparturePoint, checker, null, node,
|
||||
IsSegment(cut) ? node.RoutePosition : null),
|
||||
cut.Execution.RapidDistanceFrom(node.Position), cut.SourceOrdinal, -1, -1);
|
||||
@@ -559,7 +593,7 @@ internal static class JointCuttingPlanSearch
|
||||
}
|
||||
var preference = node.Active?.Preference;
|
||||
if (node.Active?.Perimeter is { } outside && preference == null)
|
||||
preference = PlanHoles(node.Active, outside);
|
||||
preference = PlanHoles(node.Active.Source, node.Active.Boundary.Position, outside);
|
||||
var choices = node.Active?.Choices ?? [];
|
||||
var arrival = node.Active?.Arrival ?? node.Position;
|
||||
var before = node.Active?.Before ?? node.Checker;
|
||||
@@ -1216,12 +1250,13 @@ internal static class JointCuttingPlanSearch
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private HolePreference PlanHoles(ActivePart active, ContourChoice perimeter)
|
||||
// The hole route and preferred hole entries from the tool's global arrival onto the part,
|
||||
// ending at the outside entry.
|
||||
private HolePreference PlanHoles(FixedProgramPlacement source, Vector arrival, ContourChoice perimeter)
|
||||
{
|
||||
var source = active.Source;
|
||||
var prepared = source.Prepared;
|
||||
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
|
||||
var localArrival = active.Boundary.RoutePosition - source.Location;
|
||||
var localArrival = arrival - source.Location;
|
||||
var centres = CuttingHoleOrder.Centres(prepared, localArrival, token);
|
||||
var endpoint = perimeter.Point;
|
||||
var entries = new Dictionary<int, ContourChoice>();
|
||||
@@ -1255,6 +1290,72 @@ internal static class JointCuttingPlanSearch
|
||||
return new(route, entries, pierces, endpoint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The order with segments placed, where each holed part cut straight after a segment
|
||||
/// takes the hole route and entries planned from the segment's end, where the tool
|
||||
/// really is. The free search planned the part as if the segment were not there, from
|
||||
/// where the tool was before it. The part's outside entry and the order stay, so the
|
||||
/// route is still the one planned without the lines; a kept order replanned later plans
|
||||
/// the same holes from the same point. A part keeps its planned holes when the new
|
||||
/// route has no preferred entry for every hole or does not pass the checks.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<FixedProgramPlacement> FollowSegments(IReadOnlyList<FixedProgramPlacement> order)
|
||||
{
|
||||
var followed = order.ToArray();
|
||||
var checker = new ReleasedContourState(reportMissingLeadIns: false);
|
||||
var position = snapshot.StartPoint;
|
||||
try
|
||||
{
|
||||
for (var index = 0; index < followed.Length; index++)
|
||||
{
|
||||
if (index > 0 && IsSegment(followed[index - 1])
|
||||
&& FromSegment(followed[index], position, checker) is { } replanned)
|
||||
followed[index] = replanned;
|
||||
var placement = followed[index];
|
||||
checker.Check(placement.Execution, position, placement.SourceOrdinal + 1, placement.IsCutOff,
|
||||
placement.FixedMotions(placement.Execution), token);
|
||||
position = placement.Execution.DeparturePoint;
|
||||
}
|
||||
}
|
||||
catch (BudgetExceededException)
|
||||
{
|
||||
// The plan is complete without this refinement; keep it as placed.
|
||||
return order;
|
||||
}
|
||||
return followed;
|
||||
}
|
||||
|
||||
// The part re-planned from arrival with its outside entry kept, or null to keep it.
|
||||
private FixedProgramPlacement FromSegment(FixedProgramPlacement placement, Vector arrival,
|
||||
ReleasedContourState checker)
|
||||
{
|
||||
var source = snapshot.Placements[placement.SourceOrdinal];
|
||||
var prepared = source.Prepared;
|
||||
if (prepared is not { HasCutPerimeter: true } || prepared.PerimeterOrdinal == 0
|
||||
|| placement.ContourChoices.Count != prepared.Count
|
||||
|| placement.ContourChoices[^1].ContourOrdinal != prepared.PerimeterOrdinal)
|
||||
return null;
|
||||
var perimeter = placement.ContourChoices[^1];
|
||||
var preference = PlanHoles(source, arrival, perimeter);
|
||||
if (preference.Entries.Count != prepared.PerimeterOrdinal)
|
||||
return null;
|
||||
var choices = preference.Route.Select(hole => preference.Entries[hole]).Append(perimeter).ToArray();
|
||||
Program program;
|
||||
OwnedExecution execution;
|
||||
try
|
||||
{
|
||||
CountExpansion(source);
|
||||
program = prepared.Emit(choices);
|
||||
execution = ExecutionMotionReader.Read(program, source.Location, arrival, token);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return Check(source, execution, arrival, checker.Copy(), new Attempt(null), choices)
|
||||
? source.Propose(program, execution, choices, token) : null;
|
||||
}
|
||||
|
||||
// The placements cut at a node, and the excluded ones, which count as cut throughout.
|
||||
private HashSet<int> Finished(Node node)
|
||||
{
|
||||
@@ -1504,6 +1605,9 @@ 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];
|
||||
@@ -1568,8 +1672,8 @@ internal static class JointCuttingPlanSearch
|
||||
ContourChoice Perimeter = null, HolePreference Preference = null);
|
||||
/// <summary>
|
||||
/// A search state; Previous links a whole-part boundary to the boundary before it. Anchor,
|
||||
/// when set, is where the tool was before the cut-off segments cut last: entries and hole
|
||||
/// routes rank from <see cref="RoutePosition"/>, every rapid is checked from Position.
|
||||
/// when set, is where the tool was before the cut-off segments cut last: outside entries
|
||||
/// rank from <see cref="RoutePosition"/>; hole routes and every rapid start from Position.
|
||||
/// </summary>
|
||||
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
|
||||
ActivePart Active, Node Previous, Vector? Anchor = null)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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,7 +58,8 @@ public static class DrawingJobMapper
|
||||
plate.EdgeSpacing,
|
||||
plate.Quadrant,
|
||||
cost: null,
|
||||
clampZones: new List<ClampZone>(plate.ClampZones)
|
||||
clampZones: new List<ClampZone>(plate.ClampZones),
|
||||
outline: plate.Outline
|
||||
)
|
||||
{ ClampRail = plate.ClampRail };
|
||||
}
|
||||
@@ -126,6 +127,7 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -377,8 +377,9 @@ 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}");
|
||||
$"{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}");
|
||||
}
|
||||
|
||||
private sealed class ProbeBudget(int solves)
|
||||
@@ -428,7 +429,8 @@ internal static class ClampRemovalRecovery
|
||||
stock.EdgeSpacing,
|
||||
stock.Quadrant,
|
||||
stock.Cost,
|
||||
sheetZones
|
||||
sheetZones,
|
||||
stock.Outline
|
||||
)
|
||||
{ ClampRail = stock.ClampRail };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
|
||||
@@ -189,13 +190,7 @@ internal static class CutoutLatticeFill
|
||||
.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).FirstOrDefault();
|
||||
if (grown == null || grown.Count < 3)
|
||||
return new PathsD();
|
||||
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);
|
||||
return PackBoundary.InnerFit(grown, new PathsD { hole });
|
||||
}
|
||||
|
||||
/// <summary>Drops copies that fail the production clearance check against the frame; any
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
#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,6 +57,12 @@ 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
|
||||
@@ -104,10 +110,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 => geometry[p.Id] is { Cutouts.Count: 0 }).ToArray();
|
||||
var flat = eligible.Where(p => solid.GetValueOrDefault(p.Id)).ToArray();
|
||||
var nested = CutoutCirclePacker.RoundHole(profile.Cutouts[hole]) == null
|
||||
? Array.Empty<NestJobPart>()
|
||||
: eligible.Where(p => round[p.Id] && geometry[p.Id]!.Cutouts.Count > 0).ToArray();
|
||||
: eligible.Where(p => round[p.Id] && !solid.GetValueOrDefault(p.Id)).ToArray();
|
||||
if (flat.Length == 0 && nested.Length == 0)
|
||||
continue;
|
||||
foreach (var pose in Propose(frame, hole, flat, nested))
|
||||
|
||||
@@ -10,7 +10,8 @@ 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.
|
||||
/// 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.
|
||||
/// All accepted poses are clearance-checked.
|
||||
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
|
||||
internal static class CutoutRouter
|
||||
@@ -32,7 +33,8 @@ 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 or circle-packer pose.</summary>
|
||||
/// after indexing; true marks a retained lattice seed, false a residual NFP, circle-packer or
|
||||
/// frontier-packer pose.</summary>
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
IReadOnlyList<NestJobPart> inserts, double spacing,
|
||||
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default) =>
|
||||
@@ -66,9 +68,10 @@ 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 Circles(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, memo, token));
|
||||
return Observe(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());
|
||||
@@ -77,6 +80,7 @@ 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.
|
||||
@@ -148,11 +152,20 @@ 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> Circles(IReadOnlyList<NestJobPlacement> poses)
|
||||
IReadOnlyList<NestJobPlacement> Observe(IReadOnlyList<NestJobPlacement> poses)
|
||||
{
|
||||
foreach (var pose in poses)
|
||||
observeAccepted?.Invoke(pose, false);
|
||||
|
||||
@@ -15,7 +15,7 @@ public static class NestJobCost
|
||||
/// </summary>
|
||||
public static double NetSheetArea(NestJob job, NestJobPlateResult sheet)
|
||||
{
|
||||
var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length;
|
||||
var area = sheet.Stock.Area;
|
||||
var minimum = job.Options.MinimumSalvageDimension;
|
||||
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0)
|
||||
return area;
|
||||
@@ -84,16 +84,18 @@ public static class NestJobCost
|
||||
private static double NetSheetArea(
|
||||
NestJobOptions options, NestPlateStock stock, double left, double bottom, double right, double top)
|
||||
{
|
||||
var area = stock.Size.Width * stock.Size.Length;
|
||||
var area = stock.Area;
|
||||
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) =>
|
||||
PlateRemnant.LargestOffcut(stock.WorkArea, stock.PartSpacing, left, bottom, right, top, minimum)
|
||||
stock.Outline == null
|
||||
&& 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,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
@@ -120,7 +121,12 @@ internal static class NestJobPlacementValidator
|
||||
foreach (var cutout in shape.Cutouts)
|
||||
if (!FitsWorkArea(cutout, workArea))
|
||||
return false;
|
||||
return true;
|
||||
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);
|
||||
}
|
||||
|
||||
private static bool FitsWorkArea(Shape contour, Box workArea)
|
||||
|
||||
@@ -63,6 +63,8 @@ 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,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
@@ -394,6 +395,7 @@ 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)
|
||||
);
|
||||
|
||||
@@ -465,6 +467,7 @@ public static class NestLayoutCheck
|
||||
)
|
||||
{
|
||||
var workArea = plate.WorkArea();
|
||||
var stock = plate.Outline == null ? null : DrawingJobMapper.FromPlate("plate", plate, null);
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
@@ -481,6 +484,19 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,7 +517,9 @@ public static class NestLayoutCheck
|
||||
)
|
||||
{
|
||||
var workArea = plate.WorkArea();
|
||||
var budget = workArea.Width * workArea.Length;
|
||||
var budget = plate.Outline == null
|
||||
? workArea.Width * workArea.Length
|
||||
: DrawingJobMapper.FromPlate("plate", plate, null).UsableOutline().Sum(Clipper.Area);
|
||||
var placedArea = parts.Sum(p => p.BaseDrawing.Area);
|
||||
|
||||
if (placedArea > budget + Tolerance.Epsilon)
|
||||
|
||||
@@ -89,6 +89,7 @@ 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;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
@@ -22,16 +24,19 @@ public sealed class NestPlateStock
|
||||
|
||||
public NestPlateStock(string id, Size size, int? quantity, double partSpacing,
|
||||
Spacing edgeSpacing, int quadrant, double? cost,
|
||||
IReadOnlyList<ClampZone> clampZones = null)
|
||||
IReadOnlyList<ClampZone> clampZones = null, SheetOutline outline = 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;
|
||||
@@ -92,8 +97,50 @@ 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; }
|
||||
@@ -106,8 +153,9 @@ 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.</summary>
|
||||
public double Area => Size.Width * Size.Length;
|
||||
/// <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>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,6 +29,16 @@ 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,
|
||||
@@ -431,6 +441,10 @@ 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,6 +40,7 @@ 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,6 +90,8 @@ 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,9 +131,12 @@ internal sealed class BarStock
|
||||
var lengths = new double[types.Count];
|
||||
for (var t = 0; t < types.Count; t++)
|
||||
{
|
||||
var fitting = types[t].Orientations
|
||||
.Where(o => (alongX ? o.Height : o.Width) <= crossExtent + BarPacker.OverhangAllowance)
|
||||
.ToList();
|
||||
// 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();
|
||||
if (fitting.Count == 0)
|
||||
{
|
||||
fits[t] = Array.Empty<BarOrientation>();
|
||||
|
||||
@@ -44,6 +44,10 @@ 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
|
||||
{
|
||||
@@ -63,7 +67,10 @@ 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,
|
||||
@@ -73,9 +80,12 @@ internal sealed class FrontierPacker
|
||||
double beta,
|
||||
WorkCounter counter,
|
||||
BlockCatalog? blocks = null,
|
||||
ClampObstacles? clamps = null
|
||||
ClampObstacles? clamps = null,
|
||||
PackBoundary? boundary = 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;
|
||||
@@ -85,9 +95,13 @@ internal sealed class FrontierPacker
|
||||
this.stock = stock;
|
||||
this.axis = axis;
|
||||
this.beta = beta;
|
||||
work = stock.WorkArea;
|
||||
this.boundary = boundary;
|
||||
work = boundary?.Bounds ?? 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();
|
||||
@@ -97,9 +111,11 @@ internal sealed class FrontierPacker
|
||||
{
|
||||
if (byOrientation.ContainsKey(o))
|
||||
return true;
|
||||
if (!stock.Fits(o.Width, o.Height))
|
||||
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)
|
||||
return false;
|
||||
var region = new Region(o, work, single);
|
||||
if (clamps != null)
|
||||
for (var i = 0; i < clamps.Count; i++)
|
||||
{
|
||||
@@ -110,6 +126,13 @@ 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;
|
||||
@@ -126,7 +149,7 @@ internal sealed class FrontierPacker
|
||||
foreach (var pair in typePairs)
|
||||
{
|
||||
// Members missing from the catalog get regions too, but only for pair placement.
|
||||
if (stock.Fits(pair.Width, pair.Height) && Track(pair.A, single: false) && Track(pair.B, single: false))
|
||||
if (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]));
|
||||
}
|
||||
}
|
||||
@@ -148,7 +171,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 (!stock.Fits(width, height))
|
||||
if (!Fits(width, height))
|
||||
continue;
|
||||
foreach (var member in members)
|
||||
{
|
||||
@@ -341,6 +364,15 @@ 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;
|
||||
@@ -353,7 +385,7 @@ internal sealed class FrontierPacker
|
||||
maxX = minX;
|
||||
if (maxY < minY)
|
||||
maxY = minY;
|
||||
free = new PathsD
|
||||
free = seed ?? new PathsD
|
||||
{
|
||||
new PathD
|
||||
{
|
||||
@@ -363,7 +395,8 @@ internal sealed class FrontierPacker
|
||||
new(minX - FitSlack, maxY + FitSlack),
|
||||
},
|
||||
};
|
||||
bounds = Clipper.GetBounds(free);
|
||||
free.RemoveAll(p => p.Count < 3);
|
||||
bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
|
||||
}
|
||||
|
||||
public Orientation Orientation { get; }
|
||||
|
||||
@@ -24,8 +24,9 @@ namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
/// </summary>
|
||||
public sealed class IrregularNestingEngine : INestingEngine
|
||||
{
|
||||
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
|
||||
private static readonly (PackAxis Axis, double Beta)[] Variants =
|
||||
/// <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 =
|
||||
{
|
||||
(PackAxis.X, 1.0),
|
||||
(PackAxis.Y, 1.0),
|
||||
@@ -148,6 +149,7 @@ 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);
|
||||
@@ -229,6 +231,18 @@ 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)
|
||||
@@ -306,7 +320,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));
|
||||
BlocksFor(stock), ClampsFor(stock), BoundaryFor(stock));
|
||||
var fill = packer.Fill(remaining, token);
|
||||
sheetPacked?.Invoke();
|
||||
if (fill.Parts.Count > 0)
|
||||
|
||||
@@ -30,9 +30,13 @@ 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));
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#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,6 +76,8 @@ 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,11 +69,12 @@ internal static class SheetByStock
|
||||
}
|
||||
|
||||
var trials = new List<(SheetLayout Layout, double Net, double Offcut)>();
|
||||
if (preferred != null)
|
||||
// Box and disc packers fill rectangles only; an outlined sheet is left to engines that fill outlines.
|
||||
if (preferred != null && preferred.Outline == null)
|
||||
Try(preferred, trials);
|
||||
if (trials.Count == 0)
|
||||
foreach (var stock in job.Plates)
|
||||
if (!ReferenceEquals(stock, preferred))
|
||||
if (!ReferenceEquals(stock, preferred) && stock.Outline == null)
|
||||
Try(stock, trials);
|
||||
|
||||
if (trials.Count == 0)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Gpu</RootNamespace>
|
||||
<AssemblyName>OpenNest.Gpu</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -696,6 +696,145 @@ 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>
|
||||
|
||||
@@ -530,6 +530,33 @@ 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)
|
||||
@@ -666,6 +693,7 @@ 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.
|
||||
@@ -719,7 +747,8 @@ namespace OpenNest.IO
|
||||
// Cut-offs
|
||||
if (p.CutOffs != null)
|
||||
{
|
||||
var sequence = new Dictionary<CutOff, IReadOnlyList<int>>();
|
||||
var places = new Dictionary<CutOff, IReadOnlyList<int>>();
|
||||
var firstPlaces = new Dictionary<CutOff, int>();
|
||||
|
||||
foreach (var cutoffDto in p.CutOffs)
|
||||
{
|
||||
@@ -739,12 +768,12 @@ namespace OpenNest.IO
|
||||
plate.CutOffs.Add(cutoff);
|
||||
|
||||
if (cutoffDto.Segments is { Length: > 0 } segments)
|
||||
sequence[cutoff] = segments;
|
||||
places[cutoff] = segments;
|
||||
else if (cutoffDto.Sequence is int index)
|
||||
sequence[cutoff] = [index];
|
||||
firstPlaces[cutoff] = index;
|
||||
}
|
||||
|
||||
plate.RegenerateCutOffs(new CutOffSettings(), sequence);
|
||||
plate.RegenerateCutOffs(new CutOffSettings(), places, firstPlaces);
|
||||
}
|
||||
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
@@ -353,6 +353,7 @@ 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)
|
||||
@@ -395,12 +396,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
|
||||
@@ -411,7 +412,7 @@ namespace OpenNest.IO
|
||||
StartLimit = cutoff.StartLimit,
|
||||
EndLimit = cutoff.EndLimit,
|
||||
Sequence = segments.Length > 0 ? segments.Min() : null,
|
||||
Segments = consecutive ? null : segments,
|
||||
Segments = segments.Length > 0 ? segments : null,
|
||||
HeadLockOvercut = cutoff.HeadLockOvercut,
|
||||
FarEdgeFirst = cutoff.FarEdgeFirst,
|
||||
}
|
||||
@@ -461,12 +462,26 @@ 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
|
||||
|
||||
@@ -66,6 +66,8 @@ 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 };
|
||||
@@ -121,6 +123,8 @@ 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 };
|
||||
@@ -173,6 +177,8 @@ 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();
|
||||
@@ -240,6 +246,8 @@ 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(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, 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(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, 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=14");
|
||||
Execute(factory.DatabasePath, "PRAGMA user_version=15");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
|
||||
}
|
||||
|
||||
@@ -70,6 +70,36 @@ 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(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, 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(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class MaterialListTests
|
||||
|
||||
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, 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(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
}
|
||||
|
||||
private static NestRecord Record(string name, string material, string grade) => new()
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
}
|
||||
|
||||
private static NestRecord Record(string name, Units units) => new()
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
|
||||
@@ -10,15 +11,19 @@ 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)
|
||||
public ServerFactory(string? databasePath = null, HttpMessageHandler? updateHandler = 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");
|
||||
@@ -36,6 +41,13 @@ 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())));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,4 +62,10 @@ 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(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, 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));
|
||||
|
||||
@@ -294,7 +294,7 @@ public sealed class SetReleaseTests
|
||||
|
||||
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, 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));
|
||||
|
||||
@@ -67,7 +67,7 @@ public sealed class SharedSettingsTests
|
||||
|
||||
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Empty(upgraded.ListSharedSettings());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,6 @@ 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) =>
|
||||
{
|
||||
@@ -98,10 +97,7 @@ internal static class NestArchive
|
||||
Width = System.Math.Min(size.Width, size.Length),
|
||||
Length = System.Math.Max(size.Width, size.Length),
|
||||
Quantity = p.Quantity,
|
||||
StockItemCodes = (p.StockItemCodes ?? new())
|
||||
.TakeWhile(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Take(System.Math.Max(p.Quantity, 0))
|
||||
.ToList(),
|
||||
StockItemCodes = PlateCodes(p).ToList(),
|
||||
ExpectedRemnant = ReadRemnant(p.ExpectedRemnant),
|
||||
};
|
||||
})
|
||||
@@ -121,6 +117,27 @@ 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;
|
||||
|
||||
|
||||
@@ -138,9 +138,10 @@ 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");
|
||||
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");
|
||||
// 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 (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);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ 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.");
|
||||
@@ -220,6 +221,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -342,7 +344,7 @@ public sealed partial class NestDatabase : IDisposable
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = "PRAGMA user_version";
|
||||
var version = (long)command.ExecuteScalar()!;
|
||||
if (version > 13) throw new InvalidOperationException("Database schema is newer than this server.");
|
||||
if (version > 14) throw new InvalidOperationException("Database schema is newer than this server.");
|
||||
if (version == 0)
|
||||
{
|
||||
command.CommandText = """
|
||||
@@ -435,6 +437,8 @@ public sealed partial class NestDatabase : IDisposable
|
||||
command.CommandText = SharedSettingSchema;
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
if (version < 14)
|
||||
AddStockCodes(command);
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ 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();
|
||||
|
||||
@@ -59,6 +67,11 @@ 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) =>
|
||||
{
|
||||
@@ -154,6 +167,8 @@ app.MapDrawingSets();
|
||||
app.MapReleases();
|
||||
app.MapMaterials();
|
||||
app.MapSettings();
|
||||
if (updatesEnabled)
|
||||
app.MapUpdates();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -4,6 +4,18 @@ 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=<path></c> or <c>--database <path></c>, then
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,7 @@ 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
|
||||
@@ -0,0 +1,108 @@
|
||||
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();
|
||||
}
|
||||
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user