mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-29 04:51:05 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:
|
matrix:
|
||||||
project:
|
project:
|
||||||
- OpenNest.Tests
|
- OpenNest.Tests
|
||||||
|
- OpenNest.Desktop.Tests
|
||||||
- OpenNest.Engine.Tests
|
- OpenNest.Engine.Tests
|
||||||
- OpenNest.IO.Tests
|
- OpenNest.IO.Tests
|
||||||
- OpenNest.Server.Tests
|
- OpenNest.Server.Tests
|
||||||
|
- OpenNest.FrontEnd.Tests
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
with:
|
with:
|
||||||
@@ -70,7 +72,7 @@ jobs:
|
|||||||
|
|
||||||
windows-desktop:
|
windows-desktop:
|
||||||
# WinForms tests need a Windows desktop runtime; the Linux job can only
|
# 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
|
runs-on: windows-2022
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
defaults:
|
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
|
# mini dump in TestResults, instead of holding the runner until the
|
||||||
# job timeout.
|
# job timeout.
|
||||||
run: |
|
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
|
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." }
|
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
||||||
}
|
}
|
||||||
@@ -96,6 +96,11 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
artifacts/*.zip
|
artifacts/*.zip
|
||||||
artifacts/*.sha256
|
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
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
|
|||||||
@@ -22,13 +22,15 @@ dotnet build OpenNest.sln
|
|||||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
|
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
|
||||||
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.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.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.Server.Tests/OpenNest.Server.Tests.csproj -c Release
|
||||||
|
dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj -c Release
|
||||||
|
|
||||||
# Windows runtime tests
|
# Windows runtime tests
|
||||||
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
|
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.
|
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.
|
- `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.
|
- 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.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
|
||||||
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
|
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (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.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.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
|
## 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.
|
- `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.
|
- `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.
|
- `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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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:
|
Read the relevant contract before changing its behavior:
|
||||||
|
|
||||||
- [Nest file format](docs/nest-file-format.md)
|
- [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)
|
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
|
||||||
- [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
|
- [Lead-in placement](docs/geometry/lead-in-placement.md) (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
|
- [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
|
- [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)
|
- [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.
|
- [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.
|
- [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.
|
- [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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.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" Version="11.3.0" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" 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
|
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|
||||||
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|
|| 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(
|
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;
|
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)
|
if (options.InputFiles.Count == 0)
|
||||||
{
|
{
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
@@ -197,6 +209,12 @@ static class NestConsole
|
|||||||
ApplyTemplate(plate, options);
|
ApplyTemplate(plate, options);
|
||||||
ApplyOverrides(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);
|
var drawing = ResolveDrawing(nest, options);
|
||||||
|
|
||||||
if (drawing == null)
|
if (drawing == null)
|
||||||
@@ -276,6 +294,9 @@ static class NestConsole
|
|||||||
if (Size.TryParse(args[++i], out var sz))
|
if (Size.TryParse(args[++i], out var sz))
|
||||||
o.PlateSize = sz;
|
o.PlateSize = sz;
|
||||||
break;
|
break;
|
||||||
|
case "--outline" when i + 1 < args.Length:
|
||||||
|
o.OutlineFile = args[++i];
|
||||||
|
break;
|
||||||
case "--check-overlaps":
|
case "--check-overlaps":
|
||||||
o.CheckOverlaps = true;
|
o.CheckOverlaps = true;
|
||||||
break;
|
break;
|
||||||
@@ -526,6 +547,13 @@ static class NestConsole
|
|||||||
|
|
||||||
if (options.PlateSize.HasValue && !hasDxfOnly)
|
if (options.PlateSize.HasValue && !hasDxfOnly)
|
||||||
plate.Size = options.PlateSize.Value;
|
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)
|
static Drawing ResolveDrawing(Nest nest, Options options)
|
||||||
@@ -996,6 +1024,9 @@ static class NestConsole
|
|||||||
--quantity <n> Max parts to place (default: 0 = unlimited)
|
--quantity <n> Max parts to place (default: 0 = unlimited)
|
||||||
--spacing <value> Override part spacing
|
--spacing <value> Override part spacing
|
||||||
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
|
--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)
|
--output <path> Output nest file path (default: <input>-result.nest)
|
||||||
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
|
--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
|
--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 int Quantity;
|
||||||
public double? Spacing;
|
public double? Spacing;
|
||||||
public Size? PlateSize;
|
public Size? PlateSize;
|
||||||
|
public string OutlineFile;
|
||||||
public bool CheckOverlaps;
|
public bool CheckOverlaps;
|
||||||
public bool NoSave;
|
public bool NoSave;
|
||||||
public bool KeepParts;
|
public bool KeepParts;
|
||||||
|
|||||||
@@ -544,6 +544,8 @@ public static class AutomaticCutOffPlanner
|
|||||||
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
|
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
|
||||||
"Sheet length and width must be positive and finite.", nameof(plate));
|
"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));
|
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(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
|
||||||
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
|
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
|
||||||
Nonnegative(plate.EdgeSpacing.Bottom)),
|
Nonnegative(plate.EdgeSpacing.Bottom)),
|
||||||
|
|||||||
@@ -70,9 +70,11 @@ internal enum SupportEntries
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// One automatic start candidate: the owned contour choice plus its preference kind and a
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
|
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
|
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
|
||||||
|
|||||||
@@ -107,16 +107,18 @@ internal static class ContourEntryRanking
|
|||||||
if (target != null)
|
if (target != null)
|
||||||
{
|
{
|
||||||
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
|
// 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 left = p.X - minX;
|
||||||
var right = maxX - p.X;
|
var right = maxX - p.X;
|
||||||
var bottom = p.Y - minY;
|
var bottom = p.Y - minY;
|
||||||
var top = maxY - p.Y;
|
var top = maxY - p.Y;
|
||||||
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
|
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
|
||||||
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
|
var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
|
||||||
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
|
if (facingRight && right <= near) facing++;
|
||||||
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
|
if (!facingRight && left <= near) facing++;
|
||||||
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++;
|
if (facingTop && top <= near) facing++;
|
||||||
|
if (!facingTop && bottom <= near) facing++;
|
||||||
}
|
}
|
||||||
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
|
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
|
||||||
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
|
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ public static class ExecutionMotionReader
|
|||||||
var pos = frame;
|
var pos = frame;
|
||||||
var first = true;
|
var first = true;
|
||||||
var countBefore = moves.Count;
|
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();
|
token.ThrowIfCancellationRequested();
|
||||||
if (--budget < 0)
|
if (--budget < 0)
|
||||||
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
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,
|
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
|
||||||
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
|
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
|
||||||
motion is ArcMove { Rotation: RotationType.CW });
|
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;
|
pos = end;
|
||||||
first = false;
|
first = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,20 +38,31 @@ public sealed class OwnedExecution
|
|||||||
public sealed class ExecutionMotion
|
public sealed class ExecutionMotion
|
||||||
{
|
{
|
||||||
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
|
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
|
||||||
PostVerificationGeometry.Curve curve)
|
PostVerificationGeometry.Curve curve, bool headRaised = false, int? code = null)
|
||||||
{
|
{
|
||||||
Start = start;
|
Start = start;
|
||||||
End = end;
|
End = end;
|
||||||
Rapid = rapid;
|
Rapid = rapid;
|
||||||
Layer = layer;
|
Layer = layer;
|
||||||
Curve = curve;
|
Curve = curve;
|
||||||
|
HeadRaised = headRaised;
|
||||||
|
Code = code;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Vector? Start { get; }
|
public Vector? Start { get; }
|
||||||
public Vector End { get; }
|
public Vector End { get; }
|
||||||
public bool Rapid { get; }
|
public bool Rapid { get; }
|
||||||
public LayerType Layer { 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;
|
public double Length => Curve?.Length ?? 0;
|
||||||
internal PostVerificationGeometry.Curve Curve { get; }
|
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>
|
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
|
||||||
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
|
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
|
||||||
=> CataloguePoints(shape, 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();
|
.ToList();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -763,12 +763,14 @@ public sealed class PreparedContours
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The preferred catalogue points for one contour: for each entity, its convex corner
|
/// 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,
|
/// (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.
|
/// nothing; a single whole circle yields no points at all.
|
||||||
/// </summary>
|
/// </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;
|
var count = shape.Entities.Count;
|
||||||
if (count == 1 && shape.Entities[0] is Circle)
|
if (count == 1 && shape.Entities[0] is Circle)
|
||||||
{
|
{
|
||||||
@@ -792,29 +794,65 @@ public sealed class PreparedContours
|
|||||||
switch (corner.Kind)
|
switch (corner.Kind)
|
||||||
{
|
{
|
||||||
case ContourCuttingStrategy.CornerKind.Convex:
|
case ContourCuttingStrategy.CornerKind.Convex:
|
||||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
|
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
|
||||||
break;
|
break;
|
||||||
case ContourCuttingStrategy.CornerKind.Smooth
|
case ContourCuttingStrategy.CornerKind.Smooth
|
||||||
when entity is Line && Next(i) is Arc:
|
when entity is Line && Next(i) is Arc:
|
||||||
// A line leaving into an arc: the tangent joint. The reverse travel
|
// A line leaving into an arc: the tangent joint. The reverse travel
|
||||||
// order classifies the same joint from the arc, matched below.
|
// 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;
|
break;
|
||||||
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
||||||
// of a plain straight edge are not preferred automatic starts here.
|
// of a plain straight edge are not preferred automatic starts here.
|
||||||
case ContourCuttingStrategy.CornerKind.Smooth
|
case ContourCuttingStrategy.CornerKind.Smooth
|
||||||
when entity is Arc && Next(i) is Line:
|
when entity is Arc && Next(i) is Line:
|
||||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
found.Add(Joint(i, vertex, i));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entity is Line line)
|
if (entity is Line line)
|
||||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
|
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
|
|
||||||
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
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>
|
/// <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
|
/// As <see cref="Check(OwnedExecution, Vector?, int, bool, CancellationToken)"/>, but a
|
||||||
/// finding whose offending motion <paramref name="isFixed"/> owns (by motion index) is
|
/// 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
|
/// 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>
|
/// </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,
|
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
|
||||||
CancellationToken token)
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -67,8 +69,10 @@ public sealed class ReleasedContourState
|
|||||||
moves[0] = moves[0].WithStart(arrival);
|
moves[0] = moves[0].WithStart(arrival);
|
||||||
var findings = new List<PostVerificationFinding>();
|
var findings = new List<PostVerificationFinding>();
|
||||||
var fixedFindings = new List<PostVerificationFinding>();
|
var fixedFindings = new List<PostVerificationFinding>();
|
||||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
|
var raisedFindings = new List<PostVerificationFinding>();
|
||||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
|
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings,
|
||||||
|
raisedFindings: raisedFindings);
|
||||||
|
return (findings.AsReadOnly(), fixedFindings.AsReadOnly(), raisedFindings.AsReadOnly());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -97,7 +101,7 @@ public sealed class ReleasedContourState
|
|||||||
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
||||||
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
|
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
|
||||||
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
|
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 current = new CutGroup();
|
||||||
var unfinished = new List<CutGroup>();
|
var unfinished = new List<CutGroup>();
|
||||||
@@ -166,16 +170,22 @@ public sealed class ReleasedContourState
|
|||||||
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
|
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
|
||||||
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
|
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
|
||||||
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
|
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
|
||||||
if (PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
|
if (!PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
|
||||||
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
continue;
|
||||||
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
|
||||||
$"of part {obstacle.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,
|
Location = (start + move.End) * 0.5,
|
||||||
RapidEnd = move.End,
|
RapidStart = start,
|
||||||
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
RapidEnd = move.End,
|
||||||
}, owned);
|
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)
|
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,
|
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
|
||||||
/// not a corner.
|
/// not a corner.
|
||||||
/// </summary>
|
/// </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 cross = tangentIn.X * tangentOut.Y - tangentIn.Y * tangentOut.X;
|
||||||
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
|
var dot = tangentIn.DotProduct(tangentOut);
|
||||||
var turn = winding == RotationType.CCW ? cross : -cross;
|
var turn = winding == RotationType.CCW ? cross : -cross;
|
||||||
|
|
||||||
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
|
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);
|
var bounds = plate.BoundingBox(includeParts: false);
|
||||||
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
|
// 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,
|
double lineStart,
|
||||||
lineEnd,
|
lineEnd,
|
||||||
@@ -281,6 +282,13 @@ namespace OpenNest
|
|||||||
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
|
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)>();
|
var exclusions = new List<(double Start, double End)>();
|
||||||
|
|
||||||
foreach (var part in plate.Parts)
|
foreach (var part in plate.Parts)
|
||||||
@@ -325,27 +333,71 @@ namespace OpenNest
|
|||||||
}
|
}
|
||||||
|
|
||||||
var segments = new List<(double Start, double End)>();
|
var segments = new List<(double Start, double End)>();
|
||||||
var current = lineStart;
|
foreach (var (spanStart, spanEnd) in spans)
|
||||||
|
|
||||||
foreach (var ex in merged)
|
|
||||||
{
|
{
|
||||||
var clampedStart = System.Math.Max(ex.Start, lineStart);
|
var current = spanStart;
|
||||||
var clampedEnd = System.Math.Min(ex.End, lineEnd);
|
|
||||||
|
|
||||||
if (clampedStart > current)
|
foreach (var ex in merged)
|
||||||
segments.Add((current, clampedStart));
|
{
|
||||||
|
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();
|
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
|
||||||
|
|
||||||
return segments;
|
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 static readonly List<(double Start, double End)> EmptyExclusions = new();
|
||||||
|
|
||||||
private List<(double Start, double End)> GetPartExclusions(
|
private List<(double Start, double End)> GetPartExclusions(
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ namespace OpenNest.Geometry
|
|||||||
// the joint's tangents; otherwise the neighbour reads as a second boundary there.
|
// the joint's tangents; otherwise the neighbour reads as a second boundary there.
|
||||||
internal const double JointTolerance = 1e-6;
|
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 List<Entity> entities;
|
||||||
private readonly int[] loopOf;
|
private readonly int[] loopOf;
|
||||||
private readonly int[] previous;
|
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)
|
private static double SignedArea(List<Entity> entities, int first, int last)
|
||||||
{
|
{
|
||||||
|
|||||||
+33
-4
@@ -98,6 +98,9 @@ namespace OpenNest
|
|||||||
// A turned sheet is the same sheet; any other size is no longer the coded item.
|
// A turned sheet is the same sheet; any other size is no longer the coded item.
|
||||||
if (stockItemCodes.Count > 0 && !SameSheet(size, value))
|
if (stockItemCodes.Count > 0 && !SameSheet(size, value))
|
||||||
stockItemCodes = Array.Empty<string>();
|
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;
|
size = value;
|
||||||
ResolveClampRail();
|
ResolveClampRail();
|
||||||
}
|
}
|
||||||
@@ -105,6 +108,26 @@ namespace OpenNest
|
|||||||
|
|
||||||
private Size size;
|
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) =>
|
private static bool SameSheet(Size a, Size b) =>
|
||||||
a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
|
a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
|
||||||
|
|
||||||
@@ -526,7 +549,9 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
const double oneAndHalfPI = System.Math.PI * 1.5;
|
const double oneAndHalfPI = System.Math.PI * 1.5;
|
||||||
|
|
||||||
|
var turned = Outline?.Rotated90(rotationDirection);
|
||||||
Size = new Size(Size.Length, Size.Width);
|
Size = new Size(Size.Length, Size.Width);
|
||||||
|
Outline = turned;
|
||||||
|
|
||||||
// After Size swap above, new Size.Width = old Length (old X extent),
|
// After Size swap above, new Size.Width = old Length (old X extent),
|
||||||
// new Size.Length = old Width (old Y extent).
|
// new Size.Length = old Width (old Y extent).
|
||||||
@@ -632,10 +657,12 @@ namespace OpenNest
|
|||||||
}
|
}
|
||||||
|
|
||||||
Rotate(System.Math.PI, centerpt);
|
Rotate(System.Math.PI, centerpt);
|
||||||
|
Outline = Outline?.Rotated180();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Rotate(System.Math.PI);
|
Rotate(System.Math.PI);
|
||||||
|
Outline = Outline?.Rotated180();
|
||||||
Quadrant = (Quadrant + 2) % 4;
|
Quadrant = (Quadrant + 2) % 4;
|
||||||
|
|
||||||
if (Quadrant == 0)
|
if (Quadrant == 0)
|
||||||
@@ -841,7 +868,8 @@ namespace OpenNest
|
|||||||
/// </example>
|
/// </example>
|
||||||
public void AutoSize(double roundingFactor = 1.0)
|
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;
|
return;
|
||||||
|
|
||||||
var bounds = Parts.GetBoundingBox();
|
var bounds = Parts.GetBoundingBox();
|
||||||
@@ -890,7 +918,7 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
|
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
|
||||||
{
|
{
|
||||||
if (Parts.Count == 0)
|
if (Parts.Count == 0 || Outline != null)
|
||||||
return default;
|
return default;
|
||||||
|
|
||||||
var bounds = Parts.GetBoundingBox();
|
var bounds = Parts.GetBoundingBox();
|
||||||
@@ -942,12 +970,13 @@ namespace OpenNest
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public double Area()
|
public double Area()
|
||||||
{
|
{
|
||||||
return Size.Width * Size.Length;
|
return Outline?.Area ?? Size.Width * Size.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ namespace OpenNest
|
|||||||
var followsDefaults = trailing != null
|
var followsDefaults = trailing != null
|
||||||
&& trailing.Parts.Count == 0
|
&& trailing.Parts.Count == 0
|
||||||
&& trailing.CutOffs.Count == 0
|
&& trailing.CutOffs.Count == 0
|
||||||
|
&& trailing.Outline == null
|
||||||
&& trailing.Size.Width == defaults.Size.Width
|
&& trailing.Size.Width == defaults.Size.Width
|
||||||
&& trailing.Size.Length == defaults.Size.Length
|
&& trailing.Size.Length == defaults.Size.Length
|
||||||
&& trailing.Quadrant == defaults.Quadrant
|
&& 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
|
/// (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
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
public static PlateRemnant Expected(Plate plate, double minimumDimension)
|
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.
|
// A part with no material (marks only) bounds nothing.
|
||||||
var bounds = plate.Parts
|
var bounds = plate.Parts
|
||||||
.Where(p => !p.BaseDrawing.IsCutOff && p.Program != null)
|
.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) =>
|
Task<int> AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default) =>
|
||||||
throw new NotSupportedException("This repository does not allocate nest names.");
|
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>
|
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
|
||||||
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
|
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ public enum LibraryInsertKind
|
|||||||
/// Decides how one library drawing revision enters a nest, before anything changes:
|
/// 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,
|
/// 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
|
/// 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.
|
/// of the same drawing, or another drawing with the same name refuse it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LibraryDrawingInsert
|
public sealed class LibraryDrawingInsert
|
||||||
@@ -75,7 +76,7 @@ public sealed class LibraryDrawingInsert
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// For a new copy, true when the library drawing's cut pins come with it: the library
|
/// 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.
|
/// added quantity leaves the existing copy as it is.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool KeepPins { get; }
|
public bool KeepPins { get; }
|
||||||
@@ -140,7 +141,12 @@ public sealed class LibraryDrawingInsert
|
|||||||
var drawing = package.Drawing;
|
var drawing = package.Drawing;
|
||||||
drawing.Name = record.Name;
|
drawing.Name = record.Name;
|
||||||
drawing.Customer = record.Customer;
|
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);
|
(record.Settings ?? new DrawingNestingSettings()).ApplyTo(drawing);
|
||||||
drawing.Quantity.Required = quantity;
|
drawing.Quantity.Required = quantity;
|
||||||
drawing.Library = link;
|
drawing.Library = link;
|
||||||
@@ -177,21 +183,24 @@ public sealed class LibraryDrawingInsert
|
|||||||
private static LibraryDrawingInsert Refuse(Nest nest, string reason) =>
|
private static LibraryDrawingInsert Refuse(Nest nest, string reason) =>
|
||||||
new(nest, LibraryInsertKind.Refused, null, 0, keepPins: false, Array.Empty<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)
|
private static List<string> Differences(Nest nest, DrawingLibraryRecord record)
|
||||||
{
|
{
|
||||||
var differences = new List<string>();
|
var differences = new List<string>();
|
||||||
var material = nest.Material ?? new Material();
|
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)})");
|
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)})");
|
differences.Add($"grade {Text(record.MaterialGrade)} (nest {Text(material.Grade)})");
|
||||||
var thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness);
|
var thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness);
|
||||||
var nestThickness = DrawingLibraryRules.NormalizeThickness(nest.Thickness);
|
var nestThickness = DrawingLibraryRules.NormalizeThickness(nest.Thickness);
|
||||||
if (thickness != nestThickness)
|
if (thickness != 0 && thickness != nestThickness)
|
||||||
differences.Add($"thickness {Number(thickness)} (nest {Number(nestThickness)})");
|
differences.Add($"thickness {Number(thickness)} (nest {Number(nestThickness)})");
|
||||||
return differences;
|
return differences;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsSet(string? value) => SharedListNames.Key(value).Length > 0;
|
||||||
|
|
||||||
private static string Describe(DrawingLibraryRecord record) =>
|
private static string Describe(DrawingLibraryRecord record) =>
|
||||||
string.IsNullOrWhiteSpace(record.Customer)
|
string.IsNullOrWhiteSpace(record.Customer)
|
||||||
? $"library drawing '{record.Name}' revision {record.Revision}"
|
? $"library drawing '{record.Name}' revision {record.Revision}"
|
||||||
|
|||||||
@@ -73,6 +73,25 @@ public static class MachineCuttingDefaults
|
|||||||
return true;
|
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>
|
/// <summary>
|
||||||
/// Replaces the part clearance, overtravel, minimum segment and direction of
|
/// 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.
|
/// <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 Drops => Rows.Where(r => r.IsDrop).Sum(r => r.Quantity);
|
||||||
public int SubstituteRows => Rows.Count(r => r.IsSubstitute);
|
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
|
public string Summary
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -64,6 +69,8 @@ public sealed record LedgerStockMapping(
|
|||||||
var text = $"Ledger: {Plural(SheetRows, "sheet size")}, {Plural(Drops, "drop")}";
|
var text = $"Ledger: {Plural(SheetRows, "sheet size")}, {Plural(Drops, "drop")}";
|
||||||
if (SubstituteRows > 0)
|
if (SubstituteRows > 0)
|
||||||
text += $", {Plural(SubstituteRows, "substitute row")}";
|
text += $", {Plural(SubstituteRows, "substitute row")}";
|
||||||
|
if (OnOtherNests > 0)
|
||||||
|
text += $" ({OnOtherNests} on other nests)";
|
||||||
return skipped.Count == 0
|
return skipped.Count == 0
|
||||||
? text
|
? text
|
||||||
: $"{text} ({SkippedUnknownShape + SkippedTooSmall} skipped: {string.Join(", ", skipped)})";
|
: $"{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;
|
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(
|
public async Task<NestRecord> UpdateFileAsync(
|
||||||
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
|
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")]
|
[Collection("Cutout pipeline registry")]
|
||||||
public class CutoutPipelinePreviewTests
|
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));
|
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
|
||||||
return new NestPipelineRequest(engine,
|
return new NestPipelineRequest(engine,
|
||||||
new[] { new NestItem { Drawing = frame, Quantity = 1 },
|
new[] { new NestItem { Drawing = frame, Quantity = 1 },
|
||||||
@@ -112,7 +113,8 @@ public class CutoutPipelinePreviewTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
|
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.Items[1].Quantity = 2;
|
||||||
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } };
|
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } };
|
||||||
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
|
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
|
||||||
@@ -171,7 +173,8 @@ public class CutoutPipelinePreviewTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
|
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;
|
request.Items[1].Quantity = 2;
|
||||||
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
|
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
|
||||||
{
|
{
|
||||||
@@ -273,7 +276,8 @@ public class CutoutPipelinePreviewTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
|
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
|
var request = initial with
|
||||||
{
|
{
|
||||||
Items = new[] { initial.Items[0], initial.Items[1],
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -212,9 +212,10 @@ public sealed class CuttingPlanBatch
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Safest first, then fastest: the replayed safest route from the free order and from the
|
/// 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
|
/// current order, whichever breaks fewer checks (most severe first), then raises the head
|
||||||
/// travel. A route whose planner-chosen motions pass every check is taken at once, even
|
/// fewer times, then has less rapid travel. A route whose planner-chosen motions pass every
|
||||||
/// when fixed lead-ins still warn. Null when neither walk finds a route.
|
/// check head-down is taken at once, even when fixed lead-ins still warn. Null when neither
|
||||||
|
/// walk finds a route.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static (CuttingPlanResult Result, bool FreeOrder)? SafestRoute(Entry entry, CancellationToken token)
|
private static (CuttingPlanResult Result, bool FreeOrder)? SafestRoute(Entry entry, CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -225,10 +226,9 @@ public sealed class CuttingPlanBatch
|
|||||||
{
|
{
|
||||||
if (CuttingPlanService.PlanSafestRoute(snapshot, token) is not { } candidate)
|
if (CuttingPlanService.PlanSafestRoute(snapshot, token) is not { } candidate)
|
||||||
continue;
|
continue;
|
||||||
if (!candidate.IsSafestRouteFallback)
|
if (!candidate.IsSafestRouteFallback && candidate.HeadRaises == 0)
|
||||||
return (candidate, freeOrder);
|
return (candidate, freeOrder);
|
||||||
if (best is not { } known || candidate.SafestRoute < known.Result.SafestRoute
|
if (best is not { } known || Safer(candidate, known.Result))
|
||||||
|| candidate.SafestRoute == known.Result.SafestRoute && candidate.RapidDistance < known.Result.RapidDistance)
|
|
||||||
best = (candidate, freeOrder);
|
best = (candidate, freeOrder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,6 +239,17 @@ public sealed class CuttingPlanBatch
|
|||||||
return best;
|
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,
|
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
|
||||||
int firstPassBudget = FirstPassBudget)
|
int firstPassBudget = FirstPassBudget)
|
||||||
{
|
{
|
||||||
@@ -447,6 +458,8 @@ public sealed class CuttingPlanProposal
|
|||||||
Quadrant = source.Quadrant,
|
Quadrant = source.Quadrant,
|
||||||
PartSpacing = source.PartSpacing,
|
PartSpacing = source.PartSpacing,
|
||||||
EdgeSpacing = source.EdgeSpacing,
|
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.
|
// Machine clamps stay visible while inspecting the proposal; zones are immutable.
|
||||||
foreach (var zone in source.ClampZones)
|
foreach (var zone in source.ClampZones)
|
||||||
@@ -512,8 +525,11 @@ public sealed class CuttingPlanProposal
|
|||||||
+ $"regenerated, {kept} kept as is; rapid travel "
|
+ $"regenerated, {kept} kept as is; rapid travel "
|
||||||
+ $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}. {order}"
|
+ $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}. {order}"
|
||||||
+ (plate.ReversedCutOffCount == 0 ? ""
|
+ (plate.ReversedCutOffCount == 0 ? ""
|
||||||
: $" {Count(plate.ReversedCutOffCount, "cutoff segment")} at the far edge change direction.");
|
: $" {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).
|
+ (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)))
|
foreach (var line in Limit(result.Findings.Select(DescribeFinding)))
|
||||||
yield return line;
|
yield return line;
|
||||||
yield break;
|
yield break;
|
||||||
|
|||||||
@@ -367,6 +367,12 @@ public sealed class CuttingPlanResult
|
|||||||
/// <summary>Whether this is a safest-route fallback rather than a route that passed every check.</summary>
|
/// <summary>Whether this is a safest-route fallback rather than a route that passed every check.</summary>
|
||||||
public bool IsSafestRouteFallback => SafestRoute != null;
|
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>
|
/// <summary>The captured input this result was planned from; binds Apply to its freshness record.</summary>
|
||||||
internal CuttingPlanSnapshot Snapshot { get; set; }
|
internal CuttingPlanSnapshot Snapshot { get; set; }
|
||||||
}
|
}
|
||||||
@@ -350,10 +350,13 @@ public static class CuttingPlanService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The safest route the greedy walk finds, independently replayed; null when there is none.
|
/// 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
|
/// Its planner-chosen rapids that still cross parts already cut raise the head
|
||||||
/// carrying every violation, applicable only with explicit acceptance. A route the replay
|
/// (<see cref="RaisedRapids"/>): only here, after the strict search found no route avoiding
|
||||||
/// cannot check completely, or with any motion into material (a lead that does not fit,
|
/// them. A route that then passes every check is Ready, listing its raises. One that does not
|
||||||
/// a cut-order or nested-lead conflict), is never offered: the strict refusal stands.
|
/// 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>
|
/// </summary>
|
||||||
internal static CuttingPlanResult PlanSafestRoute(CuttingPlanSnapshot snapshot, CancellationToken token)
|
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);
|
var order = JointCuttingPlanSearch.SafestRoute(snapshot.WithSearchLimits(budget, snapshot.MaxEntries), token);
|
||||||
if (order == null)
|
if (order == null)
|
||||||
return 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);
|
var result = ReplayPrograms(snapshot, order, 0, token, safestRoute: true);
|
||||||
if (result.Status is not (CuttingPlanStatus.Ready or CuttingPlanStatus.BestEffort)
|
if (result.Status is not (CuttingPlanStatus.Ready or CuttingPlanStatus.BestEffort)
|
||||||
|| result.SafestRoute is { Severe: > 0 })
|
|| result.SafestRoute is { Severe: > 0 })
|
||||||
@@ -397,6 +408,9 @@ public static class CuttingPlanService
|
|||||||
// Violations inside fixed emissions: accepted as given, reported, never refused (D1a).
|
// 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.
|
// Capture adds the drawing pins it could not honour; their contours were planned normally.
|
||||||
var warnings = new List<CuttingPlanFinding>(snapshot.Findings);
|
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 materialOwners = snapshot.Placements.Where(p => !p.IsCutOff).ToArray();
|
||||||
var materials = materialOwners.Select(p => p.Material).ToArray();
|
var materials = materialOwners.Select(p => p.Material).ToArray();
|
||||||
foreach (var proposal in order)
|
foreach (var proposal in order)
|
||||||
@@ -433,8 +447,10 @@ public static class CuttingPlanService
|
|||||||
findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions);
|
findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions);
|
||||||
}
|
}
|
||||||
var isFixed = proposal.FixedMotions(execution);
|
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);
|
source.IsCutOff, isFixed, token);
|
||||||
|
raises.AddRange(RaisedRapids.Notes(snapshot, source, raised));
|
||||||
|
raisedRapids += RaisedRapids.Count(raised);
|
||||||
findings.AddRange(JointCuttingPlanSearch.Map(snapshot, strict));
|
findings.AddRange(JointCuttingPlanSearch.Map(snapshot, strict));
|
||||||
warnings.AddRange(fixedFindings.Select(f => FixedLeadWarnings.FromChecker(snapshot, source, f)));
|
warnings.AddRange(fixedFindings.Select(f => FixedLeadWarnings.FromChecker(snapshot, source, f)));
|
||||||
var fixedLeads = new List<FixedLeadFailure>();
|
var fixedLeads = new List<FixedLeadFailure>();
|
||||||
@@ -464,12 +480,14 @@ public static class CuttingPlanService
|
|||||||
}
|
}
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
// Support tabs that cannot hold their part level are noted, never refused.
|
// 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))
|
if (safestRoute && findings.Count != 0 && findings.All(f => f.Kind != PostVerificationKind.Incomplete))
|
||||||
return new(CuttingPlanStatus.BestEffort, order, findings.Concat(warnings).Concat(notes).Distinct(),
|
return new(CuttingPlanStatus.BestEffort, order, findings.Concat(warnings).Concat(notes).Distinct(),
|
||||||
expansions, distance)
|
expansions, distance)
|
||||||
{
|
{
|
||||||
SafestRoute = RouteSeverity.Of(findings.Distinct().Select(f => f.Kind)),
|
SafestRoute = RouteSeverity.Of(findings.Distinct().Select(f => f.Kind)),
|
||||||
|
HeadRaises = raisedRapids,
|
||||||
};
|
};
|
||||||
if (findings.Count != 0)
|
if (findings.Count != 0)
|
||||||
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
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.
|
// UNVERIFIED: never Ready, applicable only through explicit warning acceptance.
|
||||||
if (warnings.Count != 0)
|
if (warnings.Count != 0)
|
||||||
return new(CuttingPlanStatus.BestEffort, order, warnings.Concat(notes).Distinct(), expansions,
|
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,
|
return new(CuttingPlanStatus.Ready, order, notes, expansions: expansions,
|
||||||
rapidDistance: distance, independentlyReplayed: true);
|
rapidDistance: distance, independentlyReplayed: true)
|
||||||
|
{ HeadRaises = raisedRapids };
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CuttingPlanFinding DependencyFinding(CuttingPlanSnapshot snapshot,
|
private static CuttingPlanFinding DependencyFinding(CuttingPlanSnapshot snapshot,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ internal static class JointCuttingPlanSearch
|
|||||||
{
|
{
|
||||||
var inserted = CutOffInsertion.Insert(snapshot, outcome.Order, attached, walk.CountExpansion, token);
|
var inserted = CutOffInsertion.Insert(snapshot, outcome.Order, attached, walk.CountExpansion, token);
|
||||||
if (inserted != null)
|
if (inserted != null)
|
||||||
return walk.Ready(inserted);
|
return walk.Ready(walk.FollowSegments(inserted));
|
||||||
}
|
}
|
||||||
catch (BudgetExceededException)
|
catch (BudgetExceededException)
|
||||||
{
|
{
|
||||||
@@ -195,27 +195,43 @@ internal static class JointCuttingPlanSearch
|
|||||||
}, token);
|
}, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Cut the blocked part before every part whose cut contour its approach crossed", unless that
|
// When the approach crossed the part cut just before the one it left from, the tool went on
|
||||||
// would contradict an order already required. False when nothing new was learned.
|
// 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)
|
private static bool Learn(Attempt attempt, HashSet<int>[] prerequisites)
|
||||||
{
|
{
|
||||||
if (attempt.Blocked is not int blocked)
|
var rules = Rules(attempt, prerequisites).ToArray();
|
||||||
return false;
|
foreach (var (first, then) in rules)
|
||||||
var learned = false;
|
prerequisites[then].Add(first);
|
||||||
foreach (var crossed in attempt.Crossed.Order())
|
return rules.Length != 0;
|
||||||
{
|
|
||||||
if (crossed == blocked || prerequisites[crossed].Contains(blocked) || Precedes(crossed, blocked, prerequisites))
|
|
||||||
continue;
|
|
||||||
prerequisites[crossed].Add(blocked);
|
|
||||||
learned = true;
|
|
||||||
}
|
|
||||||
return learned;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// True when a crossing of the blocked part's approach would teach a new order rule.
|
// 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) =>
|
private static bool CanLearn(Attempt attempt, HashSet<int>[] prerequisites) =>
|
||||||
attempt.Blocked is int blocked && attempt.Crossed.Any(crossed => crossed != blocked
|
Rules(attempt, prerequisites).Any();
|
||||||
&& !prerequisites[crossed].Contains(blocked) && !Precedes(crossed, blocked, prerequisites));
|
|
||||||
|
// 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.
|
// True when 'first' must already come before 'second' through the prerequisite chain.
|
||||||
private static bool Precedes(int first, int second, HashSet<int>[] prerequisites)
|
private static bool Precedes(int first, int second, HashSet<int>[] prerequisites)
|
||||||
@@ -334,10 +350,10 @@ internal static class JointCuttingPlanSearch
|
|||||||
}
|
}
|
||||||
// A cutoff is a fixed program cut the same way wherever it goes, so replanning
|
// 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
|
// 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
|
if (prerequisites != null && sequence != null && attempt.Blocked is int blocked
|
||||||
&& snapshot.Placements[blocked] is { IsCutOff: true, SeparatesSheet: false }
|
&& snapshot.Placements[blocked].IsCutOff && CanLearn(attempt, prerequisites))
|
||||||
&& CanLearn(attempt, prerequisites))
|
|
||||||
return attempt;
|
return attempt;
|
||||||
if (attempt.Advance(node))
|
if (attempt.Advance(node))
|
||||||
progressExpansions = Expansions;
|
progressExpansions = Expansions;
|
||||||
@@ -523,8 +539,9 @@ internal static class JointCuttingPlanSearch
|
|||||||
CountExpansion(source);
|
CountExpansion(source);
|
||||||
var checker = node.Checker.Copy();
|
var checker = node.Checker.Copy();
|
||||||
if (Check(cut, cut.Execution, node.Position, checker, attempt))
|
if (Check(cut, cut.Execution, node.Position, checker, attempt))
|
||||||
// A segment is transparent: the part after it ranks its entries and hole route
|
// 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.
|
// 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,
|
yield return new(new([.. node.Order, cut], cut.Execution.DeparturePoint, checker, null, node,
|
||||||
IsSegment(cut) ? node.RoutePosition : null),
|
IsSegment(cut) ? node.RoutePosition : null),
|
||||||
cut.Execution.RapidDistanceFrom(node.Position), cut.SourceOrdinal, -1, -1);
|
cut.Execution.RapidDistanceFrom(node.Position), cut.SourceOrdinal, -1, -1);
|
||||||
@@ -559,7 +576,7 @@ internal static class JointCuttingPlanSearch
|
|||||||
}
|
}
|
||||||
var preference = node.Active?.Preference;
|
var preference = node.Active?.Preference;
|
||||||
if (node.Active?.Perimeter is { } outside && preference == null)
|
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 choices = node.Active?.Choices ?? [];
|
||||||
var arrival = node.Active?.Arrival ?? node.Position;
|
var arrival = node.Active?.Arrival ?? node.Position;
|
||||||
var before = node.Active?.Before ?? node.Checker;
|
var before = node.Active?.Before ?? node.Checker;
|
||||||
@@ -1216,12 +1233,13 @@ internal static class JointCuttingPlanSearch
|
|||||||
return ordered;
|
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 prepared = source.Prepared;
|
||||||
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
|
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 centres = CuttingHoleOrder.Centres(prepared, localArrival, token);
|
||||||
var endpoint = perimeter.Point;
|
var endpoint = perimeter.Point;
|
||||||
var entries = new Dictionary<int, ContourChoice>();
|
var entries = new Dictionary<int, ContourChoice>();
|
||||||
@@ -1255,6 +1273,72 @@ internal static class JointCuttingPlanSearch
|
|||||||
return new(route, entries, pierces, endpoint);
|
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.
|
// The placements cut at a node, and the excluded ones, which count as cut throughout.
|
||||||
private HashSet<int> Finished(Node node)
|
private HashSet<int> Finished(Node node)
|
||||||
{
|
{
|
||||||
@@ -1504,6 +1588,9 @@ internal static class JointCuttingPlanSearch
|
|||||||
/// <summary>The part cut last before the deepest boundary, or null at the start point.</summary>
|
/// <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;
|
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>
|
/// <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
|
internal int? Blocked => sequence == null || Deepest == null || Deepest.Order.Length >= sequence.Length ? null
|
||||||
: sequence[Deepest.Order.Length];
|
: sequence[Deepest.Order.Length];
|
||||||
@@ -1568,8 +1655,8 @@ internal static class JointCuttingPlanSearch
|
|||||||
ContourChoice Perimeter = null, HolePreference Preference = null);
|
ContourChoice Perimeter = null, HolePreference Preference = null);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A search state; Previous links a whole-part boundary to the boundary before it. Anchor,
|
/// 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
|
/// when set, is where the tool was before the cut-off segments cut last: outside entries
|
||||||
/// routes rank from <see cref="RoutePosition"/>, every rapid is checked from Position.
|
/// rank from <see cref="RoutePosition"/>; hole routes and every rapid start from Position.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
|
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
|
||||||
ActivePart Active, Node Previous, Vector? Anchor = null)
|
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.EdgeSpacing,
|
||||||
plate.Quadrant,
|
plate.Quadrant,
|
||||||
cost: null,
|
cost: null,
|
||||||
clampZones: new List<ClampZone>(plate.ClampZones)
|
clampZones: new List<ClampZone>(plate.ClampZones),
|
||||||
|
outline: plate.Outline
|
||||||
)
|
)
|
||||||
{ ClampRail = plate.ClampRail };
|
{ ClampRail = plate.ClampRail };
|
||||||
}
|
}
|
||||||
@@ -126,6 +127,7 @@ public static class DrawingJobMapper
|
|||||||
// them into the placement frame through ClampZoneFrame, exactly as the stock did.
|
// them into the placement frame through ClampZoneFrame, exactly as the stock did.
|
||||||
foreach (var zone in stock.SheetClampZones)
|
foreach (var zone in stock.SheetClampZones)
|
||||||
plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds));
|
plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds));
|
||||||
|
plate.Outline = stock.Outline;
|
||||||
return plate;
|
return plate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,8 +377,9 @@ internal static class ClampRemovalRecovery
|
|||||||
var e = stock.EdgeSpacing;
|
var e = stock.EdgeSpacing;
|
||||||
var zones = string.Join(",", stock.SheetClampZones.Select(z =>
|
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}")));
|
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(
|
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)
|
private sealed class ProbeBudget(int solves)
|
||||||
@@ -428,7 +429,8 @@ internal static class ClampRemovalRecovery
|
|||||||
stock.EdgeSpacing,
|
stock.EdgeSpacing,
|
||||||
stock.Quadrant,
|
stock.Quadrant,
|
||||||
stock.Cost,
|
stock.Cost,
|
||||||
sheetZones
|
sheetZones,
|
||||||
|
stock.Outline
|
||||||
)
|
)
|
||||||
{ ClampRail = stock.ClampRail };
|
{ ClampRail = stock.ClampRail };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Clipper2Lib;
|
|||||||
using OpenNest.Engine.BestFit;
|
using OpenNest.Engine.BestFit;
|
||||||
using OpenNest.Engine.Jobs.Adapters;
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
using OpenNest.Engine.Jobs.Placement;
|
using OpenNest.Engine.Jobs.Placement;
|
||||||
|
using OpenNest.Engine.NestingEngines.Irregular;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
|
||||||
@@ -189,13 +190,7 @@ internal static class CutoutLatticeFill
|
|||||||
.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).FirstOrDefault();
|
.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).FirstOrDefault();
|
||||||
if (grown == null || grown.Count < 3)
|
if (grown == null || grown.Count < 3)
|
||||||
return new PathsD();
|
return new PathsD();
|
||||||
var reflected = new PathD(grown.Select(p => new PointD(-p.x, -p.y)));
|
return PackBoundary.InnerFit(grown, new PathsD { hole });
|
||||||
// Clipper.MinkowskiSum rounds to two decimals; pass the job precision explicitly.
|
|
||||||
var forbidden = Clipper.Union(Minkowski.Sum(reflected, hole, true, Precision),
|
|
||||||
new PathsD { Clipper.TranslatePath(reflected, hole[0].x, hole[0].y) }, FillRule.NonZero, Precision);
|
|
||||||
var anchor = grown[0];
|
|
||||||
return Clipper.Difference(new PathsD { Clipper.TranslatePath(hole, -anchor.x, -anchor.y) }, forbidden,
|
|
||||||
FillRule.NonZero, Precision);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Drops copies that fail the production clearance check against the frame; any
|
/// <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.
|
// 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
|
var outline = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g
|
||||||
? System.Math.Abs(g.Perimeter.Area()) : double.PositiveInfinity);
|
? 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 bundles = new Dictionary<string, Bundle>(StringComparer.Ordinal);
|
||||||
var byFrame = new Dictionary<string, List<NestJobPart>>(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
|
// 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],
|
.Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id],
|
||||||
p.Priority, LocalRotation(p.Rotation, angle)))
|
p.Priority, LocalRotation(p.Rotation, angle)))
|
||||||
.ToArray();
|
.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
|
var nested = CutoutCirclePacker.RoundHole(profile.Cutouts[hole]) == null
|
||||||
? Array.Empty<NestJobPart>()
|
? 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)
|
if (flat.Length == 0 && nested.Length == 0)
|
||||||
continue;
|
continue;
|
||||||
foreach (var pose in Propose(frame, hole, flat, nested))
|
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
|
/// <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;
|
/// 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
|
/// 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.
|
/// All accepted poses are clearance-checked.
|
||||||
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
|
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
|
||||||
internal static class CutoutRouter
|
internal static class CutoutRouter
|
||||||
@@ -32,7 +33,8 @@ internal static class CutoutRouter
|
|||||||
Fill(frame, cutoutIndex, inserts, spacing, null, memo, token);
|
Fill(frame, cutoutIndex, inserts, spacing, null, memo, token);
|
||||||
|
|
||||||
/// <summary>Optional internal diagnostic receives only poses from the chosen proposal,
|
/// <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,
|
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||||
IReadOnlyList<NestJobPart> inserts, double spacing,
|
IReadOnlyList<NestJobPart> inserts, double spacing,
|
||||||
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default) =>
|
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
|
// 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).
|
// when demand exceeds what fits (every surplus copy is searched for and not found).
|
||||||
if (CutoutCirclePacker.Applies(hole, inserts))
|
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 accepted = new List<NestJobPlacement>();
|
||||||
|
var geometries = new Dictionary<string, JobPartGeometry>(StringComparer.Ordinal);
|
||||||
var observed = new List<(NestJobPlacement Pose, bool Lattice)>();
|
var observed = new List<(NestJobPlacement Pose, bool Lattice)>();
|
||||||
var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
|
var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
|
||||||
var holeArea = System.Math.Abs(hole.Area());
|
var holeArea = System.Math.Abs(hole.Area());
|
||||||
@@ -77,6 +80,7 @@ internal static class CutoutRouter
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var geometry = JobPartGeometry.Read(part.Geometry);
|
var geometry = JobPartGeometry.Read(part.Geometry);
|
||||||
|
geometries[part.Id] = geometry;
|
||||||
var ratio = geometry.MaterialArea / holeArea;
|
var ratio = geometry.MaterialArea / holeArea;
|
||||||
// Unsupported or degenerate material cannot establish a useful routing ratio;
|
// Unsupported or degenerate material cannot establish a useful routing ratio;
|
||||||
// NFP retains its own fail-closed checks and the bounded lattice is not tried.
|
// NFP retains its own fail-closed checks and the bounded lattice is not tried.
|
||||||
@@ -148,11 +152,20 @@ internal static class CutoutRouter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
token.ThrowIfCancellationRequested();
|
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)
|
foreach (var (pose, lattice) in observed)
|
||||||
observeAccepted?.Invoke(pose, lattice);
|
observeAccepted?.Invoke(pose, lattice);
|
||||||
return accepted.ToArray();
|
return accepted.ToArray();
|
||||||
|
|
||||||
IReadOnlyList<NestJobPlacement> Circles(IReadOnlyList<NestJobPlacement> poses)
|
IReadOnlyList<NestJobPlacement> Observe(IReadOnlyList<NestJobPlacement> poses)
|
||||||
{
|
{
|
||||||
foreach (var pose in poses)
|
foreach (var pose in poses)
|
||||||
observeAccepted?.Invoke(pose, false);
|
observeAccepted?.Invoke(pose, false);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public static class NestJobCost
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static double NetSheetArea(NestJob job, NestJobPlateResult sheet)
|
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;
|
var minimum = job.Options.MinimumSalvageDimension;
|
||||||
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0)
|
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0)
|
||||||
return area;
|
return area;
|
||||||
@@ -84,16 +84,18 @@ public static class NestJobCost
|
|||||||
private static double NetSheetArea(
|
private static double NetSheetArea(
|
||||||
NestJobOptions options, NestPlateStock stock, double left, double bottom, double right, double top)
|
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;
|
var minimum = options.MinimumSalvageDimension;
|
||||||
if (options.SalvageRate == 0 || minimum <= 0)
|
if (options.SalvageRate == 0 || minimum <= 0)
|
||||||
return area;
|
return area;
|
||||||
return area - options.SalvageRate * LargestOffcut(stock, left, bottom, right, top, minimum);
|
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(
|
private static double LargestOffcut(
|
||||||
NestPlateStock stock, double left, double bottom, double right, double top, double minimum) =>
|
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;
|
is { } offcut ? offcut.Length * offcut.Width : 0;
|
||||||
|
|
||||||
public static bool UsesExplicitCosts(NestJob job) =>
|
public static bool UsesExplicitCosts(NestJob job) =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using Clipper2Lib;
|
||||||
using OpenNest.Engine.Jobs.Placement;
|
using OpenNest.Engine.Jobs.Placement;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
@@ -120,7 +121,12 @@ internal static class NestJobPlacementValidator
|
|||||||
foreach (var cutout in shape.Cutouts)
|
foreach (var cutout in shape.Cutouts)
|
||||||
if (!FitsWorkArea(cutout, workArea))
|
if (!FitsWorkArea(cutout, workArea))
|
||||||
return false;
|
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)
|
private static bool FitsWorkArea(Shape contour, Box workArea)
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ public sealed class NestJobRunner : INestingEngine
|
|||||||
var stock = job.Plates[index];
|
var stock = job.Plates[index];
|
||||||
if (stock.Quantity is int quantity && used[stock.Id] >= quantity)
|
if (stock.Quantity is int quantity && used[stock.Id] >= quantity)
|
||||||
continue;
|
continue;
|
||||||
|
if (stock.Outline != null)
|
||||||
|
continue; // Single-plate fill strategies fill rectangles only.
|
||||||
hasAvailableStock = true;
|
hasAvailableStock = true;
|
||||||
var request = new PlatePlacementRequest(
|
var request = new PlatePlacementRequest(
|
||||||
stock,
|
stock,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Clipper2Lib;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Engine.Jobs.Adapters;
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
@@ -394,6 +395,7 @@ public static class NestLayoutCheck
|
|||||||
&& expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top)
|
&& expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top)
|
||||||
&& expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom)
|
&& expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom)
|
||||||
&& expected.Quadrant == actual.Quadrant
|
&& expected.Quadrant == actual.Quadrant
|
||||||
|
&& Equals(expected.Outline, actual.Outline)
|
||||||
&& SameZones(expected.ClampZones, actual.ClampZones)
|
&& SameZones(expected.ClampZones, actual.ClampZones)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -465,6 +467,7 @@ public static class NestLayoutCheck
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var workArea = plate.WorkArea();
|
var workArea = plate.WorkArea();
|
||||||
|
var stock = plate.Outline == null ? null : DrawingJobMapper.FromPlate("plate", plate, null);
|
||||||
|
|
||||||
foreach (var part in parts)
|
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 "
|
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area "
|
||||||
+ $"of a {plate.Size} plate"
|
+ $"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 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);
|
var placedArea = parts.Sum(p => p.BaseDrawing.Area);
|
||||||
|
|
||||||
if (placedArea > budget + Tolerance.Epsilon)
|
if (placedArea > budget + Tolerance.Epsilon)
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ public static class NestPipelineCommit
|
|||||||
continue;
|
continue;
|
||||||
var plate = nextPlate();
|
var plate = nextPlate();
|
||||||
plate.Size = proposed.Stock.Size;
|
plate.Size = proposed.Stock.Size;
|
||||||
|
plate.Outline = proposed.Stock.Outline;
|
||||||
plate.PartSpacing = proposed.Stock.PartSpacing;
|
plate.PartSpacing = proposed.Stock.PartSpacing;
|
||||||
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
|
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
|
||||||
plate.Quadrant = proposed.Stock.Quadrant;
|
plate.Quadrant = proposed.Stock.Quadrant;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Clipper2Lib;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Engine.Jobs;
|
namespace OpenNest.Engine.Jobs;
|
||||||
@@ -22,16 +24,19 @@ public sealed class NestPlateStock
|
|||||||
|
|
||||||
public NestPlateStock(string id, Size size, int? quantity, double partSpacing,
|
public NestPlateStock(string id, Size size, int? quantity, double partSpacing,
|
||||||
Spacing edgeSpacing, int quadrant, double? cost,
|
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))
|
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.");
|
throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null.");
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||||
if (quantity < 0)
|
if (quantity < 0)
|
||||||
throw new ArgumentOutOfRangeException(nameof(quantity));
|
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;
|
Cost = cost;
|
||||||
Id = id;
|
Id = id;
|
||||||
Size = size;
|
Size = size;
|
||||||
|
Outline = outline;
|
||||||
Quantity = quantity;
|
Quantity = quantity;
|
||||||
PartSpacing = partSpacing;
|
PartSpacing = partSpacing;
|
||||||
EdgeSpacing = edgeSpacing;
|
EdgeSpacing = edgeSpacing;
|
||||||
@@ -92,8 +97,50 @@ public sealed class NestPlateStock
|
|||||||
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
|
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
|
||||||
public double? Cost { get; }
|
public double? Cost { get; }
|
||||||
public string Id { 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; }
|
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>
|
/// <summary>Available physical sheets: null is unlimited, zero is legal but unavailable.</summary>
|
||||||
public int? Quantity { get; }
|
public int? Quantity { get; }
|
||||||
public double PartSpacing { get; }
|
public double PartSpacing { get; }
|
||||||
@@ -106,8 +153,9 @@ public sealed class NestPlateStock
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Box WorkArea => new(workArea.X, workArea.Y, workArea.Length, workArea.Width);
|
public Box WorkArea => new(workArea.X, workArea.Y, workArea.Length, workArea.Width);
|
||||||
|
|
||||||
/// <summary>Full sheet area before edge spacing is removed.</summary>
|
/// <summary>Full sheet area before edge spacing is removed: the material inside the
|
||||||
public double Area => Size.Width * Size.Length;
|
/// <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>
|
/// <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) =>
|
public bool Fits(double width, double height, double epsilon = 1e-9) =>
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ public static class PlateFillService
|
|||||||
"Horizontal Remnant",
|
"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(
|
public static List<Part> FillItem(
|
||||||
string strategy,
|
string strategy,
|
||||||
Plate plate,
|
Plate plate,
|
||||||
@@ -431,6 +441,10 @@ public static class PlateFillService
|
|||||||
private static PlateFiller RequireFiller(string strategy, Plate plate)
|
private static PlateFiller RequireFiller(string strategy, Plate plate)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(strategy);
|
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),
|
// An explicit empty string is an unknown strategy; only ResolveStrategy(string),
|
||||||
// used for caller selections, maps null/empty to Fill.
|
// used for caller selections, maps null/empty to Fill.
|
||||||
ResolveStrategy(strategy, allowEmpty: false);
|
ResolveStrategy(strategy, allowEmpty: false);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ internal static class RepeatedPlateGrouping
|
|||||||
&& Bits(a.EdgeSpacing.Top) == Bits(b.EdgeSpacing.Top)
|
&& Bits(a.EdgeSpacing.Top) == Bits(b.EdgeSpacing.Top)
|
||||||
&& Bits(a.EdgeSpacing.Bottom) == Bits(b.EdgeSpacing.Bottom)
|
&& Bits(a.EdgeSpacing.Bottom) == Bits(b.EdgeSpacing.Bottom)
|
||||||
&& a.Quantity == b.Quantity
|
&& a.Quantity == b.Quantity
|
||||||
|
&& Equals(a.Outline, b.Outline)
|
||||||
&& a.Cost.HasValue == b.Cost.HasValue
|
&& a.Cost.HasValue == b.Cost.HasValue
|
||||||
&& (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value))
|
&& (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value))
|
||||||
&& SameZones(a.ClampZones, b.ClampZones);
|
&& 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>
|
/// <summary>True when the disc fits the work area in both directions.</summary>
|
||||||
public static bool Fits(NestPlateStock stock, DiscType type)
|
public static bool Fits(NestPlateStock stock, DiscType type)
|
||||||
{
|
{
|
||||||
|
if (stock.Outline != null)
|
||||||
|
return false; // The lattice fills rectangles only.
|
||||||
var work = stock.WorkArea;
|
var work = stock.WorkArea;
|
||||||
return 2 * type.Radius <= work.Right - work.Left + Epsilon
|
return 2 * type.Radius <= work.Right - work.Left + Epsilon
|
||||||
&& 2 * type.Radius <= work.Top - work.Bottom + Epsilon;
|
&& 2 * type.Radius <= work.Top - work.Bottom + Epsilon;
|
||||||
|
|||||||
@@ -131,9 +131,12 @@ internal sealed class BarStock
|
|||||||
var lengths = new double[types.Count];
|
var lengths = new double[types.Count];
|
||||||
for (var t = 0; t < types.Count; t++)
|
for (var t = 0; t < types.Count; t++)
|
||||||
{
|
{
|
||||||
var fitting = types[t].Orientations
|
// A bar is a rectangle; an outlined sheet is left to engines that fill outlines.
|
||||||
.Where(o => (alongX ? o.Height : o.Width) <= crossExtent + BarPacker.OverhangAllowance)
|
var fitting = stock.Outline != null
|
||||||
.ToList();
|
? new List<BarOrientation>()
|
||||||
|
: types[t].Orientations
|
||||||
|
.Where(o => (alongX ? o.Height : o.Width) <= crossExtent + BarPacker.OverhangAllowance)
|
||||||
|
.ToList();
|
||||||
if (fitting.Count == 0)
|
if (fitting.Count == 0)
|
||||||
{
|
{
|
||||||
fits[t] = Array.Empty<BarOrientation>();
|
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,
|
/// 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.
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
internal sealed class FrontierPacker
|
internal sealed class FrontierPacker
|
||||||
{
|
{
|
||||||
@@ -63,7 +67,10 @@ internal sealed class FrontierPacker
|
|||||||
private readonly WorkCounter counter;
|
private readonly WorkCounter counter;
|
||||||
private readonly BlockCatalog? blocks;
|
private readonly BlockCatalog? blocks;
|
||||||
private readonly ClampObstacles? clamps;
|
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(
|
public FrontierPacker(
|
||||||
IReadOnlyList<PartType> types,
|
IReadOnlyList<PartType> types,
|
||||||
NoFitCache nfps,
|
NoFitCache nfps,
|
||||||
@@ -73,9 +80,12 @@ internal sealed class FrontierPacker
|
|||||||
double beta,
|
double beta,
|
||||||
WorkCounter counter,
|
WorkCounter counter,
|
||||||
BlockCatalog? blocks = null,
|
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.blocks = blocks;
|
||||||
this.clamps = stock.ClampZones.Count == 0 ? null : clamps ?? new ClampObstacles(stock);
|
this.clamps = stock.ClampZones.Count == 0 ? null : clamps ?? new ClampObstacles(stock);
|
||||||
this.counter = counter;
|
this.counter = counter;
|
||||||
@@ -85,9 +95,13 @@ internal sealed class FrontierPacker
|
|||||||
this.stock = stock;
|
this.stock = stock;
|
||||||
this.axis = axis;
|
this.axis = axis;
|
||||||
this.beta = beta;
|
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)
|
public SheetFill Fill(IReadOnlyList<int> remaining, CancellationToken token)
|
||||||
{
|
{
|
||||||
var left = remaining.ToArray();
|
var left = remaining.ToArray();
|
||||||
@@ -97,9 +111,11 @@ internal sealed class FrontierPacker
|
|||||||
{
|
{
|
||||||
if (byOrientation.ContainsKey(o))
|
if (byOrientation.ContainsKey(o))
|
||||||
return true;
|
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;
|
return false;
|
||||||
var region = new Region(o, work, single);
|
|
||||||
if (clamps != null)
|
if (clamps != null)
|
||||||
for (var i = 0; i < clamps.Count; i++)
|
for (var i = 0; i < clamps.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -110,6 +126,13 @@ internal sealed class FrontierPacker
|
|||||||
counter.Add(1);
|
counter.Add(1);
|
||||||
token.ThrowIfCancellationRequested();
|
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);
|
states.Add(region);
|
||||||
byOrientation[o] = region;
|
byOrientation[o] = region;
|
||||||
return true;
|
return true;
|
||||||
@@ -126,7 +149,7 @@ internal sealed class FrontierPacker
|
|||||||
foreach (var pair in typePairs)
|
foreach (var pair in typePairs)
|
||||||
{
|
{
|
||||||
// Members missing from the catalog get regions too, but only for pair placement.
|
// 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]));
|
offered.Add(new PairState(pair, byOrientation[pair.A], byOrientation[pair.B]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +171,7 @@ internal sealed class FrontierPacker
|
|||||||
continue;
|
continue;
|
||||||
var width = members.Max(p => p.Right) - members.Min(p => p.Left);
|
var width = members.Max(p => p.Right) - members.Min(p => p.Left);
|
||||||
var height = members.Max(p => p.Top) - members.Min(p => p.Bottom);
|
var height = members.Max(p => p.Top) - members.Min(p => p.Bottom);
|
||||||
if (!stock.Fits(width, height))
|
if (!Fits(width, height))
|
||||||
continue;
|
continue;
|
||||||
foreach (var member in members)
|
foreach (var member in members)
|
||||||
{
|
{
|
||||||
@@ -341,6 +364,15 @@ internal sealed class FrontierPacker
|
|||||||
private RectD bounds;
|
private RectD bounds;
|
||||||
|
|
||||||
public Region(Orientation orientation, Box work, bool single)
|
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;
|
Orientation = orientation;
|
||||||
Single = single;
|
Single = single;
|
||||||
@@ -353,7 +385,7 @@ internal sealed class FrontierPacker
|
|||||||
maxX = minX;
|
maxX = minX;
|
||||||
if (maxY < minY)
|
if (maxY < minY)
|
||||||
maxY = minY;
|
maxY = minY;
|
||||||
free = new PathsD
|
free = seed ?? new PathsD
|
||||||
{
|
{
|
||||||
new PathD
|
new PathD
|
||||||
{
|
{
|
||||||
@@ -363,7 +395,8 @@ internal sealed class FrontierPacker
|
|||||||
new(minX - FitSlack, maxY + FitSlack),
|
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; }
|
public Orientation Orientation { get; }
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ namespace OpenNest.Engine.NestingEngines.Irregular;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class IrregularNestingEngine : INestingEngine
|
public sealed class IrregularNestingEngine : INestingEngine
|
||||||
{
|
{
|
||||||
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
|
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).
|
||||||
private static readonly (PackAxis Axis, double Beta)[] Variants =
|
/// 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.X, 1.0),
|
||||||
(PackAxis.Y, 1.0),
|
(PackAxis.Y, 1.0),
|
||||||
@@ -148,6 +149,7 @@ public sealed class IrregularNestingEngine : INestingEngine
|
|||||||
private const int MaxTail = 3;
|
private const int MaxTail = 3;
|
||||||
private readonly Dictionary<double, NoFitCache> caches = new();
|
private readonly Dictionary<double, NoFitCache> caches = new();
|
||||||
private readonly Dictionary<NestPlateStock, ClampObstacles> clamps = 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, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
|
||||||
private readonly Dictionary<double, BlockCatalog> blocks = new();
|
private readonly Dictionary<double, BlockCatalog> blocks = new();
|
||||||
private readonly IReadOnlyDictionary<string, NestJobPart> parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
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;
|
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)
|
private ClampObstacles? ClampsFor(NestPlateStock stock)
|
||||||
{
|
{
|
||||||
if (stock.ClampZones.Count == 0)
|
if (stock.ClampZones.Count == 0)
|
||||||
@@ -306,7 +320,7 @@ public sealed class IrregularNestingEngine : INestingEngine
|
|||||||
continue;
|
continue;
|
||||||
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
|
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,
|
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);
|
var fill = packer.Fill(remaining, token);
|
||||||
sheetPacked?.Invoke();
|
sheetPacked?.Invoke();
|
||||||
if (fill.Parts.Count > 0)
|
if (fill.Parts.Count > 0)
|
||||||
|
|||||||
@@ -30,9 +30,13 @@ internal sealed class NoFitCache
|
|||||||
|
|
||||||
public NoFitCache(double clearance)
|
public NoFitCache(double clearance)
|
||||||
{
|
{
|
||||||
|
Clearance = clearance;
|
||||||
halfClearance = clearance / 2;
|
halfClearance = clearance / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The clearance two footprints together keep.</summary>
|
||||||
|
public double Clearance { get; }
|
||||||
|
|
||||||
public PathD Footprint(Orientation o) =>
|
public PathD Footprint(Orientation o) =>
|
||||||
footprints.GetOrAdd((o.TypeIndex, o.Index), _ => BuildFootprint(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)
|
private static bool FitsStock(NestPlateStock stock, BoxOrientation o)
|
||||||
{
|
{
|
||||||
|
if (stock.Outline != null)
|
||||||
|
return false; // Boxes pack rectangles only.
|
||||||
var work = stock.WorkArea;
|
var work = stock.WorkArea;
|
||||||
return o.Width <= work.Right - work.Left + SheetPacker.OverhangAllowance
|
return o.Width <= work.Right - work.Left + SheetPacker.OverhangAllowance
|
||||||
&& o.Height <= work.Top - work.Bottom + 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)>();
|
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);
|
Try(preferred, trials);
|
||||||
if (trials.Count == 0)
|
if (trials.Count == 0)
|
||||||
foreach (var stock in job.Plates)
|
foreach (var stock in job.Plates)
|
||||||
if (!ReferenceEquals(stock, preferred))
|
if (!ReferenceEquals(stock, preferred) && stock.Outline == null)
|
||||||
Try(stock, trials);
|
Try(stock, trials);
|
||||||
|
|
||||||
if (trials.Count == 0)
|
if (trials.Count == 0)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RootNamespace>OpenNest.Gpu</RootNamespace>
|
<RootNamespace>OpenNest.Gpu</RootNamespace>
|
||||||
<AssemblyName>OpenNest.Gpu</AssemblyName>
|
<AssemblyName>OpenNest.Gpu</AssemblyName>
|
||||||
</PropertyGroup>
|
</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)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
[JsonConverter(typeof(WriteOnlyConverter<ExpectedRemnantDto>))]
|
[JsonConverter(typeof(WriteOnlyConverter<ExpectedRemnantDto>))]
|
||||||
public ExpectedRemnantDto ExpectedRemnant { get; init; }
|
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>
|
/// <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"/>.
|
/// missing or invalid removal-minutes value falls back to <see cref="ClampRail.DefaultRemovalMinutes"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <summary>The saved support bed, or null when absent or invalid (a bad bed never fails the nest).</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)
|
private static SupportBed ReadSupportBed(SupportBedDto dto)
|
||||||
{
|
{
|
||||||
if (dto == null)
|
if (dto == null)
|
||||||
@@ -666,6 +693,7 @@ namespace OpenNest.IO
|
|||||||
);
|
);
|
||||||
plate.GrainAngle = p.GrainAngle;
|
plate.GrainAngle = p.GrainAngle;
|
||||||
plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters);
|
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
|
// Clamp zones: older files have no key at all; an individually invalid
|
||||||
// entry is dropped rather than poisoning the whole nest.
|
// entry is dropped rather than poisoning the whole nest.
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ namespace OpenNest.IO
|
|||||||
!KeepEmptyPlates
|
!KeepEmptyPlates
|
||||||
&& plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0
|
&& plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0
|
||||||
&& plate.CutOffs.Count == 0
|
&& plate.CutOffs.Count == 0
|
||||||
|
&& plate.Outline == null
|
||||||
&& (
|
&& (
|
||||||
(plate.ClampZones.Count == 0 && plate.RemovedClamps.Count == 0)
|
(plate.ClampZones.Count == 0 && plate.RemovedClamps.Count == 0)
|
||||||
|| DefaultsRecreateClamps(plate)
|
|| DefaultsRecreateClamps(plate)
|
||||||
@@ -461,12 +462,26 @@ namespace OpenNest.IO
|
|||||||
: ToDto(plate.ClampRail),
|
: ToDto(plate.ClampRail),
|
||||||
StockItemCodes = plate.StockItemCodes.Count == 0 ? null : plate.StockItemCodes.ToList(),
|
StockItemCodes = plate.StockItemCodes.Count == 0 ? null : plate.StockItemCodes.ToList(),
|
||||||
ExpectedRemnant = ToDto(PlateRemnant.Expected(plate, nest.MinimumRemnantDimension)),
|
ExpectedRemnant = ToDto(PlateRemnant.Expected(plate, nest.MinimumRemnantDimension)),
|
||||||
|
Outline = ToDto(plate.Outline),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return list;
|
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) =>
|
private static ExpectedRemnantDto ToDto(PlateRemnant remnant) =>
|
||||||
remnant == null
|
remnant == null
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ namespace OpenNest.Mcp.Tools
|
|||||||
var strategy = ResolveStrategy(engine);
|
var strategy = ResolveStrategy(engine);
|
||||||
if (strategy == null)
|
if (strategy == null)
|
||||||
return EngineError(engine);
|
return EngineError(engine);
|
||||||
|
if (PlateFillService.Refusal(plate) is { } refusal)
|
||||||
|
return $"Error: {refusal} Nothing committed.";
|
||||||
|
|
||||||
var countBefore = plate.Parts.Count;
|
var countBefore = plate.Parts.Count;
|
||||||
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
||||||
@@ -121,6 +123,8 @@ namespace OpenNest.Mcp.Tools
|
|||||||
var strategy = ResolveStrategy(engine);
|
var strategy = ResolveStrategy(engine);
|
||||||
if (strategy == null)
|
if (strategy == null)
|
||||||
return EngineError(engine);
|
return EngineError(engine);
|
||||||
|
if (PlateFillService.Refusal(plate) is { } refusal)
|
||||||
|
return $"Error: {refusal} Nothing committed.";
|
||||||
|
|
||||||
var countBefore = plate.Parts.Count;
|
var countBefore = plate.Parts.Count;
|
||||||
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
||||||
@@ -173,6 +177,8 @@ namespace OpenNest.Mcp.Tools
|
|||||||
var strategy = ResolveStrategy(engine);
|
var strategy = ResolveStrategy(engine);
|
||||||
if (strategy == null)
|
if (strategy == null)
|
||||||
return EngineError(engine);
|
return EngineError(engine);
|
||||||
|
if (PlateFillService.Refusal(plate) is { } refusal)
|
||||||
|
return $"Error: {refusal} Nothing committed.";
|
||||||
|
|
||||||
var finder = RemnantFinder.FromPlate(plate);
|
var finder = RemnantFinder.FromPlate(plate);
|
||||||
var remnants = finder.FindRemnants();
|
var remnants = finder.FindRemnants();
|
||||||
@@ -240,6 +246,8 @@ namespace OpenNest.Mcp.Tools
|
|||||||
var strategy = ResolveStrategy(engine);
|
var strategy = ResolveStrategy(engine);
|
||||||
if (strategy == null)
|
if (strategy == null)
|
||||||
return EngineError(engine);
|
return EngineError(engine);
|
||||||
|
if (PlateFillService.Refusal(plate) is { } refusal)
|
||||||
|
return $"Error: {refusal} Nothing committed.";
|
||||||
|
|
||||||
var parsed = ParseItems(drawingNames, quantities);
|
var parsed = ParseItems(drawingNames, quantities);
|
||||||
if (parsed.error != null)
|
if (parsed.error != null)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public sealed class DrawingLibraryDatabaseTests
|
|||||||
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
|
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
|
||||||
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
|
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
|
||||||
Assert.Equal(before, NestDigest(factory.DatabasePath));
|
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);
|
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))
|
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));
|
Assert.Equal(before, NestDigest(factory.DatabasePath));
|
||||||
// Two distinct blobs across four revisions: the bracket's notes edit and the
|
// Two distinct blobs across four revisions: the bracket's notes edit and the
|
||||||
// plate both reuse the bracket's first package.
|
// plate both reuse the bracket's first package.
|
||||||
@@ -80,7 +80,7 @@ public sealed class DrawingLibraryDatabaseTests
|
|||||||
{
|
{
|
||||||
using var factory = new ServerFactory();
|
using var factory = new ServerFactory();
|
||||||
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
|
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));
|
Assert.Throws<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,36 @@ public sealed class DrawingLibraryQueryTests
|
|||||||
Assert.Equal(page.Items.Count, page.Total);
|
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]
|
[Theory]
|
||||||
[InlineData("name", false)]
|
[InlineData("name", false)]
|
||||||
[InlineData("name", true)]
|
[InlineData("name", true)]
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests
|
|||||||
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
|
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
|
||||||
Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items);
|
Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items);
|
||||||
Assert.Equal(before, Digest(factory.DatabasePath));
|
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);
|
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||||
@@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests
|
|||||||
Assert.Equal(before, Digest(factory.DatabasePath));
|
Assert.Equal(before, Digest(factory.DatabasePath));
|
||||||
Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing");
|
Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing");
|
||||||
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public sealed class MaterialListTests
|
|||||||
|
|
||||||
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
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.Empty(upgraded.ListMaterials());
|
||||||
Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade));
|
Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade));
|
||||||
Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName);
|
Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName);
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ public sealed class NestMaterialGradeTests
|
|||||||
Assert.Equal("Stainless", record.Material);
|
Assert.Equal("Stainless", record.Material);
|
||||||
Assert.Equal("", record.MaterialGrade);
|
Assert.Equal("", record.MaterialGrade);
|
||||||
Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).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()
|
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.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(inch)!.Units);
|
||||||
Assert.Equal(Units.Inches, upgraded.Get(unreadable)!.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()
|
private static NestRecord Record(string name, Units units) => new()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
|||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
namespace OpenNest.Server.Tests;
|
namespace OpenNest.Server.Tests;
|
||||||
|
|
||||||
@@ -10,15 +11,19 @@ namespace OpenNest.Server.Tests;
|
|||||||
/// Hosts the production routes in memory against a private, disposable SQLite file.
|
/// Hosts the production routes in memory against a private, disposable SQLite file.
|
||||||
/// The production <see cref="NestDatabase"/> registration is replaced before anything
|
/// 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.
|
/// 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.
|
/// Disposing the factory disposes the host (and its database) before deleting the directory.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
||||||
{
|
{
|
||||||
private bool _cleanedUp;
|
private bool _cleanedUp;
|
||||||
private NestDatabase? _database;
|
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"));
|
DirectoryPath = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N"));
|
||||||
Directory.CreateDirectory(DirectoryPath);
|
Directory.CreateDirectory(DirectoryPath);
|
||||||
DatabasePath = databasePath ?? Path.Combine(DirectoryPath, "nests.db");
|
DatabasePath = databasePath ?? Path.Combine(DirectoryPath, "nests.db");
|
||||||
@@ -36,6 +41,13 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
|||||||
services.RemoveAll<NestDatabase>();
|
services.RemoveAll<NestDatabase>();
|
||||||
// Register an externally owned instance; this factory closes it before deleting its file.
|
// Register an externally owned instance; this factory closes it before deleting its file.
|
||||||
services.AddSingleton(_database = new NestDatabase(DatabasePath, pooling: false));
|
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))
|
if (Directory.Exists(DirectoryPath))
|
||||||
Directory.Delete(DirectoryPath, recursive: true);
|
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);
|
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)!;
|
var kept = upgraded.GetRelease(releaseId)!;
|
||||||
Assert.Empty(kept.Drawings);
|
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));
|
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);
|
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%'"));
|
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)!;
|
var release = upgraded.GetRelease(releaseId)!;
|
||||||
Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone));
|
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);
|
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());
|
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)
|
.OrderBy(p => p.Name, StringComparer.Ordinal)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// NestReader: codes end at the first blank entry and at the plate's quantity.
|
|
||||||
var platesOut = plates
|
var platesOut = plates
|
||||||
.Select((p, index) =>
|
.Select((p, index) =>
|
||||||
{
|
{
|
||||||
@@ -98,10 +97,7 @@ internal static class NestArchive
|
|||||||
Width = System.Math.Min(size.Width, size.Length),
|
Width = System.Math.Min(size.Width, size.Length),
|
||||||
Length = System.Math.Max(size.Width, size.Length),
|
Length = System.Math.Max(size.Width, size.Length),
|
||||||
Quantity = p.Quantity,
|
Quantity = p.Quantity,
|
||||||
StockItemCodes = (p.StockItemCodes ?? new())
|
StockItemCodes = PlateCodes(p).ToList(),
|
||||||
.TakeWhile(code => !string.IsNullOrWhiteSpace(code))
|
|
||||||
.Take(System.Math.Max(p.Quantity, 0))
|
|
||||||
.ToList(),
|
|
||||||
ExpectedRemnant = ReadRemnant(p.ExpectedRemnant),
|
ExpectedRemnant = ReadRemnant(p.ExpectedRemnant),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -121,6 +117,27 @@ internal static class NestArchive
|
|||||||
return null;
|
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>
|
/// <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;
|
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.IncludeArchived) conditions.Add("archived=0");
|
||||||
if (query.NormalizedSearch.Length > 0) conditions.Add("(" + DrawingSearchPredicate + ")");
|
if (query.NormalizedSearch.Length > 0) conditions.Add("(" + DrawingSearchPredicate + ")");
|
||||||
if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("customerKey=$customer");
|
if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("customerKey=$customer");
|
||||||
if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("materialKey=$material");
|
// A drawing saved without a material, grade or thickness (0) suits any, so it matches every filter value.
|
||||||
if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("gradeKey=$grade");
|
if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("(materialKey=$material OR materialKey='')");
|
||||||
if (query.Thickness is not null) conditions.Add("thickness=$thickness");
|
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.CutType is not null) conditions.Add("cutType=$cutType");
|
||||||
if (query.CutFirst is not null) conditions.Add("cutFirst=$cutFirst");
|
if (query.CutFirst is not null) conditions.Add("cutFirst=$cutFirst");
|
||||||
var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions);
|
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);
|
AddRecordParameters(command, id, record, file, updateFile: true);
|
||||||
command.ExecuteNonQuery();
|
command.ExecuteNonQuery();
|
||||||
|
WriteStockCodes(id, file, transaction);
|
||||||
Snapshot(id, transaction);
|
Snapshot(id, transaction);
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
|
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("$revision", checked(currentRevision + 1));
|
||||||
command.Parameters.AddWithValue("$source", (object?)sourceRevision ?? DBNull.Value);
|
command.Parameters.AddWithValue("$source", (object?)sourceRevision ?? DBNull.Value);
|
||||||
command.ExecuteNonQuery();
|
command.ExecuteNonQuery();
|
||||||
|
WriteStockCodes(id, file, transaction);
|
||||||
Snapshot(id, transaction);
|
Snapshot(id, transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +344,7 @@ public sealed partial class NestDatabase : IDisposable
|
|||||||
command.Transaction = transaction;
|
command.Transaction = transaction;
|
||||||
command.CommandText = "PRAGMA user_version";
|
command.CommandText = "PRAGMA user_version";
|
||||||
var version = (long)command.ExecuteScalar()!;
|
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)
|
if (version == 0)
|
||||||
{
|
{
|
||||||
command.CommandText = """
|
command.CommandText = """
|
||||||
@@ -435,6 +437,8 @@ public sealed partial class NestDatabase : IDisposable
|
|||||||
command.CommandText = SharedSettingSchema;
|
command.CommandText = SharedSettingSchema;
|
||||||
command.ExecuteNonQuery();
|
command.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
if (version < 14)
|
||||||
|
AddStockCodes(command);
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
|||||||
var databasePath = ServerOptions.DatabasePath(args, Environment.GetEnvironmentVariable(ServerOptions.DatabaseVariable));
|
var databasePath = ServerOptions.DatabasePath(args, Environment.GetEnvironmentVariable(ServerOptions.DatabaseVariable));
|
||||||
builder.Services.AddSingleton(_ => new NestDatabase(databasePath));
|
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.
|
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -59,6 +67,11 @@ app.MapGet("/api/nests/{id:guid}/sheets", (Guid id, NestDatabase db) =>
|
|||||||
: Results.Ok(sheets);
|
: 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.
|
// 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) =>
|
app.MapPost("/api/nest-names/next", (int year, NestDatabase db) =>
|
||||||
{
|
{
|
||||||
@@ -154,6 +167,8 @@ app.MapDrawingSets();
|
|||||||
app.MapReleases();
|
app.MapReleases();
|
||||||
app.MapMaterials();
|
app.MapMaterials();
|
||||||
app.MapSettings();
|
app.MapSettings();
|
||||||
|
if (updatesEnabled)
|
||||||
|
app.MapUpdates();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ namespace OpenNest.Server;
|
|||||||
public static class ServerOptions
|
public static class ServerOptions
|
||||||
{
|
{
|
||||||
public const string DatabaseVariable = "OPENNEST_DB";
|
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>
|
/// <summary>
|
||||||
/// The database path: <c>--database=<path></c> or <c>--database <path></c>, then
|
/// 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.
|
# Docker volume holding nests.db; change only to switch to a verified restore.
|
||||||
OPENNEST_DATA_VOLUME=opennest-data
|
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();
|
||||||
|
}
|
||||||
@@ -19,4 +19,17 @@ public class ClampZonePreviewTests
|
|||||||
Assert.Equal(2, preview.ClampZones.Count);
|
Assert.Equal(2, preview.ClampZones.Count);
|
||||||
Assert.Null(preview.ClampRail); // a detached display copy never re-resolves
|
Assert.Null(preview.ClampRail); // a detached display copy never re-resolves
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PreviewCarriesThePlatesSupportBed()
|
||||||
|
{
|
||||||
|
var plate = BestEffortCuttingPlanTests.Plate(BestEffortCuttingPlanTests.TouchingContours());
|
||||||
|
plate.SupportBed = new OpenNest.Support.SupportGrid(25, 100, 2, 2, stabilityMargin: 0.1);
|
||||||
|
|
||||||
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
var preview = proposal.BuildPreview(0);
|
||||||
|
|
||||||
|
Assert.NotNull(preview);
|
||||||
|
Assert.Same(plate.SupportBed, preview.SupportBed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +67,97 @@ public class ContourEntryCandidateTests
|
|||||||
Assert.Equal(8, forward.Count);
|
Assert.Equal(8, forward.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Square 0..10 whose corners are 0.5-radius fillets, counterclockwise or clockwise.</summary>
|
||||||
|
internal static Program RoundedSquare(bool clockwise)
|
||||||
|
{
|
||||||
|
var p = new Program();
|
||||||
|
p.MoveTo(0.5, 0);
|
||||||
|
if (clockwise)
|
||||||
|
{
|
||||||
|
p.ArcTo(new Vector(0, 0.5), new Vector(0.5, 0.5), RotationType.CW);
|
||||||
|
p.LineTo(0, 9.5);
|
||||||
|
p.ArcTo(new Vector(0.5, 10), new Vector(0.5, 9.5), RotationType.CW);
|
||||||
|
p.LineTo(9.5, 10);
|
||||||
|
p.ArcTo(new Vector(10, 9.5), new Vector(9.5, 9.5), RotationType.CW);
|
||||||
|
p.LineTo(10, 0.5);
|
||||||
|
p.ArcTo(new Vector(9.5, 0), new Vector(9.5, 0.5), RotationType.CW);
|
||||||
|
p.LineTo(0.5, 0);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
p.LineTo(9.5, 0);
|
||||||
|
p.ArcTo(new Vector(10, 0.5), new Vector(9.5, 0.5), RotationType.CCW);
|
||||||
|
p.LineTo(10, 9.5);
|
||||||
|
p.ArcTo(new Vector(9.5, 10), new Vector(9.5, 9.5), RotationType.CCW);
|
||||||
|
p.LineTo(0.5, 10);
|
||||||
|
p.ArcTo(new Vector(0, 9.5), new Vector(0.5, 9.5), RotationType.CCW);
|
||||||
|
p.LineTo(0, 0.5);
|
||||||
|
p.ArcTo(new Vector(0.5, 0), new Vector(0.5, 0.5), RotationType.CCW);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void CornerFilletJoints_AreConvexCorners(bool clockwise)
|
||||||
|
{
|
||||||
|
var prepared = Capture(RoundedSquare(clockwise));
|
||||||
|
|
||||||
|
var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
|
||||||
|
|
||||||
|
// Both joints of each fillet start the cut at its corner, one radius from the sharp corner.
|
||||||
|
var corners = candidates.Where(c => c.Kind == AutomaticEntryKind.ConvexCorner).ToList();
|
||||||
|
Assert.Equal(8, corners.Count);
|
||||||
|
Assert.All(corners, c => Assert.Equal(0.5, c.CornerReach, 9));
|
||||||
|
Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(10, 9.5)) < 1e-6);
|
||||||
|
Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(9.5, 10)) < 1e-6);
|
||||||
|
Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.TangentJoint);
|
||||||
|
Assert.Equal(4, candidates.Count(c => c.Kind == AutomaticEntryKind.StraightMidpoint));
|
||||||
|
Assert.All(candidates.Where(c => c.Kind == AutomaticEntryKind.StraightMidpoint),
|
||||||
|
c => Assert.Equal(0, c.CornerReach));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FilletLongerThanItsEdge_StaysATangentJoint()
|
||||||
|
{
|
||||||
|
// A 5-radius quarter arc after a 1-long bottom edge is a curve, not a rounded corner.
|
||||||
|
var p = new Program();
|
||||||
|
p.MoveTo(0, 0);
|
||||||
|
p.LineTo(1, 0);
|
||||||
|
p.ArcTo(new Vector(6, 5), new Vector(1, 5), RotationType.CCW);
|
||||||
|
p.LineTo(6, 6);
|
||||||
|
p.LineTo(0, 6);
|
||||||
|
p.LineTo(0, 0);
|
||||||
|
var prepared = Capture(p);
|
||||||
|
|
||||||
|
var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
|
||||||
|
|
||||||
|
Assert.Equal(2, candidates.Count(c => c.Kind == AutomaticEntryKind.TangentJoint));
|
||||||
|
Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.ConvexCorner
|
||||||
|
&& (Distance(c.Choice.Point, new Vector(1, 0)) < 1e-6 || Distance(c.Choice.Point, new Vector(6, 5)) < 1e-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InsideCornerFillet_StaysATangentJoint()
|
||||||
|
{
|
||||||
|
// An L whose inside corner is rounded: that fillet turns the reflex way.
|
||||||
|
var p = new Program();
|
||||||
|
p.MoveTo(0, 0);
|
||||||
|
p.LineTo(10, 0);
|
||||||
|
p.LineTo(10, 4);
|
||||||
|
p.LineTo(5, 4);
|
||||||
|
p.ArcTo(new Vector(4, 5), new Vector(5, 5), RotationType.CW);
|
||||||
|
p.LineTo(4, 10);
|
||||||
|
p.LineTo(0, 10);
|
||||||
|
p.LineTo(0, 0);
|
||||||
|
var prepared = Capture(p);
|
||||||
|
|
||||||
|
var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
|
||||||
|
|
||||||
|
var joints = candidates.Where(c => c.Kind == AutomaticEntryKind.TangentJoint).ToList();
|
||||||
|
Assert.Contains(joints, c => Distance(c.Choice.Point, new Vector(5, 4)) < 1e-6);
|
||||||
|
Assert.Contains(joints, c => Distance(c.Choice.Point, new Vector(4, 5)) < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TangentLineArcJoint_IsTangentJoint_NotACorner()
|
public void TangentLineArcJoint_IsTangentJoint_NotACorner()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -93,6 +93,22 @@ public class ContourEntryRankingTests
|
|||||||
Assert.True(rightMid < bottomMid);
|
Assert.True(rightMid < bottomMid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void FilletedCorner_BeatsAFacingMidpointNearerTheRoute(bool clockwise)
|
||||||
|
{
|
||||||
|
// Travel favours the right edge's midpoint, but a long edge's middle is a last resort:
|
||||||
|
// the joints of the facing rounded corner count on both of its sides and lead.
|
||||||
|
var ranked = Catalogue(ContourEntryCandidateTests.RoundedSquare(clockwise))
|
||||||
|
.RankTowardNextCut(new Vector(14, 6), new Vector(12, 5)).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(AutomaticEntryKind.ConvexCorner, ranked[0].Kind);
|
||||||
|
Assert.True(At(ranked[0], 10, 9.5) || At(ranked[0], 9.5, 10));
|
||||||
|
var rightMid = ranked.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 10, 5));
|
||||||
|
Assert.True(ranked.FindIndex(c => At(c, 10, 0.5)) < rightMid);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TangentAndMidpoint_ShareARankTier()
|
public void TangentAndMidpoint_ShareARankTier()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -109,6 +109,65 @@ public class CutOffSegmentPlanningTests
|
|||||||
Assert.Equal(result.RapidDistance, kept.RapidDistance, 6);
|
Assert.Equal(result.RapidDistance, kept.RapidDistance, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A frame with two small holes on its left strip, one above the other, and a window,
|
||||||
|
// between a locked square A at the top left, cut before it, and a locked square B to its
|
||||||
|
// right, cut after it. A line through the frame's right strip stops halfway up, so its one
|
||||||
|
// segment runs from the bottom edge to just below the frame and is cut straight before it.
|
||||||
|
// Planned without the segment, from A, the frame would cut its upper hole first; the tool
|
||||||
|
// is really at the bottom, so it cuts the lower hole first instead of crossing its own
|
||||||
|
// rapid. Replanned with the order kept, the frame plans the same holes from the same point.
|
||||||
|
[Fact]
|
||||||
|
public void PartAfterSegment_PlansItsHolesFromTheSegmentsEnd()
|
||||||
|
{
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
var nest = new Nest();
|
||||||
|
var plate = nest.CreatePlate();
|
||||||
|
plate.Size = new Size(60, 120);
|
||||||
|
plate.Parts.Add(LockedSquare("A", new Vector(2, 42), topLeft: false));
|
||||||
|
var frame = new Part(new Drawing("frame", Frame()), new Vector(30, 5));
|
||||||
|
plate.Parts.Add(frame);
|
||||||
|
plate.Parts.Add(LockedSquare("B", new Vector(80, 40), topLeft: false));
|
||||||
|
var cutOff = new CutOff(new Vector(65, 0), CutOffAxis.Vertical) { EndLimit = 25 };
|
||||||
|
plate.CutOffs.Add(cutOff);
|
||||||
|
plate.RegenerateCutOffs(new CutOffSettings());
|
||||||
|
PlateSequencing.PlaceCutOffs(plate, [cutOff]);
|
||||||
|
Assert.Single(CutOffSegments.Of(plate, cutOff));
|
||||||
|
|
||||||
|
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters));
|
||||||
|
|
||||||
|
Assert.True(result.Status == CuttingPlanStatus.Ready && result.IndependentlyReplayed, Describe(result));
|
||||||
|
var order = result.ProposedOrder.Select(o => o.SourcePart.BaseDrawing.IsCutOff ? "s" : o.SourcePart.BaseDrawing.Name);
|
||||||
|
Assert.Equal("A s frame B", string.Join(" ", order));
|
||||||
|
var planned = result.ProposedOrder[2];
|
||||||
|
var rapids = planned.Execution.Motions.Where(m => m.Rapid).Select(m => m.End).ToArray();
|
||||||
|
// Into the lower hole (y 14.5 to 15.1 on the sheet), then the upper one (34.5 to 35.1).
|
||||||
|
Assert.InRange(rapids[0].Y, 14, 16);
|
||||||
|
Assert.InRange(rapids[1].Y, 34, 36);
|
||||||
|
|
||||||
|
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
|
||||||
|
var kept = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters,
|
||||||
|
preservePartOrder: true));
|
||||||
|
Assert.True(kept.Status == CuttingPlanStatus.Ready && kept.IndependentlyReplayed, Describe(kept));
|
||||||
|
Assert.Equal(result.RapidDistance, kept.RapidDistance, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 40 x 40 with 0.6 square holes at (2.5, 9.5) and (2.5, 29.5) and an 18 x 30 window.
|
||||||
|
private static Program Frame()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
void Loop(params Vector[] points)
|
||||||
|
{
|
||||||
|
program.MoveTo(points[0].X, points[0].Y);
|
||||||
|
foreach (var point in points.Skip(1).Append(points[0]))
|
||||||
|
program.LineTo(point.X, point.Y);
|
||||||
|
}
|
||||||
|
Loop(new(0, 0), new(0, 40), new(40, 40), new(40, 0));
|
||||||
|
foreach (var y in new[] { 29.5, 9.5 })
|
||||||
|
Loop(new(2.5, y), new(3.1, y), new(3.1, y + 0.6), new(2.5, y + 0.6));
|
||||||
|
Loop(new(12, 5), new(30, 5), new(30, 35), new(12, 35));
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
private const double Gap = 0.25, Width = 10.6, Neck = 5.6, Height = 20, Margin = 0.25;
|
private const double Gap = 0.25, Width = 10.6, Neck = 5.6, Height = 20, Margin = 0.25;
|
||||||
// The slanted shoulders need more horizontal room than the gap.
|
// The slanted shoulders need more horizontal room than the gap.
|
||||||
private const double Pitch = (Width + Neck) / 2 + 2 * Gap;
|
private const double Pitch = (Width + Neck) / 2 + 2 * Gap;
|
||||||
|
|||||||
@@ -79,6 +79,43 @@ public class CuttingDependencyTests
|
|||||||
&& f.Message.Contains("before the sheet is separated"));
|
&& f.Message.Contains("before the sheet is separated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A column of two wide parts under three narrow ones, beside a sheet separator at X 12.5
|
||||||
|
// pierced at (12.5, 0). Climbing the column ends at the top, where the straight approach to
|
||||||
|
// that pierce crosses the wide parts, and the separator cannot be cut any earlier. The search
|
||||||
|
// learns the other way round, the part the approach left from before the parts it crossed,
|
||||||
|
// and so finishes beside the pierce. It used to learn nothing and spend its whole budget.
|
||||||
|
[Fact]
|
||||||
|
public void Plan_ApproachIntoASeparatorCrossingCutParts_LearnsToLeaveFromBeyondThem()
|
||||||
|
{
|
||||||
|
var (plate, column, separator) = ColumnBesideSeparator();
|
||||||
|
|
||||||
|
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
||||||
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
||||||
|
|
||||||
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||||
|
var order = result.ProposedOrder.Select(o => o.SourcePart).ToList();
|
||||||
|
Assert.Same(separator, order[^1]);
|
||||||
|
Assert.Contains(order[^2], column[..2]);
|
||||||
|
Assert.True(result.Expansions < 400, Describe(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Plate, Part[] Column, Part Separator) ColumnBesideSeparator()
|
||||||
|
{
|
||||||
|
var nest = new Nest();
|
||||||
|
var plate = nest.CreatePlate();
|
||||||
|
plate.Size = new Size(100, 100);
|
||||||
|
Part[] column =
|
||||||
|
[
|
||||||
|
Rectangle(2, 2, 10, 10), Rectangle(2, 14, 10, 10),
|
||||||
|
Rectangle(2, 26, 5, 10), Rectangle(2, 38, 5, 10), Rectangle(2, 50, 5, 10),
|
||||||
|
];
|
||||||
|
foreach (var part in column)
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
plate.CutOffs.Add(new CutOff(new Vector(12.5, 0), CutOffAxis.Vertical));
|
||||||
|
plate.RegenerateCutOffs(new CutOffSettings());
|
||||||
|
return (plate, column, plate.Parts.Single(p => p.BaseDrawing.IsCutOff));
|
||||||
|
}
|
||||||
|
|
||||||
// The tour cuts B, then the locked rectangle A (leaving right), then the cutoff pierced at
|
// The tour cuts B, then the locked rectangle A (leaving right), then the cutoff pierced at
|
||||||
// (10, 0) below-left of A: that approach crosses A. Replanning B's entries can never fix it,
|
// (10, 0) below-left of A: that approach crosses A. Replanning B's entries can never fix it,
|
||||||
// so the search learns "cutoff first" at once instead of retrying each of B's entries.
|
// so the search learns "cutoff first" at once instead of retrying each of B's entries.
|
||||||
|
|||||||
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user