mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-10 21:21:09 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beccbae106 | ||
|
|
6867ae2ee4 | ||
|
|
821c97e8dc | ||
|
|
9828b9d6bc | ||
|
|
9273a44258 | ||
|
|
7dc471bbf8 | ||
|
|
6894fcfade | ||
|
|
f4fb6e1f3d | ||
|
|
bffcd52db1 | ||
|
|
b329dd6c6b | ||
|
|
c32f3e5df3 | ||
|
|
0d7207e58e | ||
|
|
53e5b78e3c | ||
|
|
b92d079e77 | ||
|
|
3beaf53942 | ||
|
|
da13801d25 | ||
|
|
9627bb9625 | ||
|
|
8e9946aa47 | ||
|
|
49603eddf9 | ||
|
|
921d100aa0 | ||
|
|
071531dbfc | ||
|
|
5668952e6b | ||
|
|
e58e8b85c4 | ||
|
|
ae8e7c6e42 | ||
|
|
9c3a8a5063 | ||
|
|
735c076afb | ||
|
|
f62f73533e | ||
|
|
5adcddc748 | ||
|
|
3c561566cd | ||
|
|
74937b7952 | ||
|
|
bff11dbc3c | ||
|
|
757d366e4a | ||
|
|
aa8616ea84 | ||
|
|
d7f9f25265 | ||
|
|
ea540253bb | ||
|
|
c8f070ea64 | ||
|
|
a4417af763 | ||
|
|
bcbcf1e611 | ||
|
|
331234d6f3 | ||
|
|
6be8a7b41c | ||
|
|
9b643aaec9 | ||
|
|
a9fc9f0afb | ||
|
|
872cd8e4b8 | ||
|
|
e6e2e5e0ad | ||
|
|
863ce4afde | ||
|
|
b1430bf5ba | ||
|
|
535219b073 | ||
|
|
a437162039 | ||
|
|
1caf9b68bb | ||
|
|
4aa035af2f | ||
|
|
7cc0e6f126 | ||
|
|
7d498f2b41 | ||
|
|
f09a9ad705 | ||
|
|
78a5ad88e2 | ||
|
|
5eb7fd920b | ||
|
|
dd0c31271a | ||
|
|
f88b805e68 | ||
|
|
2b23150801 | ||
|
|
b026bd19d2 | ||
|
|
f9b2713420 | ||
|
|
b630b50b7b | ||
|
|
1d87d212de | ||
|
|
924763493d | ||
|
|
46adbfd6f6 | ||
|
|
fbe82d198a | ||
|
|
a994bae76f | ||
|
|
f9abab0c6d | ||
|
|
3cd7729d38 | ||
|
|
6de23fb831 | ||
|
|
ae14458444 | ||
|
|
0e4f4f5109 | ||
|
|
cf483c1a2e | ||
|
|
e8215004ab | ||
|
|
e290254a18 | ||
|
|
f31cd1c797 | ||
|
|
014051c121 | ||
|
|
1179210e46 | ||
|
|
dc559a6c66 | ||
|
|
4f272176b3 | ||
|
|
227a561d59 | ||
|
|
0547af2ff6 | ||
|
|
d98bee87ec | ||
|
|
b5bc03bea1 | ||
|
|
dffb06a9e3 | ||
|
|
8700ae187a | ||
|
|
7f718f222c | ||
|
|
440fe767f1 | ||
|
|
4dae54c629 | ||
|
|
7ea21f3afe | ||
|
|
90a1a087f0 | ||
|
|
890cbc486b | ||
|
|
698b91d256 | ||
|
|
9d4d024d60 | ||
|
|
54cea0c325 | ||
|
|
c60694b947 | ||
|
|
032854895d | ||
|
|
be5fef6875 | ||
|
|
9efeb4e5b6 | ||
|
|
3e35821ab1 | ||
|
|
c5b6e680cb | ||
|
|
31ee77914f | ||
|
|
3b5cf92af2 | ||
|
|
2939fe0802 | ||
|
|
072e96c08e | ||
|
|
53d46303cd | ||
|
|
6c4ebe49b3 | ||
|
|
5de628760f | ||
|
|
d7fe7fe33c | ||
|
|
fbc23d6b64 | ||
|
|
2721375e8b | ||
|
|
d133f6879b | ||
|
|
322a66c339 | ||
|
|
52e421dcbe | ||
|
|
6ee81a3501 | ||
|
|
a0001f04ac | ||
|
|
91089820ee | ||
|
|
a2966b9f86 | ||
|
|
5da1e35e88 | ||
|
|
72039a238b | ||
|
|
6771533e03 | ||
|
|
c243830641 | ||
|
|
a0d6c7212b | ||
|
|
8a282d53cf | ||
|
|
bfc36d166c | ||
|
|
ceddd8cf93 | ||
|
|
84a0fedac0 | ||
|
|
96db154d9e | ||
|
|
c122e64edd | ||
|
|
60d235c8ce | ||
|
|
81ec6a8125 | ||
|
|
b0cdbb3e81 | ||
|
|
54ad7ee84d | ||
|
|
d29f919aa2 | ||
|
|
e8c52a354d | ||
|
|
82d54aec87 | ||
|
|
b2cc796e4a | ||
|
|
e0bc97a719 | ||
|
|
af9886c355 | ||
|
|
3d728b000e | ||
|
|
1a46d9896f | ||
|
|
b703abe8dc | ||
|
|
f9b718e890 | ||
|
|
d4e622271a | ||
|
|
f597b06024 | ||
|
|
3ca23e9474 | ||
|
|
b1a03c1796 | ||
|
|
62e5410cdb | ||
|
|
12819198f9 | ||
|
|
763e2a1d4a | ||
|
|
0a0da50006 | ||
|
|
ac7ee41801 | ||
|
|
fd37cbffef | ||
|
|
c0cd7c6aff | ||
|
|
c8883bbd1e | ||
|
|
44372d129e | ||
|
|
4b2aba83bb | ||
|
|
be4886f876 | ||
|
|
e7499f14c2 | ||
|
|
10a4128187 | ||
|
|
4e92d0e8f3 | ||
|
|
277719a1d6 | ||
|
|
d57678ecee | ||
|
|
a89adc7911 | ||
|
|
8a5f16069d | ||
|
|
b28df6efa0 | ||
|
|
5e2606f679 | ||
|
|
eec6ed0125 | ||
|
|
9dd70ef779 | ||
|
|
8cc8d04b64 | ||
|
|
ec8fd6f8ed | ||
|
|
45f5ce6954 | ||
|
|
bbdd51fabc | ||
|
|
8076ddbd78 | ||
|
|
1d310b373e | ||
|
|
c027793077 | ||
|
|
658b3552c1 | ||
|
|
9d6d5fa4b2 | ||
|
|
a72e9eb900 | ||
|
|
e5d9e12df9 | ||
|
|
06ec8b1c43 | ||
|
|
f0d84e041c | ||
|
|
a5e219008e | ||
|
|
e271580f62 | ||
|
|
7bff9baa23 | ||
|
|
bdd6625713 | ||
|
|
24169a5f8f | ||
|
|
e231e44f72 | ||
|
|
082a50bbcd | ||
|
|
31525ba342 | ||
|
|
970d2442d5 | ||
|
|
01e2238258 | ||
|
|
59d4c8d727 | ||
|
|
817c4482eb | ||
|
|
561f5be06c | ||
|
|
63bcd89d35 | ||
|
|
8df4c73093 | ||
|
|
7dc8cbb22d | ||
|
|
9431006edd | ||
|
|
2dab923259 | ||
|
|
dd473b6d38 | ||
|
|
23d5362aa1 | ||
|
|
8fb95e57b5 | ||
|
|
55910d1f9d | ||
|
|
a28b543634 | ||
|
|
ad5f2a9041 | ||
|
|
45ab837c8c | ||
|
|
a392cc0863 | ||
|
|
e628345947 | ||
|
|
08d646413a | ||
|
|
ca693285bb | ||
|
|
05d5c56ac0 | ||
|
|
abced0a7ec | ||
|
|
e947c3ad86 | ||
|
|
3a8994c8fe | ||
|
|
4e3be68c11 | ||
|
|
4d55e11318 | ||
|
|
2a128c5a11 | ||
|
|
667beb4334 | ||
|
|
bb6bdc8213 | ||
|
|
b550640ef0 | ||
|
|
4028dafb26 | ||
|
|
7d9f900633 | ||
|
|
27665c0ded | ||
|
|
2c1a8eef42 | ||
|
|
47f9593a41 | ||
|
|
e76864b489 | ||
|
|
7a7b671408 | ||
|
|
d80002e832 | ||
|
|
508b7db0df | ||
|
|
4ca8b0debf | ||
|
|
889e33d179 | ||
|
|
1c2bf16b3e | ||
|
|
6ca9a1243c | ||
|
|
c313d9699e | ||
|
|
73a02cf309 | ||
|
|
cce2585d79 | ||
|
|
2db8f5f841 | ||
|
|
047174432f | ||
|
|
bb21ffdf35 | ||
|
|
7ec11993de | ||
|
|
16d4f706b2 | ||
|
|
42755c9422 | ||
|
|
38de7ab3a3 | ||
|
|
0719476618 | ||
|
|
afdf196754 | ||
|
|
35be95b843 | ||
|
|
4ae50c8a73 | ||
|
|
a0c620690e | ||
|
|
07a9739774 | ||
|
|
6a85593bd4 | ||
|
|
c798be9b95 | ||
|
|
fe216be2b4 | ||
|
|
a1d20bdeb5 | ||
|
|
41460481d6 | ||
|
|
a9396ba065 | ||
|
|
101d7b5a50 | ||
|
|
a0d063bb94 | ||
|
|
c21b5b006e | ||
|
|
55ab8ec4a5 | ||
|
|
662d3daf8c | ||
|
|
360c7136dd | ||
|
|
501d90b825 | ||
|
|
fa7a1f0fce | ||
|
|
672ed09074 | ||
|
|
e5dc67346d | ||
|
|
f22e839850 | ||
|
|
b8e8cc387d | ||
|
|
c6b6678a2f | ||
|
|
4179b53c97 | ||
|
|
c8419761e0 | ||
|
|
16bd92567c | ||
|
|
cd51f7cfda | ||
|
|
6f4e872c03 | ||
|
|
588ae5ed09 | ||
|
|
277ca36f42 | ||
|
|
8dded7f177 | ||
|
|
b65a9485c7 | ||
|
|
63915d6fb6 | ||
|
|
379e3fd433 | ||
|
|
f1468e6f57 | ||
|
|
0be9e2201a | ||
|
|
fcb61f3b3e | ||
|
|
87e0a70878 | ||
|
|
8776eb4734 | ||
|
|
fd2e9c2f56 | ||
|
|
3618b335cb | ||
|
|
7d9c64487d | ||
|
|
cc066b457e | ||
|
|
d2f814f1f9 | ||
|
|
69d8187adc | ||
|
|
4670577110 | ||
|
|
d505a50934 | ||
|
|
bc15c362e1 | ||
|
|
9b4c1f51d0 | ||
|
|
eba15791e7 | ||
|
|
7fe4de4b6d | ||
|
|
f4317bc52f | ||
|
|
207f126b9e | ||
|
|
0f1e5fb568 | ||
|
|
0ede9443de | ||
|
|
58b8467c39 | ||
|
|
eedff85904 | ||
|
|
cc16590881 | ||
|
|
8ad32e4864 | ||
|
|
7198f7a4f7 | ||
|
|
ee842c822f | ||
|
|
512b861739 |
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"vpk": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"commands": [
|
||||||
|
"vpk"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,14 @@
|
|||||||
!OpenNest.Data/**
|
!OpenNest.Data/**
|
||||||
!OpenNest.Core/
|
!OpenNest.Core/
|
||||||
!OpenNest.Core/**
|
!OpenNest.Core/**
|
||||||
|
!OpenNest.Server.Worker/
|
||||||
|
!OpenNest.Server.Worker/**
|
||||||
|
!OpenNest.Api/
|
||||||
|
!OpenNest.Api/**
|
||||||
|
!OpenNest.Engine/
|
||||||
|
!OpenNest.Engine/**
|
||||||
|
!OpenNest.IO/
|
||||||
|
!OpenNest.IO/**
|
||||||
**/bin/**
|
**/bin/**
|
||||||
**/obj/**
|
**/obj/**
|
||||||
**/data/**
|
**/data/**
|
||||||
|
|||||||
@@ -20,3 +20,6 @@
|
|||||||
*.PDF diff=astextplain
|
*.PDF diff=astextplain
|
||||||
*.rtf diff=astextplain
|
*.rtf diff=astextplain
|
||||||
*.RTF diff=astextplain
|
*.RTF diff=astextplain
|
||||||
|
|
||||||
|
# Container scripts run in Linux images.
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -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:
|
||||||
@@ -30,7 +32,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x'
|
dotnet-version: '8.0.x'
|
||||||
- name: Run ${{ matrix.project }}
|
- name: Run ${{ matrix.project }}
|
||||||
run: dotnet test "${{ matrix.project }}/${{ matrix.project }}.csproj"
|
run: dotnet test "${{ matrix.project }}/${{ matrix.project }}.csproj" -c Release
|
||||||
|
|
||||||
synthetic-nests:
|
synthetic-nests:
|
||||||
name: Six synthetic Irregular nests
|
name: Six synthetic Irregular nests
|
||||||
@@ -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" ]
|
||||||
@@ -6,6 +6,10 @@ on:
|
|||||||
- 'OpenNest.Server/**'
|
- 'OpenNest.Server/**'
|
||||||
- 'OpenNest.Data/**'
|
- 'OpenNest.Data/**'
|
||||||
- 'OpenNest.Core/**'
|
- 'OpenNest.Core/**'
|
||||||
|
- 'OpenNest.Server.Worker/**'
|
||||||
|
- 'OpenNest.Api/**'
|
||||||
|
- 'OpenNest.Engine/**'
|
||||||
|
- 'OpenNest.IO/**'
|
||||||
- 'OpenNest.Server.Tests/**'
|
- 'OpenNest.Server.Tests/**'
|
||||||
- 'scripts/Server.Tests/**'
|
- 'scripts/Server.Tests/**'
|
||||||
- 'scripts/Test-ServerContainer.sh'
|
- 'scripts/Test-ServerContainer.sh'
|
||||||
@@ -21,6 +25,10 @@ on:
|
|||||||
- 'OpenNest.Server/**'
|
- 'OpenNest.Server/**'
|
||||||
- 'OpenNest.Data/**'
|
- 'OpenNest.Data/**'
|
||||||
- 'OpenNest.Core/**'
|
- 'OpenNest.Core/**'
|
||||||
|
- 'OpenNest.Server.Worker/**'
|
||||||
|
- 'OpenNest.Api/**'
|
||||||
|
- 'OpenNest.Engine/**'
|
||||||
|
- 'OpenNest.IO/**'
|
||||||
- 'OpenNest.Server.Tests/**'
|
- 'OpenNest.Server.Tests/**'
|
||||||
- 'scripts/Server.Tests/**'
|
- 'scripts/Server.Tests/**'
|
||||||
- 'scripts/Test-ServerContainer.sh'
|
- 'scripts/Test-ServerContainer.sh'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -225,3 +225,6 @@ OpenNest.Tests/test-config.json
|
|||||||
# Vendor programming manuals: keep reference copies outside source control.
|
# Vendor programming manuals: keep reference copies outside source control.
|
||||||
CINCINNATI LASER PROGRAMMING MANUAL.pdf
|
CINCINNATI LASER PROGRAMMING MANUAL.pdf
|
||||||
TF5200_programming_manual_en.pdf
|
TF5200_programming_manual_en.pdf
|
||||||
|
|
||||||
|
# Python bytecode
|
||||||
|
__pycache__/
|
||||||
@@ -22,24 +22,30 @@ 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`.
|
On Windows, prefer `scripts/Test-Hidden.ps1 <dotnet test args>` for WinForms tests: it runs them on a hidden desktop so no windows appear or take focus; see [hidden-desktop tests](docs/hidden-desktop-tests.md).
|
||||||
|
|
||||||
|
Plan Cutting and nesting-engine changes need the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`), which holds every nest that ever caused a problem; engine changes run only its `auto-nest` area. Never run it for fills, UI, posts or file formats. Limit it to the areas the change touches with `OPENNEST_PROBLEM_NESTS_CHECKS` (table in that doc); `part-order` and `entries` plan every nest. No plate may rank below its baseline, and none may join or climb the near-budget list compared with master's run. 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.
|
||||||
|
|
||||||
## Project map and boundaries
|
## Project map and boundaries
|
||||||
|
|
||||||
- `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter.
|
- `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter, and call `PortableMath` (not `System.Math`) for trig in Core/Engine/IO/Data so layouts match across platforms.
|
||||||
- `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 and SVG 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; `OpenNest.WinForms.Tests` redirects them to a temp file, so a test run never writes the user's settings. 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.Server`: nest storage HTTP service, kept off Engine and IO. `OpenNest.Server.Worker`: the server nesting worker, shipped in the same image.
|
||||||
|
- `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,28 +54,35 @@ 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. The one designed raise is the rapid out of a cutout's slug after its lead-out, marked at emission (see the cutting planner doc); no lead-out or an overcut keeps the head down. 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)
|
||||||
|
- [SVG import and export](docs/svg-import-export.md): SVG numbers are not drawing units; import never guesses a scale
|
||||||
|
- [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 the stop at a feature's center line) 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
|
||||||
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
|
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
|
||||||
|
- [Dimensions](docs/dimensions.md): CAD Converter annotations that store placement only, are measured from the cut geometry when shown and never affect the program
|
||||||
|
- [Common-line cutting](docs/common-line-cutting.md): parts one kerf apart in `Plate.CommonLines` share an edge; spans are derived, never stored, and spacing checks hold group members to the kerf. Plan Cutting cuts each shared span once (the owner, cut first, starts its compensated outside on the common line; partners cut only their remainders) unless the plate is set to cut them twice; only posts implementing `ICommonLinePostProcessor` (CI Fiber) may write it, others refuse. Machine trial pending. Auto Nest and Fill Area pair `AllowCommonLine` drawings at the nest's `CommonLineUsage` (Off by default, byte-identical to no common lines); `NestLayoutCheck` exempts only explicit result groups
|
||||||
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level
|
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level
|
||||||
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
|
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
|
||||||
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
|
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
|
||||||
- [Pre-post verification](docs/post-verification.md)
|
- [Pre-post verification](docs/post-verification.md)
|
||||||
- [Machine cutting defaults](docs/machine-cutting-defaults.md): the nest's machine supplies leads, kerf and cut-off settings for plates without their own parameters
|
- [Machine cutting defaults](docs/machine-cutting-defaults.md): the nest's machine supplies leads, kerf, tabs, pierce and cut-off settings per material and thickness; the Cutting Parameters dialog saves to that thickness
|
||||||
- [Shared settings](docs/shared-settings.md): in Database mode machines, post settings, clamps and nest defaults come from the server once it holds them; offline uses the last copy read, read-only
|
- [Shared settings](docs/shared-settings.md): in Database mode machines, post settings, clamps and nest defaults come from the server once it holds them; offline uses the last copy read, read-only
|
||||||
|
- [Server nesting](docs/server-nesting.md): Database-mode Auto Nest queued to the server's worker container (`OPENNEST_ROLE=worker`), one job at a time with no time limit, streaming progress and the best plan so far over SSE; the desktop replays the recorded engine answers to validate and bind locally. My Server Jobs reopens results in later sessions, and each result is kept at most once (`If-Match` Keep). The HTTP server stays off Engine/IO
|
||||||
- [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, topic IDs and the pinned help release both packaging scripts install.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Remote;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A whole-job nest request as one upload for the nest server: a ZIP holding
|
||||||
|
/// <c>job.json</c> (<see cref="NestJobDocument"/>) and <c>drawings.nest</c>, a nest archive of
|
||||||
|
/// exactly the items' drawings in the order the document names them. Drawings travel as the
|
||||||
|
/// nest file format stores them, so the server nests what the client has, edits included.
|
||||||
|
/// </summary>
|
||||||
|
public static class NestJobPackage
|
||||||
|
{
|
||||||
|
public const string JobEntry = "job.json";
|
||||||
|
public const string DrawingsEntry = "drawings.nest";
|
||||||
|
|
||||||
|
public static byte[] Write(NestPipelineRequest request, Units units = Units.Inches)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ArgumentNullException.ThrowIfNull(request.Items);
|
||||||
|
var drawings = new List<Drawing>();
|
||||||
|
foreach (var item in request.Items)
|
||||||
|
{
|
||||||
|
if (item?.Drawing != null && !drawings.Any(d => ReferenceEquals(d, item.Drawing)))
|
||||||
|
drawings.Add(item.Drawing);
|
||||||
|
}
|
||||||
|
var document = NestJobDocument.From(request, drawings);
|
||||||
|
|
||||||
|
// A throwaway nest holds the drawings only; the caller's nest is never written.
|
||||||
|
var nest = new Nest { Units = units };
|
||||||
|
foreach (var drawing in drawings)
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
|
||||||
|
{
|
||||||
|
using (var writer = new StreamWriter(zip.CreateEntry(JobEntry).Open(), new UTF8Encoding(false)))
|
||||||
|
writer.Write(document.ToJson());
|
||||||
|
using var archive = zip.CreateEntry(DrawingsEntry, CompressionLevel.NoCompression).Open();
|
||||||
|
new NestWriter(nest).Write(archive);
|
||||||
|
}
|
||||||
|
return buffer.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The package's <c>job.json</c> alone, without reading its drawings: a desktop reviewing the
|
||||||
|
/// job in a later session binds it to its own drawings instead
|
||||||
|
/// (<see cref="RemoteNestSubmission.Reopen"/>). Throws <see cref="FormatException"/> when it is not a package.
|
||||||
|
/// </summary>
|
||||||
|
public static NestJobDocument ReadDocument(byte[] package)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(package);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var zip = new ZipArchive(new MemoryStream(package, writable: false), ZipArchiveMode.Read);
|
||||||
|
var jobEntry = zip.GetEntry(JobEntry) ?? throw new FormatException($"The nest job has no {JobEntry}.");
|
||||||
|
using var reader = new StreamReader(jobEntry.Open(), Encoding.UTF8);
|
||||||
|
return NestJobDocument.FromJson(reader.ReadToEnd());
|
||||||
|
}
|
||||||
|
catch (InvalidDataException ex)
|
||||||
|
{
|
||||||
|
throw new FormatException("The nest job is not a readable ZIP package.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads a package back into a request; throws <see cref="FormatException"/> when it is not one.</summary>
|
||||||
|
public static NestPipelineRequest Read(byte[] package)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(package);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var zip = new ZipArchive(new MemoryStream(package, writable: false), ZipArchiveMode.Read);
|
||||||
|
var jobEntry = zip.GetEntry(JobEntry) ?? throw new FormatException($"The nest job has no {JobEntry}.");
|
||||||
|
var drawingsEntry = zip.GetEntry(DrawingsEntry) ?? throw new FormatException($"The nest job has no {DrawingsEntry}.");
|
||||||
|
|
||||||
|
string json;
|
||||||
|
using (var reader = new StreamReader(jobEntry.Open(), Encoding.UTF8))
|
||||||
|
json = reader.ReadToEnd();
|
||||||
|
var document = NestJobDocument.FromJson(json);
|
||||||
|
|
||||||
|
// NestReader needs a seekable stream.
|
||||||
|
using var archive = new MemoryStream();
|
||||||
|
using (var entry = drawingsEntry.Open())
|
||||||
|
entry.CopyTo(archive);
|
||||||
|
archive.Position = 0;
|
||||||
|
var nest = new NestReader(archive).Read();
|
||||||
|
return document.ToRequest(nest.Drawings.ToList());
|
||||||
|
}
|
||||||
|
catch (InvalidDataException ex)
|
||||||
|
{
|
||||||
|
throw new FormatException("The nest job is not a readable ZIP package.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,6 @@ public class NestRequestPart
|
|||||||
public string DxfPath { get; init; }
|
public string DxfPath { get; init; }
|
||||||
public int Quantity { get; init; } = 1;
|
public int Quantity { get; init; } = 1;
|
||||||
public bool AllowRotation { get; init; } = true;
|
public bool AllowRotation { get; init; } = true;
|
||||||
|
/// <summary>Nesting order: higher numbers nest first.</summary>
|
||||||
public int Priority { get; init; } = 0;
|
public int Priority { get; init; } = 0;
|
||||||
}
|
}
|
||||||
@@ -15,13 +15,23 @@ namespace OpenNest.Avalonia
|
|||||||
var window = new MainWindow();
|
var window = new MainWindow();
|
||||||
desktop.MainWindow = window;
|
desktop.MainWindow = window;
|
||||||
var args = desktop.Args ?? System.Array.Empty<string>();
|
var args = desktop.Args ?? System.Array.Empty<string>();
|
||||||
if (args.Length > 0)
|
// Each nest path before the first --option opens in its own tab; the last is shown.
|
||||||
window.OpenNest(args[0]);
|
foreach (var path in args)
|
||||||
// --snapshot <png> [plate]: render the window to an image and exit, for checking the drawing.
|
{
|
||||||
|
if (path.StartsWith("--"))
|
||||||
|
break;
|
||||||
|
window.OpenNest(path);
|
||||||
|
}
|
||||||
|
// --auto-nest: run Auto Nest on the opened nest (with --snapshot, before rendering).
|
||||||
|
if (System.Array.IndexOf(args, "--auto-nest") >= 0)
|
||||||
|
window.AutoNestOnOpen();
|
||||||
|
// --snapshot <png> [plate]: render the window to an image (status line in <png>.txt) and exit.
|
||||||
var snapshot = System.Array.IndexOf(args, "--snapshot");
|
var snapshot = System.Array.IndexOf(args, "--snapshot");
|
||||||
if (snapshot >= 0 && snapshot + 1 < args.Length)
|
if (snapshot >= 0 && snapshot + 1 < args.Length)
|
||||||
window.SaveSnapshotAndClose(args[snapshot + 1],
|
window.SaveSnapshotAndClose(args[snapshot + 1],
|
||||||
snapshot + 2 < args.Length && int.TryParse(args[snapshot + 2], out var plate) ? plate : 1);
|
snapshot + 2 < args.Length && int.TryParse(args[snapshot + 2], out var plate) ? plate : 1);
|
||||||
|
else
|
||||||
|
window.RememberPlacement();
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace OpenNest.Avalonia
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A two-button question. Cancel is the default and Esc, so a stray Enter never discards work.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ConfirmDialog : Window
|
||||||
|
{
|
||||||
|
private ConfirmDialog(string title, string message, string confirmText)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
Width = 460;
|
||||||
|
SizeToContent = SizeToContent.Height;
|
||||||
|
CanResize = false;
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||||
|
|
||||||
|
var confirm = new Button { Content = confirmText };
|
||||||
|
var cancel = new Button { Content = "Cancel", IsDefault = true, IsCancel = true };
|
||||||
|
confirm.Click += (_, _) => Close(true);
|
||||||
|
cancel.Click += (_, _) => Close(false);
|
||||||
|
|
||||||
|
var buttons = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
Spacing = 8,
|
||||||
|
};
|
||||||
|
buttons.Children.Add(confirm);
|
||||||
|
buttons.Children.Add(cancel);
|
||||||
|
|
||||||
|
var panel = new StackPanel { Margin = new Thickness(18), Spacing = 18 };
|
||||||
|
panel.Children.Add(new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap });
|
||||||
|
panel.Children.Add(buttons);
|
||||||
|
Content = panel;
|
||||||
|
Opened += (_, _) => cancel.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the user chose <paramref name="confirmText"/>.</summary>
|
||||||
|
public static Task<bool> AskAsync(
|
||||||
|
Window owner, string title, string message, string confirmText) =>
|
||||||
|
new ConfirmDialog(title, message, confirmText).ShowDialog<bool>(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,37 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:c="using:OpenNest.Avalonia.Controls"
|
|
||||||
xmlns:local="using:OpenNest.Avalonia"
|
|
||||||
x:Class="OpenNest.Avalonia.MainWindow"
|
x:Class="OpenNest.Avalonia.MainWindow"
|
||||||
Title="OpenNest (Avalonia prototype)"
|
Title="OpenNest (Avalonia prototype)"
|
||||||
Width="1400" Height="860">
|
Width="1400" Height="860">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<Menu DockPanel.Dock="Top">
|
<Menu DockPanel.Dock="Top">
|
||||||
<MenuItem Header="_File">
|
<MenuItem Header="_File">
|
||||||
<MenuItem Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
|
<MenuItem x:Name="OpenMenuItem" Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
|
||||||
|
<MenuItem x:Name="CloseMenuItem" Header="_Close" Click="OnCloseNest" />
|
||||||
|
<MenuItem x:Name="CloseAllMenuItem" Header="Close A_ll" Click="OnCloseAll" />
|
||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem Header="E_xit" Click="OnExit" />
|
<MenuItem Header="E_xit" Click="OnExit" />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem Header="_Nest">
|
||||||
|
<MenuItem x:Name="AutoNestMenuItem" Header="_Auto Nest" Click="OnAutoNest" />
|
||||||
|
</MenuItem>
|
||||||
<MenuItem Header="_View">
|
<MenuItem Header="_View">
|
||||||
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
|
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
|
||||||
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
|
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
|
||||||
<MenuItem Header="_Next Plate" InputGesture="PageDown" Click="OnNextPlate" />
|
<MenuItem Header="_Next Plate" InputGesture="PageDown" Click="OnNextPlate" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="Next N_est" InputGesture="Ctrl+Tab" Click="OnNextNest" />
|
||||||
|
<MenuItem Header="Previous Ne_st" InputGesture="Ctrl+Shift+Tab" Click="OnPreviousNest" />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
|
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
<Button Content="Open" Click="OnOpen" />
|
<Button x:Name="OpenButton" Content="Open" Click="OnOpen" />
|
||||||
<Button Content="Fit" Click="OnFit" />
|
<Button Content="Fit" Click="OnFit" />
|
||||||
<Button Content="Previous" Click="OnPreviousPlate" />
|
<Button Content="Previous" Click="OnPreviousPlate" />
|
||||||
<Button Content="Next" Click="OnNextPlate" />
|
<Button Content="Next" Click="OnNextPlate" />
|
||||||
|
<Button x:Name="AutoNestButton" Content="Auto Nest" Click="OnAutoNest" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -32,25 +39,17 @@
|
|||||||
<TextBlock x:Name="Status" Text="Open a .nest file to begin." />
|
<TextBlock x:Name="Status" Text="Open a .nest file to begin." />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="260,4,*,4,320">
|
<!-- One tab per open nest, along the bottom of the window. -->
|
||||||
<DockPanel Grid.Column="0">
|
<Border DockPanel.Dock="Bottom" Background="#E4E7EA">
|
||||||
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Plates" />
|
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||||
<ListBox x:Name="PlateList" SelectionChanged="OnPlateSelected" />
|
<StackPanel x:Name="TabStrip" Orientation="Horizontal" Margin="6,0,6,6" />
|
||||||
</DockPanel>
|
</ScrollViewer>
|
||||||
<GridSplitter Grid.Column="1" ResizeDirection="Columns" />
|
</Border>
|
||||||
<c:PlateCanvas Grid.Column="2" x:Name="Canvas" />
|
|
||||||
<GridSplitter Grid.Column="3" ResizeDirection="Columns" />
|
<Panel>
|
||||||
<DockPanel Grid.Column="4">
|
<TextBlock x:Name="EmptyHint" Text="Open a .nest file to begin." Foreground="#6B7280"
|
||||||
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Drawings on plate" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="15" />
|
||||||
<DataGrid x:Name="DrawingGrid" x:DataType="local:DrawingRow" IsReadOnly="True"
|
<ContentControl x:Name="NestHost" />
|
||||||
AutoGenerateColumns="False" GridLinesVisibility="Horizontal" CanUserSortColumns="True">
|
</Panel>
|
||||||
<DataGrid.Columns>
|
|
||||||
<DataGridTextColumn Header="Drawing" Binding="{Binding Name}" Width="*" />
|
|
||||||
<DataGridTextColumn Header="Qty" Binding="{Binding Count}" />
|
|
||||||
<DataGridTextColumn Header="Size" Binding="{Binding Size}" />
|
|
||||||
</DataGrid.Columns>
|
|
||||||
</DataGrid>
|
|
||||||
</DockPanel>
|
|
||||||
</Grid>
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Window>
|
</Window>
|
||||||
@@ -1,25 +1,47 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using OpenNest.Desktop;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Forms;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
namespace OpenNest.Avalonia
|
||||||
{
|
{
|
||||||
public sealed record DrawingRow(string Name, int Count, string Size);
|
|
||||||
|
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
private Nest nest;
|
private static readonly IBrush Accent = Brush.Parse("#3368A0");
|
||||||
private string fileName;
|
private static readonly IBrush Muted = Brush.Parse("#6B7280");
|
||||||
|
private static readonly IBrush InactiveTab = Brush.Parse("#DDE1E5");
|
||||||
|
|
||||||
|
private readonly NestTabs tabs = new();
|
||||||
|
private readonly Dictionary<NestTab, NestView> views = new();
|
||||||
|
private string notice;
|
||||||
|
private CancellationTokenSource nesting;
|
||||||
|
private NestTab nestingTab;
|
||||||
|
private Task autoNest = Task.CompletedTask;
|
||||||
|
private bool closeConfirmed;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
Canvas.ViewChanged += (_, _) => UpdateStatus();
|
// As the desktop app: plug-in engines first, then the saved engine selection.
|
||||||
|
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
|
||||||
|
var engineWarning = EngineSelection.LoadSavedSelection();
|
||||||
|
if (!string.IsNullOrEmpty(engineWarning))
|
||||||
|
notice = engineWarning;
|
||||||
|
tabs.Changed += (_, _) => ShowActiveTab();
|
||||||
KeyBindings.Add(new KeyBinding
|
KeyBindings.Add(new KeyBinding
|
||||||
{
|
{
|
||||||
Gesture = new KeyGesture(Key.O, KeyModifiers.Control),
|
Gesture = new KeyGesture(Key.O, KeyModifiers.Control),
|
||||||
@@ -27,99 +49,364 @@ namespace OpenNest.Avalonia
|
|||||||
});
|
});
|
||||||
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageUp), Command = new Command(() => StepPlate(-1)) });
|
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageUp), Command = new Command(() => StepPlate(-1)) });
|
||||||
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageDown), Command = new Command(() => StepPlate(1)) });
|
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageDown), Command = new Command(() => StepPlate(1)) });
|
||||||
|
// Ctrl+Tab before focus navigation takes the Tab key.
|
||||||
|
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
|
||||||
|
Closing += OnWindowClosing;
|
||||||
|
ShowActiveTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Null while a new tab's view is still being created.
|
||||||
|
private NestView ActiveView =>
|
||||||
|
tabs.Active != null && views.TryGetValue(tabs.Active, out var view) ? view : null;
|
||||||
|
|
||||||
|
/// <summary>Opens the nest in a new tab, or switches to its tab if open.</summary>
|
||||||
public void OpenNest(string path)
|
public void OpenNest(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
nest = new NestReader(path).Read();
|
var tab = tabs.Open(
|
||||||
|
path, file => new Document { Nest = new NestReader(file).Read() });
|
||||||
|
if (!views.ContainsKey(tab))
|
||||||
|
{
|
||||||
|
var view = new NestView(tab);
|
||||||
|
view.ViewChanged += (_, _) => { if (view == ActiveView) UpdateStatus(); };
|
||||||
|
views[tab] = view;
|
||||||
|
notice = null;
|
||||||
|
ShowActiveTab();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Status.Text = $"Could not open {Path.GetFileName(path)}: {ex.Message}";
|
ShowNotice($"Could not open {Path.GetFileName(path)}: {ex.Message}");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fileName = Path.GetFileName(path);
|
|
||||||
Title = $"{fileName} - OpenNest (Avalonia prototype)";
|
|
||||||
Canvas.ClearCache();
|
|
||||||
PlateList.ItemsSource = nest.Plates
|
|
||||||
.Select((plate, i) => $"Plate {i + 1} {plate.Size.Length:0.###} x {plate.Size.Width:0.###} ({plate.Parts.Count} parts)")
|
|
||||||
.ToList();
|
|
||||||
PlateList.SelectedIndex = nest.Plates.Count > 0 ? 0 : -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restores the window's size, position and maximized state from the user settings the
|
||||||
|
/// WinForms app shares, and saves them on close. Not used for --snapshot, whose image
|
||||||
|
/// must not depend on the user's settings.
|
||||||
|
/// </summary>
|
||||||
|
public void RememberPlacement() =>
|
||||||
|
WindowPlacementBinding.Remember(
|
||||||
|
this, WindowPlacement.MainWindow, Data.UserSettingsFile.DefaultPath);
|
||||||
|
|
||||||
|
/// <summary>Runs Auto Nest once the window opens, for --auto-nest.</summary>
|
||||||
|
public void AutoNestOnOpen() => Opened += (_, _) => autoNest = RunAutoNestAsync();
|
||||||
|
|
||||||
public void SaveSnapshotAndClose(string path, int plateNumber)
|
public void SaveSnapshotAndClose(string path, int plateNumber)
|
||||||
{
|
{
|
||||||
Opened += async (_, _) =>
|
Opened += async (_, _) =>
|
||||||
{
|
{
|
||||||
if (nest?.Plates.Count > 0)
|
ActiveView?.ShowPlate(plateNumber - 1);
|
||||||
PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1);
|
await autoNest;
|
||||||
await System.Threading.Tasks.Task.Delay(500);
|
await Task.Delay(500);
|
||||||
var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height);
|
var size = new PixelSize((int)Bounds.Width, (int)Bounds.Height);
|
||||||
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
|
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
|
||||||
bitmap.Render(this);
|
bitmap.Render(this);
|
||||||
bitmap.Save(path);
|
bitmap.Save(path);
|
||||||
|
// The status line beside the image, so headless checks can read it.
|
||||||
|
File.WriteAllText(path + ".txt", Status.Text);
|
||||||
|
closeConfirmed = true;
|
||||||
Close();
|
Close();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ShowActiveTab()
|
||||||
|
{
|
||||||
|
var view = ActiveView;
|
||||||
|
NestHost.Content = view;
|
||||||
|
EmptyHint.IsVisible = view == null;
|
||||||
|
CloseMenuItem.IsEnabled = view != null;
|
||||||
|
CloseAllMenuItem.IsEnabled = view != null;
|
||||||
|
Title = view == null
|
||||||
|
? "OpenNest (Avalonia prototype)"
|
||||||
|
: $"{view.Tab.Name} - OpenNest (Avalonia prototype)";
|
||||||
|
RebuildTabStrip();
|
||||||
|
UpdateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildTabStrip()
|
||||||
|
{
|
||||||
|
TabStrip.Children.Clear();
|
||||||
|
for (var i = 0; i < tabs.Tabs.Count; i++)
|
||||||
|
TabStrip.Children.Add(TabButton(tabs.Tabs[i], i == tabs.ActiveIndex));
|
||||||
|
|
||||||
|
var add = new Button
|
||||||
|
{
|
||||||
|
Content = "+",
|
||||||
|
FontSize = 15,
|
||||||
|
Padding = new Thickness(10, 2),
|
||||||
|
Margin = new Thickness(4, 4, 0, 0),
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
};
|
||||||
|
ToolTip.SetTip(add, "Open a nest");
|
||||||
|
add.Click += OnOpen;
|
||||||
|
TabStrip.Children.Add(add);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control TabButton(NestTab tab, bool active)
|
||||||
|
{
|
||||||
|
var name = new TextBlock
|
||||||
|
{
|
||||||
|
Text = tab.Modified ? tab.Name + " \u2022" : tab.Name,
|
||||||
|
FontSize = 13,
|
||||||
|
FontWeight = active ? FontWeight.SemiBold : FontWeight.Normal,
|
||||||
|
Foreground = active ? Brushes.Black : Muted,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
var close = new Button
|
||||||
|
{
|
||||||
|
Content = "\u2715",
|
||||||
|
FontSize = 10,
|
||||||
|
Padding = new Thickness(5, 1),
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
Foreground = Muted,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
ToolTip.SetTip(close, "Close");
|
||||||
|
close.Click += async (_, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
await CloseTabAsync(tab);
|
||||||
|
};
|
||||||
|
|
||||||
|
var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
|
||||||
|
row.Children.Add(name);
|
||||||
|
row.Children.Add(close);
|
||||||
|
var border = new Border
|
||||||
|
{
|
||||||
|
Child = row,
|
||||||
|
Padding = new Thickness(14, 5, 6, 5),
|
||||||
|
Margin = new Thickness(0, 0, 2, 0),
|
||||||
|
CornerRadius = new CornerRadius(0, 0, 6, 6),
|
||||||
|
Background = active ? Brushes.White : InactiveTab,
|
||||||
|
BorderBrush = active ? Accent : Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0, 0, 0, 2),
|
||||||
|
Cursor = new Cursor(StandardCursorType.Hand),
|
||||||
|
};
|
||||||
|
ToolTip.SetTip(border, tab.Path);
|
||||||
|
border.PointerPressed += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(border).Properties.IsLeftButtonPressed)
|
||||||
|
tabs.Activate(tabs.Tabs.ToList().IndexOf(tab));
|
||||||
|
};
|
||||||
|
return border;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CloseTabAsync(NestTab tab)
|
||||||
|
{
|
||||||
|
if (tab == nestingTab)
|
||||||
|
{
|
||||||
|
ShowNotice($"Auto Nest is running on {tab.Name}. Stop it before closing the nest.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab.Modified && !await ConfirmDialog.AskAsync(this, "Close Nest",
|
||||||
|
$"{tab.Name} has Auto Nest results that are not saved. This version of "
|
||||||
|
+ "OpenNest cannot save nests yet, so closing it discards them.",
|
||||||
|
"Discard and Close"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
tabs.Close(tab);
|
||||||
|
views.Remove(tab);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnCloseNest(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (tabs.Active != null)
|
||||||
|
await CloseTabAsync(tabs.Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnCloseAll(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var tab in tabs.Tabs.Reverse().ToList())
|
||||||
|
{
|
||||||
|
if (!await CloseTabAsync(tab))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnWindowClosing(object sender, WindowClosingEventArgs e)
|
||||||
|
{
|
||||||
|
if (closeConfirmed || !tabs.AnyModified)
|
||||||
|
{
|
||||||
|
nesting?.Cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Cancel = true;
|
||||||
|
var count = tabs.Tabs.Count(t => t.Modified);
|
||||||
|
var what = count == 1 ? "One open nest has" : $"{count} open nests have";
|
||||||
|
if (await ConfirmDialog.AskAsync(this, "Exit OpenNest",
|
||||||
|
$"{what} Auto Nest results that are not saved. This version of OpenNest "
|
||||||
|
+ "cannot save nests yet, so exiting discards them.",
|
||||||
|
"Discard and Exit"))
|
||||||
|
{
|
||||||
|
closeConfirmed = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Tab && e.KeyModifiers.HasFlag(KeyModifiers.Control))
|
||||||
|
{
|
||||||
|
StepNest(e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? -1 : 1);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNextNest(object sender, RoutedEventArgs e) => StepNest(1);
|
||||||
|
|
||||||
|
private void OnPreviousNest(object sender, RoutedEventArgs e) => StepNest(-1);
|
||||||
|
|
||||||
|
private void StepNest(int step)
|
||||||
|
{
|
||||||
|
var count = tabs.Tabs.Count;
|
||||||
|
if (count > 1)
|
||||||
|
tabs.Activate(((tabs.ActiveIndex + step) % count + count) % count);
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnOpen(object sender, RoutedEventArgs e)
|
private async void OnOpen(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
{
|
{
|
||||||
Title = "Open Nest",
|
Title = "Open Nest",
|
||||||
AllowMultiple = false,
|
AllowMultiple = true,
|
||||||
FileTypeFilter = new[] { new FilePickerFileType("Nest files") { Patterns = new[] { "*.nest" } } },
|
FileTypeFilter = new[] { new FilePickerFileType("Nest files") { Patterns = new[] { "*.nest" } } },
|
||||||
});
|
});
|
||||||
var path = files.FirstOrDefault()?.TryGetLocalPath();
|
foreach (var path in files.Select(f => f.TryGetLocalPath()).Where(p => p != null))
|
||||||
if (path != null)
|
|
||||||
OpenNest(path);
|
OpenNest(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnExit(object sender, RoutedEventArgs e) => Close();
|
private void OnExit(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
private void OnFit(object sender, RoutedEventArgs e) => Canvas.ZoomToFit();
|
private void OnAutoNest(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
// Stop always cancels and discards the whole proposal.
|
||||||
|
if (nesting != null)
|
||||||
|
{
|
||||||
|
nesting.Cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
autoNest = RunAutoNestAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nests every drawing's remaining quantity of the active nest on sheets like its shown
|
||||||
|
/// plate, through the shared pipeline off the UI thread. Other nests can be opened and
|
||||||
|
/// viewed meanwhile; the nest being nested cannot be closed until it finishes.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RunAutoNestAsync()
|
||||||
|
{
|
||||||
|
var view = ActiveView;
|
||||||
|
if (view == null || nesting != null)
|
||||||
|
return;
|
||||||
|
var tab = view.Tab;
|
||||||
|
var nest = tab.Document.Nest;
|
||||||
|
var engineName = EngineSelection.EngineName;
|
||||||
|
var template = view.PlateView.Plate
|
||||||
|
?? nest.Plates.FirstOrDefault()
|
||||||
|
?? nest.PlateDefaults.CreateNew();
|
||||||
|
var request = AutoNestJob.ForRemaining(nest, template, engineName);
|
||||||
|
if (request == null)
|
||||||
|
{
|
||||||
|
ShowNotice("Auto Nest: nothing has a remaining quantity to nest.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nesting = new CancellationTokenSource();
|
||||||
|
nestingTab = tab;
|
||||||
|
var token = nesting.Token;
|
||||||
|
SetNesting(true);
|
||||||
|
ShowNotice($"{engineName}: starting...");
|
||||||
|
var progress = new Progress<NestProgress>(p =>
|
||||||
|
{
|
||||||
|
if (!token.IsCancellationRequested)
|
||||||
|
ShowNotice(p.Description ?? $"{engineName}: plate {p.PlateNumber}");
|
||||||
|
});
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jobProgress = JobEngineNest.CreateProgress(engineName, progress);
|
||||||
|
var result = await Task.Run(() => NestPipeline.Run(request, jobProgress, token));
|
||||||
|
// Engines that ignore cancellation still have their proposal discarded.
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var firstNew = nest.Plates.Count(p => p.Parts.Count > 0);
|
||||||
|
var kept = AutoNestJob.Keep(nest, result);
|
||||||
|
if (kept.Nested)
|
||||||
|
{
|
||||||
|
tab.MarkModified();
|
||||||
|
view.Refresh(firstNew);
|
||||||
|
RebuildTabStrip();
|
||||||
|
}
|
||||||
|
ShowNotice(kept.Nested
|
||||||
|
? $"{tab.Name}: {engineName}: {kept.Note}"
|
||||||
|
: AutoNestJob.RefusalReason(result));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
ShowNotice("Auto Nest stopped; nothing was changed.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ShowNotice($"Auto Nest failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
nesting.Dispose();
|
||||||
|
nesting = null;
|
||||||
|
nestingTab = null;
|
||||||
|
SetNesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetNesting(bool running)
|
||||||
|
{
|
||||||
|
AutoNestButton.Content = running ? "Stop" : "Auto Nest";
|
||||||
|
AutoNestMenuItem.Header = running ? "_Stop Auto Nest" : "_Auto Nest";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowNotice(string text)
|
||||||
|
{
|
||||||
|
notice = text;
|
||||||
|
UpdateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFit(object sender, RoutedEventArgs e) => ActiveView?.PlateView.ZoomToFit();
|
||||||
|
|
||||||
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
|
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
|
||||||
|
|
||||||
private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
|
private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
|
||||||
|
|
||||||
private void StepPlate(int step)
|
private void StepPlate(int step) => ActiveView?.StepPlate(step);
|
||||||
{
|
|
||||||
if (nest == null || nest.Plates.Count == 0)
|
|
||||||
return;
|
|
||||||
PlateList.SelectedIndex = System.Math.Clamp(PlateList.SelectedIndex + step, 0, nest.Plates.Count - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPlateSelected(object sender, SelectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
var index = PlateList.SelectedIndex;
|
|
||||||
var plate = nest != null && index >= 0 ? nest.Plates[index] : null;
|
|
||||||
Canvas.Plate = plate;
|
|
||||||
DrawingGrid.ItemsSource = plate?.Parts
|
|
||||||
.Where(part => !part.BaseDrawing.IsCutOff)
|
|
||||||
.GroupBy(part => part.BaseDrawing)
|
|
||||||
.Select(group => new DrawingRow(group.Key.Name, group.Count(), SizeOf(group.First())))
|
|
||||||
.OrderByDescending(row => row.Count)
|
|
||||||
.ToList();
|
|
||||||
UpdateStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string SizeOf(Part part) =>
|
|
||||||
$"{part.BoundingBox.Length:0.###} x {part.BoundingBox.Width:0.###}";
|
|
||||||
|
|
||||||
private void UpdateStatus()
|
private void UpdateStatus()
|
||||||
{
|
{
|
||||||
if (Canvas.Plate == null)
|
var view = ActiveView;
|
||||||
|
var canvas = view?.PlateView;
|
||||||
|
if (canvas?.Plate == null)
|
||||||
|
{
|
||||||
|
var empty = view == null
|
||||||
|
? "Open a .nest file to begin."
|
||||||
|
: $"{view.Tab.Name} No plates";
|
||||||
|
Status.Text = notice ?? empty;
|
||||||
return;
|
return;
|
||||||
var text = $"{fileName} Plate {PlateList.SelectedIndex + 1} of {nest.Plates.Count} Zoom {Canvas.Zoom:0.##} px/unit";
|
}
|
||||||
if (Canvas.CursorWorld is { } at)
|
|
||||||
|
var text = $"{view.Tab.Name} Plate {view.PlateIndex + 1} of {view.PlateCount}"
|
||||||
|
+ $" Zoom {canvas.Zoom:0.##} px/unit";
|
||||||
|
if (canvas.CursorWorld is { } at)
|
||||||
text += $" X {at.X:0.000} Y {at.Y:0.000}";
|
text += $" X {at.X:0.000} Y {at.Y:0.000}";
|
||||||
var part = Canvas.HoveredPart ?? Canvas.SelectedPart;
|
var part = canvas.HoveredPart ?? canvas.SelectedPart;
|
||||||
if (part != null)
|
if (part != null)
|
||||||
text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}";
|
{
|
||||||
|
var how = ReferenceEquals(part, canvas.SelectedPart) ? "Selected" : "Over";
|
||||||
|
text += $" {how}: {part.BaseDrawing.Name}";
|
||||||
|
}
|
||||||
|
if (notice != null)
|
||||||
|
text += $" | {notice}";
|
||||||
Status.Text = text;
|
Status.Text = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:c="using:OpenNest.Avalonia.Controls"
|
||||||
|
xmlns:local="using:OpenNest.Avalonia"
|
||||||
|
x:Class="OpenNest.Avalonia.NestView">
|
||||||
|
<Grid ColumnDefinitions="290,4,*">
|
||||||
|
<Border Grid.Column="0" Background="White">
|
||||||
|
<TabControl x:Name="SideTabs" Padding="0">
|
||||||
|
<TabItem Header="Plates" FontSize="14" MinHeight="34">
|
||||||
|
<ListBox x:Name="PlateList" SelectionChanged="OnPlateSelected" />
|
||||||
|
</TabItem>
|
||||||
|
<TabItem Header="Drawings" FontSize="14" MinHeight="34">
|
||||||
|
<DataGrid x:Name="DrawingGrid" x:DataType="local:DrawingRow" IsReadOnly="True"
|
||||||
|
FontSize="13" AutoGenerateColumns="False"
|
||||||
|
GridLinesVisibility="Horizontal" CanUserSortColumns="True">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Drawing" Binding="{Binding Name}" Width="*" />
|
||||||
|
<DataGridTextColumn Header="Nested" Binding="{Binding Nested}" />
|
||||||
|
<DataGridTextColumn Header="Required" Binding="{Binding Required}" />
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
</Border>
|
||||||
|
<GridSplitter Grid.Column="1" ResizeDirection="Columns" Background="#D0D4D8" />
|
||||||
|
<DockPanel Grid.Column="2">
|
||||||
|
<Border DockPanel.Dock="Top" Padding="8,4" Background="White"
|
||||||
|
BorderBrush="#D0D4D8" BorderThickness="0,0,0,1">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Content="⏮" ToolTip.Tip="First plate" Click="OnFirstPlate" />
|
||||||
|
<Button Content="◀" ToolTip.Tip="Previous plate (Page Up)"
|
||||||
|
Click="OnPreviousPlate" />
|
||||||
|
<TextBlock x:Name="PlateLabel" VerticalAlignment="Center" FontSize="13" Margin="6,0" />
|
||||||
|
<Button Content="▶" ToolTip.Tip="Next plate (Page Down)" Click="OnNextPlate" />
|
||||||
|
<Button Content="⏭" ToolTip.Tip="Last plate" Click="OnLastPlate" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<c:PlateCanvas x:Name="Canvas" />
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using OpenNest.Avalonia.Controls;
|
||||||
|
using OpenNest.Desktop;
|
||||||
|
|
||||||
|
namespace OpenNest.Avalonia
|
||||||
|
{
|
||||||
|
public sealed record DrawingRow(string Name, int Nested, int Required);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One open nest: Plates and Drawings tabs on the left, the plate header and plate view on
|
||||||
|
/// the right. Each tab keeps its own view, so switching nests keeps each one's plate and zoom.
|
||||||
|
/// </summary>
|
||||||
|
public partial class NestView : UserControl
|
||||||
|
{
|
||||||
|
public NestView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public NestView(NestTab tab)
|
||||||
|
: this()
|
||||||
|
{
|
||||||
|
Tab = tab;
|
||||||
|
Canvas.ViewChanged += (_, _) => ViewChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
Refresh(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Raised when the shown plate, zoom, hover or selection changes.</summary>
|
||||||
|
public event EventHandler ViewChanged;
|
||||||
|
|
||||||
|
public NestTab Tab { get; }
|
||||||
|
|
||||||
|
public PlateCanvas PlateView => Canvas;
|
||||||
|
|
||||||
|
public int PlateIndex => PlateList.SelectedIndex;
|
||||||
|
|
||||||
|
public int PlateCount => Tab.Document.Nest.Plates.Count;
|
||||||
|
|
||||||
|
/// <summary>Rebuilds both lists and shows plate <paramref name="selectIndex"/>.</summary>
|
||||||
|
public void Refresh(int selectIndex)
|
||||||
|
{
|
||||||
|
var nest = Tab.Document.Nest;
|
||||||
|
Canvas.ClearCache();
|
||||||
|
PlateList.ItemsSource = nest.Plates
|
||||||
|
.Select((plate, i) =>
|
||||||
|
$"Plate {i + 1} {SizeText(plate)} ({plate.Parts.Count} parts)")
|
||||||
|
.ToList();
|
||||||
|
PlateList.SelectedIndex = nest.Plates.Count > 0
|
||||||
|
? System.Math.Clamp(selectIndex, 0, nest.Plates.Count - 1)
|
||||||
|
: -1;
|
||||||
|
DrawingGrid.ItemsSource = nest.Drawings
|
||||||
|
.Where(d => !d.IsCutOff)
|
||||||
|
.Select(d => new DrawingRow(d.Name, d.Quantity.Nested, d.Quantity.Required))
|
||||||
|
.ToList();
|
||||||
|
ShowPlate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowPlate(int index)
|
||||||
|
{
|
||||||
|
if (PlateCount > 0)
|
||||||
|
PlateList.SelectedIndex = System.Math.Clamp(index, 0, PlateCount - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StepPlate(int step) => ShowPlate(PlateList.SelectedIndex + step);
|
||||||
|
|
||||||
|
private void OnPlateSelected(object sender, SelectionChangedEventArgs e) => ShowPlate();
|
||||||
|
|
||||||
|
private void ShowPlate()
|
||||||
|
{
|
||||||
|
var nest = Tab.Document.Nest;
|
||||||
|
var index = PlateList.SelectedIndex;
|
||||||
|
var plate = index >= 0 && index < nest.Plates.Count ? nest.Plates[index] : null;
|
||||||
|
Canvas.Plate = plate;
|
||||||
|
PlateLabel.Text = plate == null
|
||||||
|
? "No plates"
|
||||||
|
: $"Plate {index + 1} of {nest.Plates.Count} "
|
||||||
|
+ $"{SizeText(plate)} qty {plate.Quantity}";
|
||||||
|
ViewChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SizeText(Plate plate) =>
|
||||||
|
$"{plate.Size.Length:0.###} x {plate.Size.Width:0.###}";
|
||||||
|
|
||||||
|
private void OnFirstPlate(object sender, RoutedEventArgs e) => ShowPlate(0);
|
||||||
|
|
||||||
|
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
|
||||||
|
|
||||||
|
private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
|
||||||
|
|
||||||
|
private void OnLastPlate(object sender, RoutedEventArgs e) => ShowPlate(PlateCount - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using OpenNest.Data;
|
||||||
|
using OpenNest.Desktop;
|
||||||
|
|
||||||
|
namespace OpenNest.Avalonia
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Restores a window's size, position and maximized state from the shared user-settings
|
||||||
|
/// file and saves them when it closes, under the same names the WinForms app uses.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Sizes are stored in screen pixels. Avalonia sets the client area, while WinForms stores
|
||||||
|
/// the outer window, so a size saved by one app opens a frame's width different in the
|
||||||
|
/// other; each app's own round trip is exact.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class WindowPlacementBinding
|
||||||
|
{
|
||||||
|
private readonly Window window;
|
||||||
|
private readonly string name;
|
||||||
|
private readonly string settingsPath;
|
||||||
|
private PixelPoint? normalPosition;
|
||||||
|
private (int Width, int Height)? normalSize;
|
||||||
|
|
||||||
|
private WindowPlacementBinding(Window window, string name, string settingsPath)
|
||||||
|
{
|
||||||
|
this.window = window;
|
||||||
|
this.name = name;
|
||||||
|
this.settingsPath = settingsPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Call before the window is shown.</summary>
|
||||||
|
public static void Remember(Window window, string name, string settingsPath)
|
||||||
|
{
|
||||||
|
var binding = new WindowPlacementBinding(window, name, settingsPath);
|
||||||
|
binding.Restore();
|
||||||
|
window.Opened += (_, _) => binding.Track();
|
||||||
|
window.PositionChanged += (_, _) => binding.Track();
|
||||||
|
window.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Property == TopLevel.ClientSizeProperty
|
||||||
|
|| e.Property == Window.WindowStateProperty)
|
||||||
|
binding.Track();
|
||||||
|
};
|
||||||
|
window.Closing += (_, _) => binding.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Restore()
|
||||||
|
{
|
||||||
|
var saved = WindowPlacement.Read(UserSettingsFile.Read(settingsPath), name);
|
||||||
|
var screens = window.Screens?.All;
|
||||||
|
if (saved == null || screens == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var areas = screens
|
||||||
|
.Select(s => s.WorkingArea)
|
||||||
|
.Select(a => new OpenNest.Desktop.PixelRect(a.X, a.Y, a.Width, a.Height))
|
||||||
|
.ToList();
|
||||||
|
var placement = saved.FitTo(areas);
|
||||||
|
var screen = placement.Location is { } at
|
||||||
|
? screens.First(s => s.WorkingArea.Contains(new PixelPoint(at.X, at.Y)))
|
||||||
|
: screens.FirstOrDefault(s => s.IsPrimary) ?? screens.FirstOrDefault();
|
||||||
|
|
||||||
|
if (placement.Location is { } location)
|
||||||
|
{
|
||||||
|
window.WindowStartupLocation = WindowStartupLocation.Manual;
|
||||||
|
window.Position = new PixelPoint(location.X, location.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placement.Size is { } size)
|
||||||
|
{
|
||||||
|
var scaling = screen?.Scaling ?? 1;
|
||||||
|
window.Width = size.Width / scaling;
|
||||||
|
window.Height = size.Height / scaling;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placement.Maximized)
|
||||||
|
window.WindowState = WindowState.Maximized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Remembers the normal bounds, which maximizing keeps.</summary>
|
||||||
|
private void Track()
|
||||||
|
{
|
||||||
|
if (window.WindowState != WindowState.Normal || !window.IsVisible)
|
||||||
|
return;
|
||||||
|
|
||||||
|
normalPosition = window.Position;
|
||||||
|
var scaling = window.DesktopScaling;
|
||||||
|
normalSize = ((int)System.Math.Round(window.ClientSize.Width * scaling),
|
||||||
|
(int)System.Math.Round(window.ClientSize.Height * scaling));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
Track();
|
||||||
|
var maximized = window.WindowState == WindowState.Maximized;
|
||||||
|
UserSettingsFile.Update(settingsPath, values =>
|
||||||
|
{
|
||||||
|
// A window never shown normal this session keeps the bounds saved before.
|
||||||
|
var previous = WindowPlacement.Read(values, name);
|
||||||
|
var placement = new WindowPlacement(
|
||||||
|
normalPosition is { } p ? (p.X, p.Y) : previous?.Location,
|
||||||
|
normalSize ?? previous?.Size,
|
||||||
|
maximized);
|
||||||
|
placement.WriteTo(values, name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,10 @@ namespace OpenNest.Benchmark
|
|||||||
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
|
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
|
||||||
public double SalvageRate { get; init; }
|
public double SalvageRate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Manifest "offcutCut": which edge strips count as a sheet's offcut;
|
||||||
|
/// null for all.</summary>
|
||||||
|
public CutOffAxis? OffcutCut { get; init; }
|
||||||
|
|
||||||
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
|
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
|
||||||
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
|
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
|
||||||
public List<DrawingRequest> Requests { get; init; }
|
public List<DrawingRequest> Requests { get; init; }
|
||||||
@@ -90,10 +94,11 @@ namespace OpenNest.Benchmark
|
|||||||
parts,
|
parts,
|
||||||
stock,
|
stock,
|
||||||
new NestJobOptions(
|
new NestJobOptions(
|
||||||
"Fill",
|
"Lattice",
|
||||||
maxPlates,
|
maxPlates,
|
||||||
salvageRate ?? SalvageRate,
|
salvageRate ?? SalvageRate,
|
||||||
minimumSalvageDimension ?? 0
|
minimumSalvageDimension ?? 0,
|
||||||
|
offcutCut: OffcutCut
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
|||||||
using OpenNest.Engine.BestFit;
|
using OpenNest.Engine.BestFit;
|
||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.Engine.Jobs.Adapters;
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Engine.NestingEngines;
|
||||||
|
|
||||||
namespace OpenNest.Benchmark
|
namespace OpenNest.Benchmark
|
||||||
{
|
{
|
||||||
@@ -28,6 +29,15 @@ namespace OpenNest.Benchmark
|
|||||||
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
||||||
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
/// <summary>How long past a --time-limit the harness waits before abandoning a solve.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan TimeLimitGrace = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
/// <summary>The longest --time-limit: the harness timeout above it must still fit a
|
||||||
|
/// cancellation timer.</summary>
|
||||||
|
internal static readonly TimeSpan MaxTimeLimit =
|
||||||
|
DeadlineNestingEngine.MaxLimit - TimeLimitGrace;
|
||||||
|
|
||||||
public static List<JobResult> Run(
|
public static List<JobResult> Run(
|
||||||
List<BenchmarkJob> jobs,
|
List<BenchmarkJob> jobs,
|
||||||
IReadOnlyList<NestingEngineInfo> engines,
|
IReadOnlyList<NestingEngineInfo> engines,
|
||||||
@@ -35,7 +45,9 @@ namespace OpenNest.Benchmark
|
|||||||
double? minimumSalvageDimension = null,
|
double? minimumSalvageDimension = null,
|
||||||
string outputDirectory = null,
|
string outputDirectory = null,
|
||||||
int maxParallelism = 1,
|
int maxParallelism = 1,
|
||||||
System.IO.TextWriter progressLog = null
|
System.IO.TextWriter progressLog = null,
|
||||||
|
TimeSpan? timeLimit = null,
|
||||||
|
CommonLineSetting commonLines = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
|
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
|
||||||
@@ -71,7 +83,9 @@ namespace OpenNest.Benchmark
|
|||||||
salvageRate,
|
salvageRate,
|
||||||
minimumSalvageDimension,
|
minimumSalvageDimension,
|
||||||
outputDirectory,
|
outputDirectory,
|
||||||
progressLog
|
progressLog,
|
||||||
|
timeLimit,
|
||||||
|
commonLines
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -214,13 +228,22 @@ namespace OpenNest.Benchmark
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The harness's own limit on a solve: five minutes, or a --time-limit plus a
|
||||||
|
/// grace period, so the engine's limit always fires first.</summary>
|
||||||
|
private static TimeSpan HarnessTimeout(TimeSpan? timeLimit) =>
|
||||||
|
timeLimit is { } limit && limit + TimeLimitGrace > SolveTimeout
|
||||||
|
? limit + TimeLimitGrace
|
||||||
|
: SolveTimeout;
|
||||||
|
|
||||||
private static JobResult RunOne(
|
private static JobResult RunOne(
|
||||||
BenchmarkJob job,
|
BenchmarkJob job,
|
||||||
NestingEngineInfo engineInfo,
|
NestingEngineInfo engineInfo,
|
||||||
double? salvageRate,
|
double? salvageRate,
|
||||||
double? minimumSalvageDimension,
|
double? minimumSalvageDimension,
|
||||||
string outputDirectory,
|
string outputDirectory,
|
||||||
System.IO.TextWriter progressLog
|
System.IO.TextWriter progressLog,
|
||||||
|
TimeSpan? timeLimit,
|
||||||
|
CommonLineSetting commonLines = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var requested = job.TotalRequestedQuantity;
|
var requested = job.TotalRequestedQuantity;
|
||||||
@@ -234,8 +257,22 @@ namespace OpenNest.Benchmark
|
|||||||
{
|
{
|
||||||
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
|
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
|
||||||
var engine = engineInfo.Factory();
|
var engine = engineInfo.Factory();
|
||||||
using var cts = new CancellationTokenSource(SolveTimeout);
|
if (timeLimit is { } limit)
|
||||||
var jobResult = engine.Solve(nestJob, log, cts.Token);
|
engine = new DeadlineNestingEngine(engine, limit);
|
||||||
|
using var cts = new CancellationTokenSource(HarnessTimeout(timeLimit));
|
||||||
|
NestJobResult jobResult;
|
||||||
|
if (commonLines != null)
|
||||||
|
{
|
||||||
|
// Through the desktop's pipeline, so its common-line trial runs; every part
|
||||||
|
// may pair. "off" is the like-for-like baseline for the levels.
|
||||||
|
nestJob = commonLines.Apply(nestJob);
|
||||||
|
var pipeline = NestPipeline.Run(engine, engineInfo.Name, nestJob,
|
||||||
|
job.Requests.ToDictionary(r => r.Drawing.Id.ToString(), r => r.Drawing), log, cts.Token);
|
||||||
|
nestJob = pipeline.Job;
|
||||||
|
jobResult = pipeline.Raw;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
jobResult = engine.Solve(nestJob, log, cts.Token);
|
||||||
log?.Finished(jobResult, sw.ElapsedMilliseconds);
|
log?.Finished(jobResult, sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
|
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
|
||||||
@@ -356,7 +393,7 @@ namespace OpenNest.Benchmark
|
|||||||
ElapsedMs = sw.ElapsedMilliseconds,
|
ElapsedMs = sw.ElapsedMilliseconds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (Exception ex) when (ex is OperationCanceledException || ex is TimeoutException)
|
||||||
{
|
{
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
log?.Failed("timed out", sw.ElapsedMilliseconds);
|
log?.Failed("timed out", sw.ElapsedMilliseconds);
|
||||||
@@ -368,7 +405,9 @@ namespace OpenNest.Benchmark
|
|||||||
PartsRequested = requested,
|
PartsRequested = requested,
|
||||||
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||||
ElapsedMs = sw.ElapsedMilliseconds,
|
ElapsedMs = sw.ElapsedMilliseconds,
|
||||||
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
|
Error = ex is TimeoutException
|
||||||
|
? ex.Message
|
||||||
|
: $"Timed out after {HarnessTimeout(timeLimit).TotalMinutes:0.#} minute(s)",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
|
namespace OpenNest.Benchmark
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <c>--common-lines</c>: solve every job through <see cref="NestPipeline"/> at this usage
|
||||||
|
/// level and kerf, with every part allowed to share a common line.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CommonLineSetting(CommonLineUsage Usage, double Kerf)
|
||||||
|
{
|
||||||
|
public NestJob Apply(NestJob job)
|
||||||
|
{
|
||||||
|
var o = job.Options;
|
||||||
|
var options = new NestJobOptions(o.PlacementStrategy, o.MaxPlates, o.SalvageRate,
|
||||||
|
o.MinimumSalvageDimension, o.SearchUntilCancelled, o.OffcutCut)
|
||||||
|
{
|
||||||
|
CommonLineUsage = Usage,
|
||||||
|
CommonLineKerf = Kerf,
|
||||||
|
};
|
||||||
|
var parts = job.Parts.Select(p => new NestJobPart(p.Id, p.Geometry, p.Quantity, p.Priority, p.Rotation)
|
||||||
|
{
|
||||||
|
AllowCommonLine = true,
|
||||||
|
});
|
||||||
|
return new NestJob(parts, job.Plates, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,9 +52,24 @@ namespace OpenNest.Benchmark
|
|||||||
PartSpacing = partSpacingOverride ?? manifest.Spacing,
|
PartSpacing = partSpacingOverride ?? manifest.Spacing,
|
||||||
Quadrant = manifest.Quadrant,
|
Quadrant = manifest.Quadrant,
|
||||||
Requests = requests,
|
Requests = requests,
|
||||||
|
OffcutCut = OffcutCut(manifest.OffcutCut, manifestPath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>"horizontal" or "vertical" (any case); absent counts every edge strip.
|
||||||
|
/// </summary>
|
||||||
|
private static CutOffAxis? OffcutCut(string value, string manifestPath) =>
|
||||||
|
value?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
null => null,
|
||||||
|
"horizontal" => CutOffAxis.Horizontal,
|
||||||
|
"vertical" => CutOffAxis.Vertical,
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
$"Manifest '{manifestPath}' has offcutCut \"{value}\"; "
|
||||||
|
+ "use \"horizontal\" or \"vertical\"."
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
private static Manifest ReadManifest(string manifestPath)
|
private static Manifest ReadManifest(string manifestPath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -135,12 +150,18 @@ namespace OpenNest.Benchmark
|
|||||||
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}");
|
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}");
|
||||||
|
|
||||||
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
|
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
|
||||||
if (!part.AllowRotation)
|
// Quarter turns are a 90° sweep from 0 to 270°; automatic rotation allows any angle.
|
||||||
|
var rotations = RotationSet(part);
|
||||||
|
if (rotations != RotationChoice.Any)
|
||||||
{
|
{
|
||||||
|
var quarter = rotations == RotationChoice.QuarterTurns;
|
||||||
drawing.Constraints ??= new NestConstraints();
|
drawing.Constraints ??= new NestConstraints();
|
||||||
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI;
|
drawing.Constraints.StepAngle = quarter
|
||||||
|
? OpenNest.Math.Angle.HalfPI
|
||||||
|
: OpenNest.Math.Angle.TwoPI;
|
||||||
drawing.Constraints.StartAngle = 0;
|
drawing.Constraints.StartAngle = 0;
|
||||||
drawing.Constraints.EndAngle = 0;
|
drawing.Constraints.EndAngle = quarter ? 3 * OpenNest.Math.Angle.HalfPI : 0;
|
||||||
|
drawing.Constraints.Allow180Equivalent = rotations == RotationChoice.HalfTurns;
|
||||||
}
|
}
|
||||||
|
|
||||||
var constraints = drawing.Constraints;
|
var constraints = drawing.Constraints;
|
||||||
@@ -156,12 +177,51 @@ namespace OpenNest.Benchmark
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum RotationChoice
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
HalfTurns,
|
||||||
|
QuarterTurns,
|
||||||
|
Any,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A part's "rotations" (degrees) must be {0}, {0, 180} or {0, 90, 180, 270}; without it,
|
||||||
|
/// "allowRotation" picks any angle (true, the default: automatic rotation) or none.
|
||||||
|
/// </summary>
|
||||||
|
private static RotationChoice RotationSet(ManifestPart part)
|
||||||
|
{
|
||||||
|
if (part.Rotations == null)
|
||||||
|
return part.AllowRotation ? RotationChoice.Any : RotationChoice.None;
|
||||||
|
if (!part.AllowRotation)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Manifest part '{part.Dxf}': set \"rotations\" or \"allowRotation\", not both."
|
||||||
|
);
|
||||||
|
|
||||||
|
var set = part.Rotations
|
||||||
|
.Select(degrees => ((degrees % 360) + 360) % 360)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(degrees => degrees)
|
||||||
|
.ToArray();
|
||||||
|
if (set.SequenceEqual(new[] { 0.0 }))
|
||||||
|
return RotationChoice.None;
|
||||||
|
if (set.SequenceEqual(new[] { 0.0, 180.0 }))
|
||||||
|
return RotationChoice.HalfTurns;
|
||||||
|
if (set.SequenceEqual(new[] { 0.0, 90.0, 180.0, 270.0 }))
|
||||||
|
return RotationChoice.QuarterTurns;
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Manifest part '{part.Dxf}': \"rotations\" must be [0], [0, 180] or "
|
||||||
|
+ $"[0, 90, 180, 270] (was [{string.Join(", ", part.Rotations)}])."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private class Manifest
|
private class Manifest
|
||||||
{
|
{
|
||||||
public List<string> SheetSizes { get; set; }
|
public List<string> SheetSizes { get; set; }
|
||||||
public double Spacing { get; set; }
|
public double Spacing { get; set; }
|
||||||
public double EdgeSpacing { get; set; }
|
public double EdgeSpacing { get; set; }
|
||||||
public int Quadrant { get; set; } = 1;
|
public int Quadrant { get; set; } = 1;
|
||||||
|
public string OffcutCut { get; set; }
|
||||||
public List<ManifestPart> Parts { get; set; }
|
public List<ManifestPart> Parts { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +230,7 @@ namespace OpenNest.Benchmark
|
|||||||
public string Dxf { get; set; }
|
public string Dxf { get; set; }
|
||||||
public int Quantity { get; set; }
|
public int Quantity { get; set; }
|
||||||
public bool AllowRotation { get; set; } = true;
|
public bool AllowRotation { get; set; } = true;
|
||||||
|
public List<double> Rotations { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,9 @@ static class BenchmarkConsole
|
|||||||
if (options == null)
|
if (options == null)
|
||||||
return 0; // --help was requested
|
return 0; // --help was requested
|
||||||
|
|
||||||
|
if (options.Invalid)
|
||||||
|
return 1;
|
||||||
|
|
||||||
if (options.InputPath == null)
|
if (options.InputPath == null)
|
||||||
{
|
{
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
@@ -124,7 +127,9 @@ static class BenchmarkConsole
|
|||||||
options.MinimumSalvageDimension,
|
options.MinimumSalvageDimension,
|
||||||
options.OutputDirectory,
|
options.OutputDirectory,
|
||||||
options.Parallel,
|
options.Parallel,
|
||||||
options.Progress ? Console.Out : null
|
options.Progress ? Console.Out : null,
|
||||||
|
options.TimeLimit,
|
||||||
|
options.CommonLines
|
||||||
);
|
);
|
||||||
|
|
||||||
Report.PrintDetailed(results);
|
Report.PrintDetailed(results);
|
||||||
@@ -197,10 +202,31 @@ static class BenchmarkConsole
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "--time-limit":
|
||||||
|
o.TimeLimit = i + 1 < args.Length ? ParseTimeLimit(args[++i]) : null;
|
||||||
|
if (o.TimeLimit == null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"Error: --time-limit needs a number of seconds from 0.001 to "
|
||||||
|
+ $"{BenchmarkRunner.MaxTimeLimit.TotalSeconds:0}"
|
||||||
|
);
|
||||||
|
o.Invalid = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "--progress":
|
case "--progress":
|
||||||
o.Progress = true;
|
o.Progress = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "--common-lines" when i + 1 < args.Length:
|
||||||
|
o.CommonLineLevel = args[++i];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "--kerf" when i + 1 < args.Length:
|
||||||
|
o.Kerf = double.TryParse(args[++i], System.Globalization.NumberStyles.Float,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var kerf) ? kerf : double.NaN;
|
||||||
|
break;
|
||||||
|
|
||||||
case "--help":
|
case "--help":
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
return null;
|
return null;
|
||||||
@@ -212,6 +238,25 @@ static class BenchmarkConsole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (o.CommonLineLevel != null)
|
||||||
|
{
|
||||||
|
CommonLineUsage? usage = o.CommonLineLevel.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"off" => CommonLineUsage.Off,
|
||||||
|
"when-it-adds-parts" => CommonLineUsage.WhenItAddsParts,
|
||||||
|
"whenever" => CommonLineUsage.Whenever,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (usage == null || o.Kerf is not { } kerf || !double.IsFinite(kerf) || kerf <= 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"Error: --common-lines needs off, when-it-adds-parts or whenever, and a positive --kerf"
|
||||||
|
);
|
||||||
|
o.Invalid = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
o.CommonLines = new CommonLineSetting(usage.Value, kerf);
|
||||||
|
}
|
||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +280,22 @@ static class BenchmarkConsole
|
|||||||
return sizes.Distinct().ToList();
|
return sizes.Distinct().ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static TimeSpan? ParseTimeLimit(string text)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
!double.TryParse(
|
||||||
|
text,
|
||||||
|
System.Globalization.NumberStyles.Float,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
out var seconds
|
||||||
|
)
|
||||||
|
|| !(seconds >= 0.001)
|
||||||
|
|| seconds > BenchmarkRunner.MaxTimeLimit.TotalSeconds
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return TimeSpan.FromSeconds(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
private static void PrintUsage()
|
private static void PrintUsage()
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
@@ -330,6 +391,24 @@ static class BenchmarkConsole
|
|||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
" --progress Log each solve's start, engine progress and finish"
|
" --progress Log each solve's start, engine progress and finish"
|
||||||
);
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" --time-limit <seconds> Stop each solve at this wall-clock limit and score"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" the best valid plan reported; none = timed out"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" --common-lines <level> Solve through the Auto Nest pipeline with every part allowed"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" to share a common line: off, when-it-adds-parts or whenever"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" (needs --kerf; off is the baseline to compare levels with)"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" --kerf <value> Common-line kerf in job units (with --common-lines)"
|
||||||
|
);
|
||||||
Console.Error.WriteLine(" --help Show this message");
|
Console.Error.WriteLine(" --help Show this message");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,5 +424,10 @@ static class BenchmarkConsole
|
|||||||
public double? MinimumSalvageDimension;
|
public double? MinimumSalvageDimension;
|
||||||
public int Parallel = 3;
|
public int Parallel = 3;
|
||||||
public bool Progress;
|
public bool Progress;
|
||||||
|
public TimeSpan? TimeLimit;
|
||||||
|
public string CommonLineLevel;
|
||||||
|
public double? Kerf;
|
||||||
|
public CommonLineSetting CommonLines;
|
||||||
|
public bool Invalid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+120
-22
@@ -24,6 +24,9 @@ return NestConsole.Run(args);
|
|||||||
|
|
||||||
static class NestConsole
|
static class NestConsole
|
||||||
{
|
{
|
||||||
|
private const string OutlinedSheetMessage =
|
||||||
|
"Error: Fill strategies need a rectangular sheet. Use --autonest --engine Irregular.";
|
||||||
|
|
||||||
public static int Run(string[] args)
|
public static int Run(string[] args)
|
||||||
{
|
{
|
||||||
using var cancellation = new CancellationTokenSource();
|
using var cancellation = new CancellationTokenSource();
|
||||||
@@ -35,7 +38,7 @@ static class NestConsole
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Nesting cancelled; nothing saved.");
|
Console.Error.WriteLine("Nesting canceled; nothing saved.");
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -132,14 +135,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 +212,12 @@ static class NestConsole
|
|||||||
ApplyTemplate(plate, options);
|
ApplyTemplate(plate, options);
|
||||||
ApplyOverrides(plate, options);
|
ApplyOverrides(plate, options);
|
||||||
|
|
||||||
|
if (!options.AutoNest && PlateFillService.Refusal(plate) != null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(OutlinedSheetMessage);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
var drawing = ResolveDrawing(nest, options);
|
var drawing = ResolveDrawing(nest, options);
|
||||||
|
|
||||||
if (drawing == null)
|
if (drawing == null)
|
||||||
@@ -257,6 +278,9 @@ static class NestConsole
|
|||||||
_ => BendRepairUnits.Unspecified,
|
_ => BendRepairUnits.Unspecified,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case "--svg-units-per-inch" when i + 1 < args.Length:
|
||||||
|
o.SvgUnitsPerInch = double.Parse(args[++i], CultureInfo.InvariantCulture);
|
||||||
|
break;
|
||||||
case "--drawing" when i + 1 < args.Length:
|
case "--drawing" when i + 1 < args.Length:
|
||||||
o.DrawingName = args[++i];
|
o.DrawingName = args[++i];
|
||||||
break;
|
break;
|
||||||
@@ -276,6 +300,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;
|
||||||
@@ -384,9 +411,10 @@ static class NestConsole
|
|||||||
return stock;
|
return stock;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool IsCadFile(string path) =>
|
static bool IsCadFile(string path) => CadFormats.IsCadFile(path);
|
||||||
path.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| path.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase);
|
static Units DrawingUnits(Options options) =>
|
||||||
|
options.CadUnits == BendRepairUnits.Millimeters ? Units.Millimeters : Units.Inches;
|
||||||
|
|
||||||
static Nest LoadOrCreateNest(Options options)
|
static Nest LoadOrCreateNest(Options options)
|
||||||
{
|
{
|
||||||
@@ -432,7 +460,7 @@ static class NestConsole
|
|||||||
// DXF-only mode: create a fresh nest.
|
// DXF-only mode: create a fresh nest.
|
||||||
if (dxfFiles.Count == 0)
|
if (dxfFiles.Count == 0)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg) files specified");
|
Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg/.svg) files specified");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,8 +507,12 @@ static class NestConsole
|
|||||||
MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value,
|
MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
DrawingUnits = DrawingUnits(options),
|
||||||
|
SvgUserUnitsPerInch = options.SvgUnitsPerInch,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
foreach (var warning in result.Warnings)
|
||||||
|
Console.Error.WriteLine($"Warning: {Path.GetFileName(path)}: {warning}");
|
||||||
foreach (var report in result.BendRepairReports)
|
foreach (var report in result.BendRepairReports)
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})"
|
$"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})"
|
||||||
@@ -526,6 +558,22 @@ 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,
|
||||||
|
new CadImportOptions
|
||||||
|
{
|
||||||
|
DrawingUnits = DrawingUnits(options),
|
||||||
|
SvgUserUnitsPerInch = options.SvgUnitsPerInch,
|
||||||
|
}
|
||||||
|
).Entities
|
||||||
|
);
|
||||||
|
Console.WriteLine($"Outline: {options.OutlineFile} ({plate.Outline})");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Drawing ResolveDrawing(Nest nest, Options options)
|
static Drawing ResolveDrawing(Nest nest, Options options)
|
||||||
@@ -589,17 +637,13 @@ static class NestConsole
|
|||||||
|
|
||||||
if (options.AutoNest)
|
if (options.AutoNest)
|
||||||
{
|
{
|
||||||
var nestItems = new List<NestItem>();
|
var nestItems = AutoNestItems(nest, plate, drawing, options);
|
||||||
var qty = options.Quantity > 0 ? options.Quantity : 1;
|
if (nestItems.Count == 0)
|
||||||
|
|
||||||
if (options.DrawingName != null)
|
|
||||||
{
|
{
|
||||||
nestItems.Add(new NestItem { Drawing = drawing, Quantity = qty });
|
Console.Error.WriteLine(
|
||||||
}
|
"Error: other plates already hold every required part. Use --quantity to nest more. Nothing saved."
|
||||||
else
|
);
|
||||||
{
|
return (false, sw.ElapsedMilliseconds, false, plates);
|
||||||
foreach (var d in nest.Drawings)
|
|
||||||
nestItems.Add(new NestItem { Drawing = d, Quantity = qty });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
@@ -637,6 +681,38 @@ static class NestConsole
|
|||||||
return (success, sw.ElapsedMilliseconds, accepted, plates);
|
return (success, sw.ElapsedMilliseconds, accepted, plates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The job's demand: --quantity of each drawing when given, otherwise each drawing's
|
||||||
|
/// required quantity less the copies on other plates (the target's parts are replaced).
|
||||||
|
/// As in Desktop Auto Nest, a drawing without a required quantity is left out, unless no
|
||||||
|
/// drawing has one: then one of each is nested.
|
||||||
|
/// </summary>
|
||||||
|
static List<NestItem> AutoNestItems(Nest nest, Plate target, Drawing drawing, Options options)
|
||||||
|
{
|
||||||
|
var drawings = (options.DrawingName != null ? new[] { drawing } : nest.Drawings.ToArray())
|
||||||
|
.Where(d => !d.IsCutOff)
|
||||||
|
.ToList();
|
||||||
|
var anyRequired = drawings.Any(d => d.Quantity.Required > 0);
|
||||||
|
|
||||||
|
int Demand(Drawing d)
|
||||||
|
{
|
||||||
|
if (options.Quantity > 0)
|
||||||
|
return options.Quantity;
|
||||||
|
if (!anyRequired)
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
var elsewhere = nest.Plates
|
||||||
|
.Where(p => p != target)
|
||||||
|
.Sum(p => p.Parts.Count(part => part.BaseDrawing == d) * p.Quantity);
|
||||||
|
return d.Quantity.Required - elsewhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
return drawings
|
||||||
|
.Select(d => new NestItem { Drawing = d, Quantity = Demand(d) })
|
||||||
|
.Where(item => item.Quantity > 0)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
static (bool accepted, int committed, IReadOnlyList<Plate> plates) AutoNestJob(
|
static (bool accepted, int committed, IReadOnlyList<Plate> plates) AutoNestJob(
|
||||||
Nest nest, Plate plate, List<NestItem> nestItems, Options options, CancellationToken token)
|
Nest nest, Plate plate, List<NestItem> nestItems, Options options, CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -937,6 +1013,17 @@ static class NestConsole
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A common line cut once needs a post that writes it; none falls back to cutting it twice.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
OpenNest.CNC.CommonLineCuts.EnsurePostable(nest, post);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Error: {ex.Message} No output was written.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var verification = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
|
var verification = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
|
||||||
Console.WriteLine(verification.ToDisplayText());
|
Console.WriteLine(verification.ToDisplayText());
|
||||||
if (!verification.CanPost(options.AcknowledgePostRisks))
|
if (!verification.CanPost(options.AcknowledgePostRisks))
|
||||||
@@ -981,7 +1068,7 @@ static class NestConsole
|
|||||||
Usage: OpenNest.Console <input-files...> [options]
|
Usage: OpenNest.Console <input-files...> [options]
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
input-files One or more .nest nest files or .dxf/.dwg drawing files
|
input-files One or more .nest nest files or .dxf/.dwg/.svg drawing files
|
||||||
|
|
||||||
Modes:
|
Modes:
|
||||||
<nest.nest> Load nest and fill (existing behavior)
|
<nest.nest> Load nest and fill (existing behavior)
|
||||||
@@ -990,12 +1077,19 @@ static class NestConsole
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm
|
--repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm
|
||||||
--cad-units inches|mm Explicit source coordinate units required for bend repair
|
--cad-units inches|mm Explicit source coordinate units required for bend repair;
|
||||||
|
also the units SVGs import in (default inches)
|
||||||
|
--svg-units-per-inch N SVG units per inch, for a file without a real-world size
|
||||||
--drawing <name> Drawing name to fill with (default: first drawing)
|
--drawing <name> Drawing name to fill with (default: first drawing)
|
||||||
--plate <index> Plate index to fill (default: 0)
|
--plate <index> Plate index to fill (default: 0)
|
||||||
--quantity <n> Max parts to place (default: 0 = unlimited)
|
--quantity <n> Max parts to place (default: 0 = unlimited). With --autonest: copies of
|
||||||
|
each drawing (default: its required quantity less copies on other
|
||||||
|
plates; one of each when no drawing has a required quantity)
|
||||||
--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
|
||||||
@@ -1004,8 +1098,10 @@ static class NestConsole
|
|||||||
--stock <WxL[:cost],...> With --autonest: sheet sizes to offer, each unlimited (default: the
|
--stock <WxL[:cost],...> With --autonest: sheet sizes to offer, each unlimited (default: the
|
||||||
target plate's size); spacing, quadrant and clamps follow the target
|
target plate's size); spacing, quadrant and clamps follow the target
|
||||||
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
|
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
|
||||||
--engine <name> With --autonest: jobs engine (default: Default; also Irregular, Rectangles, Fill, ...).
|
--engine <name> With --autonest: jobs engine (default: Default; also Irregular,
|
||||||
Without --autonest: fill strategy (Fill (default), Strip, Vertical Remnant, Horizontal Remnant)
|
Rectangles, Lattice, ...). Without --autonest: fill strategy
|
||||||
|
(Lattice (default), Strip, Vertical Remnant,
|
||||||
|
Horizontal Remnant)
|
||||||
--keep-parts Don't clear existing parts before filling
|
--keep-parts Don't clear existing parts before filling
|
||||||
--no-fill Keep the loaded .nest as it is (no fill); plan, save or post it
|
--no-fill Keep the loaded .nest as it is (no fill); plan, save or post it
|
||||||
--plan-cutting After filling (or loading with --no-fill), plan cutting (part order,
|
--plan-cutting After filling (or loading with --no-fill), plan cutting (part order,
|
||||||
@@ -1037,6 +1133,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;
|
||||||
@@ -1059,5 +1156,6 @@ static class NestConsole
|
|||||||
public bool AllPlates;
|
public bool AllPlates;
|
||||||
public double? RepairBendsMillimeters;
|
public double? RepairBendsMillimeters;
|
||||||
public BendRepairUnits CadUnits;
|
public BendRepairUnits CadUnits;
|
||||||
|
public double? SvgUnitsPerInch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,7 @@ public enum AutomaticCutOffDiagnosticCode
|
|||||||
ShiftedCut,
|
ShiftedCut,
|
||||||
HorizontalRemnant,
|
HorizontalRemnant,
|
||||||
NoSafeHorizontalSeparator,
|
NoSafeHorizontalSeparator,
|
||||||
|
ClampMovedTailSeparator,
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record AutomaticCutOffDiagnostic(
|
public sealed record AutomaticCutOffDiagnostic(
|
||||||
@@ -167,7 +168,21 @@ public static class AutomaticCutOffPlanner
|
|||||||
occupied = System.Math.Min(occupied, length);
|
occupied = System.Math.Min(occupied, length);
|
||||||
occupiedY = System.Math.Min(occupiedY, width);
|
occupiedY = System.Math.Min(occupiedY, width);
|
||||||
var margin = System.Math.Max(plate.PartSpacing, settings.PartClearance) + GeometryTolerance;
|
var margin = System.Math.Max(plate.PartSpacing, settings.PartClearance) + GeometryTolerance;
|
||||||
var separator = occupied + margin;
|
var nominalSeparator = occupied + margin;
|
||||||
|
// A separator on a clamp moves just past it, away from the origin: the tail is a
|
||||||
|
// little shorter, but a clamp in the way no longer costs the whole remnant. Repeat
|
||||||
|
// for a clamp whose clearance the moved line now meets.
|
||||||
|
var separator = nominalSeparator;
|
||||||
|
ClampZone movedPast = null;
|
||||||
|
for (var pass = 0; pass <= clamps.Count && double.IsFinite(separator); pass++)
|
||||||
|
{
|
||||||
|
var blocking = clamps.FirstOrDefault(zone => CrossesClamp(zone, sign * separator));
|
||||||
|
if (blocking == null)
|
||||||
|
break;
|
||||||
|
movedPast = blocking;
|
||||||
|
var farEdge = sign > 0 ? blocking.Bounds.Right : -blocking.Bounds.Left;
|
||||||
|
separator = farEdge + plate.ClampClearance + GeometryTolerance;
|
||||||
|
}
|
||||||
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
|
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
|
||||||
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
||||||
|
|
||||||
@@ -196,7 +211,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
var y = ySign * horizontalDistance.Value;
|
var y = ySign * horizontalDistance.Value;
|
||||||
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
|
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
|
||||||
var clamp = clamps.FirstOrDefault(zone =>
|
var clamp = clamps.FirstOrDefault(zone =>
|
||||||
y >= zone.Bounds.Bottom - plate.EdgeSpacing.Bottom && y <= zone.Bounds.Top + plate.EdgeSpacing.Bottom);
|
y >= zone.Bounds.Bottom - plate.ClampClearance && y <= zone.Bounds.Top + plate.ClampClearance);
|
||||||
if (limited || clamp != null)
|
if (limited || clamp != null)
|
||||||
{
|
{
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
@@ -306,7 +321,14 @@ public static class AutomaticCutOffPlanner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (keepsTail)
|
if (keepsTail)
|
||||||
|
{
|
||||||
|
if (movedPast != null)
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.ClampMovedTailSeparator,
|
||||||
|
$"The tail separator moved from {sign * nominalSeparator:G8} to {sign * separator:G8} "
|
||||||
|
+ $"to clear clamp '{movedPast.Id}' (including its clearance).", X: sign * separator));
|
||||||
AddCandidate(sign * separator, true);
|
AddCandidate(sign * separator, true);
|
||||||
|
}
|
||||||
else if (hasTailCandidate)
|
else if (hasTailCandidate)
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
|
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
|
||||||
@@ -371,7 +393,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
|
|
||||||
// A full-span line, or one with the limits this plan gives its own vertical lines.
|
// A full-span line, or one with the limits this plan gives its own vertical lines.
|
||||||
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
|
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
|
||||||
horizontalY.HasValue &&
|
horizontalY.HasValue && cut.Gaps.Count == 0 &&
|
||||||
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
|
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
|
||||||
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
|
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
|
||||||
|
|
||||||
@@ -460,7 +482,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
{
|
{
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
AutomaticCutOffDiagnosticCode.ClampBlockedCut,
|
AutomaticCutOffDiagnosticCode.ClampBlockedCut,
|
||||||
$"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.",
|
$"The proposed cut crosses clamp '{blocked.Id}' (including its clearance); it was skipped.",
|
||||||
X: x));
|
X: x));
|
||||||
if (isSeparator)
|
if (isSeparator)
|
||||||
WarnNoSeparator(x);
|
WarnNoSeparator(x);
|
||||||
@@ -473,7 +495,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
var candidate = existing == null
|
var candidate = existing == null
|
||||||
? NewVertical(x)
|
? NewVertical(x)
|
||||||
: new CutOff(existing.Position, existing.Axis)
|
: new CutOff(existing.Position, existing.Axis)
|
||||||
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
|
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit, Gaps = existing.Gaps };
|
||||||
candidate.Regenerate(plate, settings, cache);
|
candidate.Regenerate(plate, settings, cache);
|
||||||
var program = candidate.Drawing.Program;
|
var program = candidate.Drawing.Program;
|
||||||
var usable = HasUsableSegments(program);
|
var usable = HasUsableSegments(program);
|
||||||
@@ -522,7 +544,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
// Share the exact placement-frame predicate with candidate exclusion and final
|
// Share the exact placement-frame predicate with candidate exclusion and final
|
||||||
// certification. Infeasible gaps still use nominal lines through AddCandidate.
|
// certification. Infeasible gaps still use nominal lines through AddCandidate.
|
||||||
bool CrossesClamp(ClampZone zone, double x) =>
|
bool CrossesClamp(ClampZone zone, double x) =>
|
||||||
x >= zone.Bounds.Left - plate.EdgeSpacing.Bottom && x <= zone.Bounds.Right + plate.EdgeSpacing.Bottom;
|
x >= zone.Bounds.Left - plate.ClampClearance && x <= zone.Bounds.Right + plate.ClampClearance;
|
||||||
|
|
||||||
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
|
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
|
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
|
||||||
@@ -544,10 +566,12 @@ 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.ClampClearance)),
|
||||||
"Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate));
|
"Clamp zones and clamp clearance must be finite and valid.", nameof(plate));
|
||||||
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
||||||
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
|
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
|
||||||
Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
|
Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
|
||||||
@@ -652,7 +676,9 @@ public static class AutomaticCutOffPlanner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A gapped line never runs full span, whatever its limits.
|
||||||
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||||
|
cut.Gaps.Count == 0 &&
|
||||||
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
|
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
|
||||||
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
|
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
|
||||||
|
|
||||||
@@ -673,6 +699,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||||
|
cut.Gaps.Count == 0 &&
|
||||||
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
|
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
|
||||||
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
|
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ namespace OpenNest.Bending
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var angle = StartPoint.AngleTo(EndPoint);
|
var angle = StartPoint.AngleTo(EndPoint);
|
||||||
var dx = System.Math.Cos(angle) * etchLength;
|
var dx = PortableMath.Cos(angle) * etchLength;
|
||||||
var dy = System.Math.Sin(angle) * etchLength;
|
var dy = PortableMath.Sin(angle) * etchLength;
|
||||||
|
|
||||||
result.Add(
|
result.Add(
|
||||||
CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy))
|
CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace OpenNest.Breakoffs
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves and applies <see cref="Breakoff"/>s: the joining line becomes open cuts on the
|
/// Resolves and applies <see cref="Breakoff"/>s: the joining line becomes open cuts on the
|
||||||
/// break layer between tabs, each travelling with the scrap on its left so the cutting
|
/// break layer between tabs, each traveling with the scrap on its left so the cutting
|
||||||
/// strategies pierce and lead in from the scrap.
|
/// strategies pierce and lead in from the scrap.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BreakoffBuilder
|
public static class BreakoffBuilder
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ namespace OpenNest.Breakoffs
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab stays on the
|
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab stays on the
|
||||||
/// joining line and keeps a gap from its neighbours.
|
/// joining line and keeps a gap from its neighbors.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double ClampTabCenter(int index, double center)
|
public double ClampTabCenter(int index, double center)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ namespace OpenNest.Breakoffs
|
|||||||
}
|
}
|
||||||
var arc = (Arc)e;
|
var arc = (Arc)e;
|
||||||
var angle = ArcAngleAt(arc, local);
|
var angle = ArcAngleAt(arc, local);
|
||||||
return new Vector(arc.Center.X + arc.Radius * System.Math.Cos(angle),
|
return new Vector(arc.Center.X + arc.Radius * PortableMath.Cos(angle),
|
||||||
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
|
arc.Center.Y + arc.Radius * PortableMath.Sin(angle));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector TangentOn(Entity e, double local)
|
private static Vector TangentOn(Entity e, double local)
|
||||||
@@ -168,8 +168,8 @@ namespace OpenNest.Breakoffs
|
|||||||
var arc = (Arc)e;
|
var arc = (Arc)e;
|
||||||
var angle = ArcAngleAt(arc, local);
|
var angle = ArcAngleAt(arc, local);
|
||||||
return arc.IsReversed
|
return arc.IsReversed
|
||||||
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
|
? new Vector(PortableMath.Sin(angle), -PortableMath.Cos(angle))
|
||||||
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
|
: new Vector(-PortableMath.Sin(angle), PortableMath.Cos(angle));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double ArcAngleAt(Arc arc, double local) =>
|
private static double ArcAngleAt(Arc arc, double local) =>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// How a planned program cuts a common line once (see docs/common-line-cutting.md). A shared cut
|
||||||
|
/// is a run of <see cref="LayerType.CommonLine"/> moves: an open centerline cut made without
|
||||||
|
/// kerf compensation, with straight leads on its own axis. A remainder (a member's perimeter
|
||||||
|
/// between its shared spans) is an open contour whose rapid is followed directly by one of the
|
||||||
|
/// <see cref="RemainderMarker(RotationType)"/> comments, naming the winding of the perimeter it
|
||||||
|
/// belongs to, so a post compensates it on the perimeter's side even though it encloses nothing.
|
||||||
|
/// </summary>
|
||||||
|
public static class CommonLineCuts
|
||||||
|
{
|
||||||
|
/// <summary>The comment after the rapid into a remainder of a clockwise perimeter.</summary>
|
||||||
|
public const string ClockwiseRemainder = "COMMON LINE REMAINDER CW";
|
||||||
|
|
||||||
|
/// <summary>The comment after the rapid into a remainder of a counterclockwise perimeter.</summary>
|
||||||
|
public const string CounterclockwiseRemainder = "COMMON LINE REMAINDER CCW";
|
||||||
|
|
||||||
|
/// <summary>The remainder comment for a perimeter wound <paramref name="winding"/>.</summary>
|
||||||
|
public static string RemainderMarker(RotationType winding) =>
|
||||||
|
winding == RotationType.CW ? ClockwiseRemainder : CounterclockwiseRemainder;
|
||||||
|
|
||||||
|
/// <summary>The perimeter winding a remainder comment names, or null for any other code.</summary>
|
||||||
|
public static RotationType? RemainderWinding(ICode code) => code switch
|
||||||
|
{
|
||||||
|
Comment { Value: ClockwiseRemainder } => RotationType.CW,
|
||||||
|
Comment { Value: CounterclockwiseRemainder } => RotationType.CCW,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Whether <paramref name="program"/>'s own codes cut a common line once.</summary>
|
||||||
|
public static bool IsSinglePass(Program program) =>
|
||||||
|
program?.Codes != null && program.Codes.Any(code =>
|
||||||
|
code is LinearMove { Layer: LayerType.CommonLine } or ArcMove { Layer: LayerType.CommonLine }
|
||||||
|
|| RemainderWinding(code) != null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refuses, before any output, a nest with a common line cut once when
|
||||||
|
/// <paramref name="postProcessor"/> cannot write shared cuts and remainders: it would cut the
|
||||||
|
/// shared cut with compensation (into a part) or a remainder on the wrong side. Posts never
|
||||||
|
/// fall back to cutting the line twice; set the plate to cut its common lines twice instead.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">The post cannot write the nest.</exception>
|
||||||
|
public static void EnsurePostable(Nest nest, IPostProcessor postProcessor)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(nest);
|
||||||
|
if (postProcessor is ICommonLinePostProcessor)
|
||||||
|
return;
|
||||||
|
for (var p = 0; p < nest.Plates.Count; p++)
|
||||||
|
{
|
||||||
|
var plate = nest.Plates[p];
|
||||||
|
for (var i = 0; i < plate.Parts.Count; i++)
|
||||||
|
if (IsSinglePass(plate.Parts[i]?.Program))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plate {p + 1}, part {i + 1} cuts a common line once, but the "
|
||||||
|
+ $"{postProcessor?.Name ?? "selected"} post cannot write single-pass common lines. "
|
||||||
|
+ "Set the plate to cut its common lines twice (Plate > Arrange > Cut Common Lines Twice), "
|
||||||
|
+ "plan cutting again, then post.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A post that writes common lines cut once (<see cref="CommonLineCuts"/>): each shared cut
|
||||||
|
/// without kerf compensation and each remainder compensated on its perimeter's side. Posts
|
||||||
|
/// without it refuse such a nest (<see cref="CommonLineCuts.EnsurePostable"/>).
|
||||||
|
/// </summary>
|
||||||
|
public interface ICommonLinePostProcessor : IPostProcessor
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,7 +123,7 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
var angle = StartAngle + Sweep * fraction;
|
var angle = StartAngle + Sweep * fraction;
|
||||||
var sign = System.Math.Sign(Sweep);
|
var sign = System.Math.Sign(Sweep);
|
||||||
return new Vector(-System.Math.Sin(angle) * sign, System.Math.Cos(angle) * sign);
|
return new Vector(-PortableMath.Sin(angle) * sign, PortableMath.Cos(angle) * sign);
|
||||||
}
|
}
|
||||||
return Length > 0 ? new Vector((End.X - Start.X) / Length, (End.Y - Start.Y) / Length) : Vector.Zero;
|
return Length > 0 ? new Vector((End.X - Start.X) / Length, (End.Y - Start.Y) / Length) : Vector.Zero;
|
||||||
}
|
}
|
||||||
@@ -137,8 +137,8 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
var angle = StartAngle + Sweep * fraction;
|
var angle = StartAngle + Sweep * fraction;
|
||||||
return new Vector(
|
return new Vector(
|
||||||
Center.X + Radius * System.Math.Cos(angle),
|
Center.X + Radius * PortableMath.Cos(angle),
|
||||||
Center.Y + Radius * System.Math.Sin(angle));
|
Center.Y + Radius * PortableMath.Sin(angle));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Vector(
|
return new Vector(
|
||||||
@@ -379,7 +379,7 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
// Cutting strategies emit contour moves on Display; the posts cut them.
|
// Cutting strategies emit contour moves on Display; the posts cut them.
|
||||||
LayerType.Cut or LayerType.Display or LayerType.Leadin or LayerType.Leadout or LayerType.Break
|
LayerType.Cut or LayerType.Display or LayerType.Leadin or LayerType.Leadout or LayerType.Break
|
||||||
=> CutSimulationStepKind.Cut,
|
or LayerType.CommonLine => CutSimulationStepKind.Cut,
|
||||||
LayerType.Scribe => CutSimulationStepKind.Scribe,
|
LayerType.Scribe => CutSimulationStepKind.Scribe,
|
||||||
_ => null, // Tab spans are never cut.
|
_ => null, // Tab spans are never cut.
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ internal enum AutomaticEntryKind
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Which automatic entries a perimeter tabbed for support offers (see
|
/// Which automatic entries a perimeter tabbed for support offers (see
|
||||||
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalogue.
|
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalog.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal enum SupportEntries
|
internal enum SupportEntries
|
||||||
{
|
{
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using OpenNest.Geometry;
|
|||||||
namespace OpenNest.CNC.CuttingPlanning;
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure deterministic ordering of the automatic entry catalogue toward the next cut:
|
/// Pure deterministic ordering of the automatic entry catalog toward the next cut:
|
||||||
/// circles rank by outgoing distance; other contours use facing sides, tier and travel,
|
/// circles rank by outgoing distance; other contours use facing sides, tier and travel,
|
||||||
/// then a stable geometric key. It adds no
|
/// then a stable geometric key. It adds no
|
||||||
/// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility
|
/// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility
|
||||||
@@ -62,12 +62,12 @@ internal static class ContourEntryRanking
|
|||||||
if (p.Y > maxY) maxY = p.Y;
|
if (p.Y > maxY) maxY = p.Y;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Facing sides from the target relative to the centre, matching the source plan:
|
// Facing sides from the target relative to the center, matching the source plan:
|
||||||
// horizontal right when the target is right of centre else left; vertical likewise.
|
// horizontal right when the target is right of center else left; vertical likewise.
|
||||||
var centreX = minX + (maxX - minX) * 0.5;
|
var centerX = minX + (maxX - minX) * 0.5;
|
||||||
var centreY = minY + (maxY - minY) * 0.5;
|
var centerY = minY + (maxY - minY) * 0.5;
|
||||||
var facingRight = target != null && target.Value.X > centreX;
|
var facingRight = target != null && target.Value.X > centerX;
|
||||||
var facingTop = target != null && target.Value.Y > centreY;
|
var facingTop = target != null && target.Value.Y > centerY;
|
||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
.Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival)))
|
.Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival)))
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ internal static class ContourProgramVerifier
|
|||||||
if (material == null || !material.IsComplete) return false;
|
if (material == null || !material.IsComplete) return false;
|
||||||
if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false;
|
if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false;
|
||||||
var visited = new HashSet<int>();
|
var visited = new HashSet<int>();
|
||||||
var piecesCut = new Dictionary<int, HashSet<int>>();
|
// Per tabbed ring: what each run cut, as (span start, from, to) along its span.
|
||||||
|
var piecesCut = new Dictionary<int, List<(int Span, double From, double To)>>();
|
||||||
foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut))
|
foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut))
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -76,12 +77,13 @@ internal static class ContourProgramVerifier
|
|||||||
var tabs = material.Tabs[ringIndex];
|
var tabs = material.Tabs[ringIndex];
|
||||||
if (tabs.Any(tab => tab))
|
if (tabs.Any(tab => tab))
|
||||||
{
|
{
|
||||||
// A tabbed cutout: each run cuts exactly one span between tabs, whole and once.
|
// A tabbed ring: each run follows one span between tabs and never crosses a tab. A
|
||||||
if (!CutsTabbedSpan(run.Curves, material.Rings[ringIndex], tabs, entityIndex, token, ref budget)
|
// span is usually one run; a common-line member that starts on its common line cuts
|
||||||
|| !(piecesCut.TryGetValue(ringIndex, out var cut) ? cut : piecesCut[ringIndex] = new()).Add(entityIndex))
|
// the span holding that start in two, each from where the other began or ended.
|
||||||
|
if (CutTabbedSpan(run.Curves, material.Rings[ringIndex], tabs, entityIndex, token, ref budget)
|
||||||
|
is not { } piece)
|
||||||
return false;
|
return false;
|
||||||
if (piecesCut[ringIndex].Count == SpanStarts(tabs).Count)
|
(piecesCut.TryGetValue(ringIndex, out var cut) ? cut : piecesCut[ringIndex] = new()).Add(piece);
|
||||||
visited.Add(ringIndex);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!visited.Add(ringIndex)) return false;
|
if (!visited.Add(ringIndex)) return false;
|
||||||
@@ -109,6 +111,27 @@ internal static class ContourProgramVerifier
|
|||||||
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
||||||
true, tolerance, token, ref budget)) return false;
|
true, tolerance, token, ref budget)) return false;
|
||||||
}
|
}
|
||||||
|
// Every span of a tabbed ring is cut exactly once: its pieces tile it with no gap or overlap.
|
||||||
|
foreach (var (ringIndex, pieces) in piecesCut)
|
||||||
|
{
|
||||||
|
var ring = material.Rings[ringIndex];
|
||||||
|
var tabs = material.Tabs[ringIndex];
|
||||||
|
var tolerance = AdmittedClosureSlack(ring);
|
||||||
|
foreach (var start in SpanStarts(tabs))
|
||||||
|
{
|
||||||
|
var length = Span(ring, tabs, start).Sum(c => c.Length);
|
||||||
|
var at = 0.0;
|
||||||
|
foreach (var piece in pieces.Where(p => p.Span == start).OrderBy(p => p.From))
|
||||||
|
{
|
||||||
|
if (System.Math.Abs(piece.From - at) > tolerance)
|
||||||
|
return false;
|
||||||
|
at = piece.To;
|
||||||
|
}
|
||||||
|
if (System.Math.Abs(length - at) > tolerance)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
visited.Add(ringIndex);
|
||||||
|
}
|
||||||
return visited.Count == material.Rings.Count - (material.HasDisplayPerimeter ? 1 : 0);
|
return visited.Count == material.Rings.Count - (material.HasDisplayPerimeter ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,19 +139,41 @@ internal static class ContourProgramVerifier
|
|||||||
private static List<int> SpanStarts(bool[] tabs) => Enumerable.Range(0, tabs.Length)
|
private static List<int> SpanStarts(bool[] tabs) => Enumerable.Range(0, tabs.Length)
|
||||||
.Where(i => !tabs[i] && tabs[(i + tabs.Length - 1) % tabs.Length]).ToList();
|
.Where(i => !tabs[i] && tabs[(i + tabs.Length - 1) % tabs.Length]).ToList();
|
||||||
|
|
||||||
// A run that starts exactly where a span between tabs starts and covers that span exactly,
|
// What a run along a tabbed ring cuts: its span (by the span's first curve) and where along the
|
||||||
// stopping at the next tab.
|
// span it starts and ends. Null unless the run follows the ring from its start without
|
||||||
private static bool CutsTabbedSpan(IReadOnlyList<Curve> run, IReadOnlyList<Curve> ring, bool[] tabs,
|
// reaching past the span's next tab.
|
||||||
int entityIndex, CancellationToken token, ref int budget)
|
private static (int Span, double From, double To)? CutTabbedSpan(IReadOnlyList<Curve> run,
|
||||||
|
IReadOnlyList<Curve> ring, bool[] tabs, int entityIndex, CancellationToken token, ref int budget)
|
||||||
{
|
{
|
||||||
|
if (tabs[entityIndex])
|
||||||
|
return null;
|
||||||
var tolerance = AdmittedClosureSlack(ring);
|
var tolerance = AdmittedClosureSlack(ring);
|
||||||
if (!SpanStarts(tabs).Contains(entityIndex) || ring[entityIndex].DistanceAlong(run[0].Start) > tolerance)
|
var count = tabs.Length;
|
||||||
return false;
|
var start = entityIndex;
|
||||||
|
var from = ring[entityIndex].DistanceAlong(run[0].Start);
|
||||||
|
for (var guard = 0; guard < count && !tabs[(start + count - 1) % count]; guard++)
|
||||||
|
{
|
||||||
|
start = (start + count - 1) % count;
|
||||||
|
from += ring[start].Length;
|
||||||
|
}
|
||||||
|
var rest = new List<Curve>();
|
||||||
|
for (var e = entityIndex; !tabs[e % count] && rest.Count < count; e++)
|
||||||
|
rest.Add(ring[e % count]);
|
||||||
|
var length = run.Sum(c => c.Length);
|
||||||
|
var offset = ring[entityIndex].DistanceAlong(run[0].Start);
|
||||||
|
if (length > rest.Sum(c => c.Length) - offset + tolerance
|
||||||
|
|| !Follows(run, rest, 0, offset, false, tolerance, token, ref budget, partial: true))
|
||||||
|
return null;
|
||||||
|
return (start, from, from + length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The curves of the span between tabs that starts at curve start.
|
||||||
|
private static List<Curve> Span(IReadOnlyList<Curve> ring, bool[] tabs, int start)
|
||||||
|
{
|
||||||
var span = new List<Curve>();
|
var span = new List<Curve>();
|
||||||
for (var e = entityIndex; !tabs[e % tabs.Length]; e++)
|
for (var e = start; !tabs[e % tabs.Length] && span.Count < tabs.Length; e++)
|
||||||
span.Add(ring[e % tabs.Length]);
|
span.Add(ring[e % tabs.Length]);
|
||||||
return System.Math.Abs(run.Sum(c => c.Length) - span.Sum(c => c.Length)) <= tolerance
|
return span;
|
||||||
&& Follows(run, span, 0, 0, false, tolerance, token, ref budget);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zero for an exactly closed ring: a ring may lawfully close within the nominal closure
|
// Zero for an exactly closed ring: a ring may lawfully close within the nominal closure
|
||||||
@@ -163,8 +208,10 @@ internal static class ContourProgramVerifier
|
|||||||
// collinear moves and a cyclic ring's reindexing do not change coverage.
|
// collinear moves and a cyclic ring's reindexing do not change coverage.
|
||||||
// `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon
|
// `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon
|
||||||
// unless the nominal ring closes only within the nominal closure tolerance).
|
// unless the nominal ring closes only within the nominal closure tolerance).
|
||||||
|
// `partial` accepts an actual stream that ends before the expected one does.
|
||||||
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
|
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
|
||||||
int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget)
|
int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget,
|
||||||
|
bool partial = false)
|
||||||
{
|
{
|
||||||
foreach (var curve in actual)
|
foreach (var curve in actual)
|
||||||
{
|
{
|
||||||
@@ -201,7 +248,7 @@ internal static class ContourProgramVerifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cyclic || index == expected.Count || index == expected.Count - 1
|
return cyclic || partial || index == expected.Count || index == expected.Count - 1
|
||||||
&& expected[index].Length - offset <= tolerance;
|
&& expected[index].Length - offset <= tolerance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public enum CuttingCommitStatus
|
|||||||
Applied,
|
Applied,
|
||||||
Stale,
|
Stale,
|
||||||
InvalidInput,
|
InvalidInput,
|
||||||
Cancelled,
|
Canceled,
|
||||||
Failed
|
Failed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ internal static class CuttingPlanCommit
|
|||||||
Action<Plate, Part> beforeInstall)
|
Action<Plate, Part> beforeInstall)
|
||||||
{
|
{
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
|
||||||
var scope = plans?.ToArray();
|
var scope = plans?.ToArray();
|
||||||
if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
|
if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
|
||||||
return Invalid(null, "A nonempty set of captured plate plans is required.");
|
return Invalid(null, "A nonempty set of captured plate plans is required.");
|
||||||
@@ -126,7 +126,7 @@ internal static class CuttingPlanCommit
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
|
||||||
}
|
}
|
||||||
if (difference != null)
|
if (difference != null)
|
||||||
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
|
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
|
||||||
@@ -200,7 +200,7 @@ internal static class CuttingPlanCommit
|
|||||||
|
|
||||||
// Last cancellation point. The boundary below has no await, search or geometry work.
|
// Last cancellation point. The boundary below has no await, search or geometry work.
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
|
||||||
|
|
||||||
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States,
|
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States,
|
||||||
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
|
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ public static class ExecutionMotionReader
|
|||||||
var moves = new List<ExecutionMotion>();
|
var moves = new List<ExecutionMotion>();
|
||||||
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||||
var budget = 1000000;
|
var budget = 1000000;
|
||||||
Walk(program, origin, previous);
|
Walk(program, origin, previous, false, null);
|
||||||
return new OwnedExecution(moves);
|
return new OwnedExecution(moves);
|
||||||
|
|
||||||
Vector Walk(Program current, Vector frame, Vector? arrival)
|
// raiseArrival: a marked call raises the head for the rapid that arrives at its first motion.
|
||||||
|
// callCode: the part's own code that called this sub-program, or null for the part's own codes.
|
||||||
|
Vector Walk(Program current, Vector frame, Vector? arrival, bool raiseArrival, int? callCode)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
PostVerificationGeometry.Validate(frame);
|
PostVerificationGeometry.Validate(frame);
|
||||||
@@ -41,8 +43,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.");
|
||||||
@@ -70,7 +74,8 @@ public static class ExecutionMotionReader
|
|||||||
throw new ArgumentException("Subprogram rotation is not finite.");
|
throw new ArgumentException("Subprogram rotation is not finite.");
|
||||||
// Call rotation is baked into the shared program by its setter. Do not
|
// Call rotation is baked into the shared program by its setter. Do not
|
||||||
// rotate again; offsets are frame-relative even in incremental mode.
|
// rotate again; offsets are frame-relative even in incremental mode.
|
||||||
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
|
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos,
|
||||||
|
raised[codeIndex] || first && raiseArrival, callCode ?? codeIndex);
|
||||||
first = false;
|
first = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -91,7 +96,7 @@ public static class ExecutionMotionReader
|
|||||||
PostVerificationGeometry.Validate(end);
|
PostVerificationGeometry.Validate(end);
|
||||||
var rapid = motion is RapidMove;
|
var rapid = motion is RapidMove;
|
||||||
if (first && !rapid)
|
if (first && !rapid)
|
||||||
moves.Add(new(arrival, frame, true, LayerType.Display, null));
|
moves.Add(new(arrival, frame, true, LayerType.Display, null, raiseArrival, callCode: callCode));
|
||||||
var start = first && rapid ? arrival : pos;
|
var start = first && rapid ? arrival : pos;
|
||||||
var layer = motion switch
|
var layer = motion switch
|
||||||
{
|
{
|
||||||
@@ -106,7 +111,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] || first && rapid && raiseArrival,
|
||||||
|
ReferenceEquals(current, program) ? codeIndex : null, callCode));
|
||||||
pos = end;
|
pos = end;
|
||||||
first = false;
|
first = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ internal static class IncrementalClosureDrift
|
|||||||
var endPoint = last.EndPoint + correction;
|
var endPoint = last.EndPoint + correction;
|
||||||
if (last is ArcMove arc)
|
if (last is ArcMove arc)
|
||||||
{
|
{
|
||||||
// Keep the arc's centre (relative to its start) equidistant from its moved end.
|
// Keep the arc's center (relative to its start) equidistant from its moved end.
|
||||||
var chord = endPoint.DistanceTo(new Vector());
|
var chord = endPoint.DistanceTo(new Vector());
|
||||||
if (chord <= Tolerance.Epsilon)
|
if (chord <= Tolerance.Epsilon)
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ public sealed class LeadMaterialSnapshot
|
|||||||
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
|
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
|
||||||
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
|
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
|
||||||
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
|
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
|
||||||
CancellationToken token = default)
|
CancellationToken token = default) => Capture(cleanProgram, location, token, sharedSpans: false);
|
||||||
|
|
||||||
|
/// <param name="sharedSpans">A common-line member's clean program (docs/common-line-cutting.md):
|
||||||
|
/// its perimeter may carry Tab spans, the shared spans the member never cuts.</param>
|
||||||
|
internal static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
|
||||||
|
CancellationToken token, bool sharedSpans)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -121,11 +126,16 @@ public sealed class LeadMaterialSnapshot
|
|||||||
? "A display perimeter must enclose every other contour."
|
? "A display perimeter must enclose every other contour."
|
||||||
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
||||||
}
|
}
|
||||||
|
// A ring encloses a point only inside the union of its curves' extents (each at least
|
||||||
|
// the curve's own reach), so a part with thousands of holes asks parity only where it can hold.
|
||||||
|
var ringExtents = rings.Select(ring => ring.Aggregate(PostVerificationGeometry.Extent.None,
|
||||||
|
(extent, curve) => extent.Union(curve.Extent))).ToArray();
|
||||||
|
bool Encloses(int ring, Vector point) => ringExtents[ring].Contains(point) && Inside(point, rings[ring], token);
|
||||||
var outer = -1;
|
var outer = -1;
|
||||||
for (var i = 0; i < rings.Count; i++)
|
for (var i = 0; i < rings.Count; i++)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token)))
|
if (Enumerable.Range(0, rings.Count).All(j => i == j || Encloses(i, rings[j][0].Start)))
|
||||||
{
|
{
|
||||||
if (outer >= 0)
|
if (outer >= 0)
|
||||||
throw new ArgumentException("Material perimeter is ambiguous.");
|
throw new ArgumentException("Material perimeter is ambiguous.");
|
||||||
@@ -136,16 +146,17 @@ public sealed class LeadMaterialSnapshot
|
|||||||
throw new ArgumentException(ringDisplay.Contains(true)
|
throw new ArgumentException(ringDisplay.Contains(true)
|
||||||
? "A display perimeter must enclose every other contour."
|
? "A display perimeter must enclose every other contour."
|
||||||
: "Material must have exactly one enclosing perimeter.");
|
: "Material must have exactly one enclosing perimeter.");
|
||||||
if (ringTabs[outer].Any(tab => tab))
|
if (!sharedSpans && ringTabs[outer].Any(tab => tab))
|
||||||
throw new ArgumentException("A perimeter cannot have tabs.");
|
throw new ArgumentException("A perimeter cannot have tabs.");
|
||||||
if (ringDisplay.Where((_, i) => i != outer).Any(display => display))
|
if (ringDisplay.Where((_, i) => i != outer).Any(display => display))
|
||||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||||
if (ringDisplay[outer] && rings.Count == 1)
|
if (ringDisplay[outer] && rings.Count == 1)
|
||||||
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
||||||
var holes = rings.Where((_, i) => i != outer).ToArray();
|
var holeRings = Enumerable.Range(0, rings.Count).Where(i => i != outer).ToArray();
|
||||||
for (var i = 0; i < holes.Length; i++)
|
var holes = holeRings.Select(i => rings[i]).ToArray();
|
||||||
for (var j = i + 1; j < holes.Length; j++)
|
for (var i = 0; i < holeRings.Length; i++)
|
||||||
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
|
for (var j = i + 1; j < holeRings.Length; j++)
|
||||||
|
if (Encloses(holeRings[j], holes[i][0].Start) || Encloses(holeRings[i], holes[j][0].Start))
|
||||||
throw new ArgumentException("Nested cutouts are not supported material.");
|
throw new ArgumentException("Nested cutouts are not supported material.");
|
||||||
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
|
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
|
||||||
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
|
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
|
||||||
|
|||||||
@@ -65,6 +65,17 @@ public static class LeadPathValidator
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var move = execution.Motions[i];
|
var move = execution.Motions[i];
|
||||||
|
if (!move.Rapid && move.Layer == LayerType.CommonLine)
|
||||||
|
{
|
||||||
|
// A shared cut runs in the kerf between two parts: it may touch no material at all.
|
||||||
|
var (shared, sharedObstacle) = CheckSharedCut(i);
|
||||||
|
if (shared == null)
|
||||||
|
continue;
|
||||||
|
if (isFixed?.Invoke(i) != true || fixedFailures == null)
|
||||||
|
return shared;
|
||||||
|
fixedFailures.Add(new(i, shared, sharedObstacle));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (move.Layer is not (LayerType.Leadin or LayerType.Leadout))
|
if (move.Layer is not (LayerType.Leadin or LayerType.Leadout))
|
||||||
continue;
|
continue;
|
||||||
var (failure, obstacle) = CheckMotion(i);
|
var (failure, obstacle) = CheckMotion(i);
|
||||||
@@ -78,6 +89,27 @@ public static class LeadPathValidator
|
|||||||
// checks rapid travel and contour accounting for the complete program.
|
// checks rapid travel and contour accounting for the complete program.
|
||||||
return new(true, true, null);
|
return new(true, true, null);
|
||||||
|
|
||||||
|
(LeadPathValidationResult Failure, int? Obstacle) CheckSharedCut(int i)
|
||||||
|
{
|
||||||
|
var move = execution.Motions[i];
|
||||||
|
if (move.Curve == null || !double.IsFinite(move.Length) || move.Length <= PostVerificationGeometry.Epsilon)
|
||||||
|
return (new(false, false, "A common-line shared cut is missing, degenerate or inconsistent."), null);
|
||||||
|
if (MaterialContact(move.Curve, target, null, ref budget, token) is { } own)
|
||||||
|
return (new(true, false, $"Common-line shared cut {i} contacts its own part's material ({own}). "
|
||||||
|
+ "Rejoin the parts one kerf apart, then replan."), null);
|
||||||
|
for (var other = 0; other < otherMaterials.Count; other++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var material = otherMaterials[other];
|
||||||
|
if (ReferenceEquals(material, target) || move.Curve.Extent.IsClearOfQueriesFrom(material.Extent))
|
||||||
|
continue;
|
||||||
|
if (MaterialContact(move.Curve, material, null, ref budget, token) != null)
|
||||||
|
return (new(true, false, "A common-line shared cut contacts or enters another placed material. "
|
||||||
|
+ "Rejoin the parts one kerf apart at the current kerf, then replan."), other);
|
||||||
|
}
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
|
||||||
(LeadPathValidationResult Failure, int? Obstacle) CheckMotion(int i)
|
(LeadPathValidationResult Failure, int? Obstacle) CheckMotion(int i)
|
||||||
{
|
{
|
||||||
var move = execution.Motions[i];
|
var move = execution.Motions[i];
|
||||||
@@ -100,6 +132,7 @@ public static class LeadPathValidator
|
|||||||
var adjacentIndex = groupEdge + step;
|
var adjacentIndex = groupEdge + step;
|
||||||
var genuineJoint = false;
|
var genuineJoint = false;
|
||||||
var breakLead = false;
|
var breakLead = false;
|
||||||
|
var sharedLead = false;
|
||||||
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
|
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
|
||||||
{
|
{
|
||||||
var adjacent = execution.Motions[adjacentIndex];
|
var adjacent = execution.Motions[adjacentIndex];
|
||||||
@@ -119,6 +152,20 @@ public static class LeadPathValidator
|
|||||||
allowed = joint;
|
allowed = joint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A shared cut lies in the kerf between two parts, so its lead joins no boundary
|
||||||
|
// and may touch none: its joint is the cut's own start (or end for a lead-out).
|
||||||
|
else if (!adjacent.Rapid && adjacent.Layer == LayerType.CommonLine
|
||||||
|
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
|
||||||
|
{
|
||||||
|
var edge = execution.Motions[groupEdge];
|
||||||
|
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
|
||||||
|
var cutJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
|
||||||
|
if (joint.DistanceTo(cutJoint) <= PostVerificationGeometry.Epsilon)
|
||||||
|
{
|
||||||
|
genuineJoint = true;
|
||||||
|
sharedLead = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
|
else if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
|
||||||
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
|
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
|
||||||
{
|
{
|
||||||
@@ -154,11 +201,13 @@ public static class LeadPathValidator
|
|||||||
// material is expected there, but touching any of its boundaries is not.
|
// material is expected there, but touching any of its boundaries is not.
|
||||||
var failure = breakLead
|
var failure = breakLead
|
||||||
? MaterialContact(move.Curve, target, null, ref budget, token, allowInterior: true)
|
? MaterialContact(move.Curve, target, null, ref budget, token, allowInterior: true)
|
||||||
: CheckMaterial(target, allowed);
|
: CheckMaterial(target, sharedLead ? null : allowed);
|
||||||
if (failure != null)
|
if (failure != null)
|
||||||
return (new(true, false, breakLead
|
return (new(true, false, breakLead
|
||||||
? $"Break cut lead motion {i} contacts its part's boundary ({failure}). Try a shorter lead-in or move the tabs."
|
? $"Break cut lead motion {i} contacts its part's boundary ({failure}). Try a shorter lead-in or move the tabs."
|
||||||
: $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null);
|
: sharedLead
|
||||||
|
? $"Common-line lead motion {i} contacts its part's material ({failure}). The lead runs along the shared line: shorten the lead-in or lead-out, or cut the common lines twice."
|
||||||
|
: $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null);
|
||||||
var reach = move.Curve.Extent;
|
var reach = move.Curve.Extent;
|
||||||
for (var other = 0; other < otherMaterials.Count; other++)
|
for (var other = 0; other < otherMaterials.Count; other++)
|
||||||
{
|
{
|
||||||
@@ -294,13 +343,25 @@ public static class LeadPathValidator
|
|||||||
if (--budget < 0)
|
if (--budget < 0)
|
||||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||||
var contacts = curve.Contacts(boundary, out var overlap);
|
var contacts = curve.Contacts(boundary, out var overlap);
|
||||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|
if (overlap || contacts.Any(p => permittedJoint is not { } joint || !AtJoint(p, joint, boundary)))
|
||||||
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
|
|
||||||
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
|
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
|
||||||
}
|
}
|
||||||
// With all other boundary contacts excluded, the connected open path
|
// With all other boundary contacts excluded, the connected open path
|
||||||
// has constant material membership. Use the native arc midpoint, not
|
// has constant material membership. Use the native arc midpoint, not
|
||||||
// the chord midpoint or endpoints (which can both lie in scrap).
|
// the chord midpoint or endpoints (which can both lie in scrap).
|
||||||
return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
|
return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
|
||||||
|
|
||||||
|
// A lead tangent to the boundary it joins (an arc lead-in meeting a straight edge) has a
|
||||||
|
// double root at the joint that rounding splits into two contacts a few 1e-8 apart; one
|
||||||
|
// within JointTolerance of a joint the boundary itself passes through is still the joint.
|
||||||
|
static bool AtJoint(Vector point, Vector joint, PostVerificationGeometry.Curve boundary)
|
||||||
|
{
|
||||||
|
var distance = point.DistanceTo(joint);
|
||||||
|
return distance <= PostVerificationGeometry.Epsilon
|
||||||
|
|| distance <= JointTolerance && boundary.Contains(joint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// As SlideContact's: far below any kerf, far above a tangent root's rounding drift.
|
||||||
|
private const double JointTolerance = 1e-6;
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingPlanning;
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
@@ -56,17 +55,4 @@ internal static class NestedPartContainment
|
|||||||
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
|
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
|
||||||
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter && m.Curve != null)
|
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter && m.Curve != null)
|
||||||
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
|
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
|
||||||
|
|
||||||
/// <summary>Whether a host's bounds admit an inner part's bounds as a containment candidate.</summary>
|
|
||||||
internal static bool Within(Box inner, Box outer) =>
|
|
||||||
inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
|
|
||||||
&& inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
|
|
||||||
|
|
||||||
/// <summary>The union of two boxes.</summary>
|
|
||||||
internal static Box Union(Box a, Box b)
|
|
||||||
{
|
|
||||||
var left = System.Math.Min(a.Left, b.Left);
|
|
||||||
var bottom = System.Math.Min(a.Bottom, b.Bottom);
|
|
||||||
return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ internal static class NestedPartReview
|
|||||||
continue;
|
continue;
|
||||||
var program = FixedLeadReview.Clean(part);
|
var program = FixedLeadReview.Clean(part);
|
||||||
cleanBounds[index] = NestedPartContainment.CleanBounds(program, part.Location, token);
|
cleanBounds[index] = NestedPartContainment.CleanBounds(program, part.Location, token);
|
||||||
hostBounds[index] = NestedPartContainment.Union(part.BoundingBox, cleanBounds[index]);
|
hostBounds[index] = BoundingBox.Union(part.BoundingBox, cleanBounds[index]);
|
||||||
clean[index] = program;
|
clean[index] = program;
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (IsInvalid(exception))
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
@@ -52,7 +52,7 @@ internal static class NestedPartReview
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (inner == host || clean[inner] == null || clean[host] == null
|
if (inner == host || clean[inner] == null || clean[host] == null
|
||||||
|| !NestedPartContainment.Within(cleanBounds[inner], hostBounds[host]))
|
|| !BoundingBox.IsWithin(cleanBounds[inner], hostBounds[host]))
|
||||||
continue;
|
continue;
|
||||||
var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token,
|
var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token,
|
||||||
out var ambiguity);
|
out var ambiguity);
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ internal static class OwnedCuttingParameters
|
|||||||
: CopyTab(source.TabConfig),
|
: CopyTab(source.TabConfig),
|
||||||
TabsEnabled = source.TabsEnabled,
|
TabsEnabled = source.TabsEnabled,
|
||||||
SupportTabs = source.SupportTabs,
|
SupportTabs = source.SupportTabs,
|
||||||
NoBedTabMinWidth = Dimension(source.NoBedTabMinWidth),
|
TabBySize = source.TabBySize,
|
||||||
NoBedTabMaxWidth = Dimension(source.NoBedTabMaxWidth),
|
TabSizeMinWidth = Dimension(source.TabSizeMinWidth),
|
||||||
|
TabSizeMaxWidth = Dimension(source.TabSizeMaxWidth),
|
||||||
TabHeldMaxLength = Dimension(source.TabHeldMaxLength),
|
TabHeldMaxLength = Dimension(source.TabHeldMaxLength),
|
||||||
Sequencing = new SequenceParameters
|
Sequencing = new SequenceParameters
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,20 +38,38 @@ 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, int? callCode = 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;
|
||||||
|
CallCode = callCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For a motion inside a sub-program, the index of the read program's own call that runs it;
|
||||||
|
/// null for the program's own motions.
|
||||||
|
/// </summary>
|
||||||
|
internal int? CallCode { 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, CallCode);
|
||||||
}
|
}
|
||||||
@@ -27,10 +27,17 @@ public sealed class PlateCuttingState
|
|||||||
private readonly Dictionary<Drawing, DrawingRecord> drawings;
|
private readonly Dictionary<Drawing, DrawingRecord> drawings;
|
||||||
private readonly Dictionary<CuttingParameters, string> settings;
|
private readonly Dictionary<CuttingParameters, string> settings;
|
||||||
private readonly CuttingParameters plateSettings;
|
private readonly CuttingParameters plateSettings;
|
||||||
|
private readonly ObservableList<CommonLineGroup> commonLineList;
|
||||||
|
private readonly CommonLineRecord[] commonLines;
|
||||||
|
private readonly bool cutCommonLinesTwice;
|
||||||
|
|
||||||
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
|
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
|
||||||
Dictionary<Drawing, DrawingRecord> drawings, Dictionary<CuttingParameters, string> settings)
|
Dictionary<Drawing, DrawingRecord> drawings, Dictionary<CuttingParameters, string> settings)
|
||||||
{
|
{
|
||||||
|
commonLineList = plate.CommonLines;
|
||||||
|
commonLines = plate.CommonLines.Select(group => new CommonLineRecord(group, group.Kerf,
|
||||||
|
group.Members.ToArray())).ToArray();
|
||||||
|
cutCommonLinesTwice = plate.CutCommonLinesTwice;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
plateSettings = plate.CuttingParameters;
|
plateSettings = plate.CuttingParameters;
|
||||||
Plate = plate;
|
Plate = plate;
|
||||||
@@ -82,7 +89,7 @@ public sealed class PlateCuttingState
|
|||||||
}
|
}
|
||||||
var cutOffs = plate.CutOffs.Select(c => c == null
|
var cutOffs = plate.CutOffs.Select(c => c == null
|
||||||
? throw new ArgumentException("Plate contains a missing cutoff definition.")
|
? throw new ArgumentException("Plate contains a missing cutoff definition.")
|
||||||
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit,
|
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit, c.Gaps,
|
||||||
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
|
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
|
||||||
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
||||||
|
|
||||||
@@ -157,9 +164,23 @@ public sealed class PlateCuttingState
|
|||||||
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|
||||||
|| !Bits(cutOff.Position.Y, record.Position.Y)
|
|| !Bits(cutOff.Position.Y, record.Position.Y)
|
||||||
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)
|
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)
|
||||||
|
// Gaps are replaced, never edited in place, so the reference decides.
|
||||||
|
|| !ReferenceEquals(cutOff.Gaps, record.Gaps)
|
||||||
|| !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
|
|| !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
|
||||||
return $"Cutoff {i + 1} definition changed.";
|
return $"Cutoff {i + 1} definition changed.";
|
||||||
}
|
}
|
||||||
|
// Common lines decide which edges each member cuts, and whether they are cut once.
|
||||||
|
if (!ReferenceEquals(plate.CommonLines, commonLineList) || plate.CutCommonLinesTwice != cutCommonLinesTwice
|
||||||
|
|| plate.CommonLines.Count != commonLines.Length)
|
||||||
|
return "Common lines changed.";
|
||||||
|
for (var i = 0; i < commonLines.Length; i++)
|
||||||
|
{
|
||||||
|
var record = commonLines[i];
|
||||||
|
var group = plate.CommonLines[i];
|
||||||
|
if (!ReferenceEquals(group, record.Group) || !Bits(group.Kerf, record.Kerf)
|
||||||
|
|| !group.Members.SequenceEqual(record.Members, ReferenceEqualityComparer.Instance))
|
||||||
|
return $"Common line {i + 1} changed.";
|
||||||
|
}
|
||||||
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
|
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
|
||||||
// all compared exactly above.
|
// all compared exactly above.
|
||||||
return null;
|
return null;
|
||||||
@@ -205,6 +226,9 @@ public sealed class PlateCuttingState
|
|||||||
private sealed record DrawingRecord(Program Program, Program Copy, bool IsCutOff,
|
private sealed record DrawingRecord(Program Program, Program Copy, bool IsCutOff,
|
||||||
List<ContourCutPin> Pins, string PinsFingerprint);
|
List<ContourCutPin> Pins, string PinsFingerprint);
|
||||||
|
|
||||||
|
private sealed record CommonLineRecord(CommonLineGroup Group, double Kerf, Part[] Members);
|
||||||
|
|
||||||
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
|
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
|
||||||
double? StartLimit, double? EndLimit, double HeadLockOvercut, bool FarEdgeFirst);
|
double? StartLimit, double? EndLimit, IReadOnlyList<CutOffGap> Gaps, double HeadLockOvercut,
|
||||||
|
bool FarEdgeFirst);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ using System.Threading;
|
|||||||
using OpenNest.CNC.CuttingStrategy;
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
using OpenNest.Diagnostics;
|
using OpenNest.Diagnostics;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
using OpenNest.Support;
|
using OpenNest.Support;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingPlanning;
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
@@ -24,6 +25,23 @@ public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector
|
|||||||
public Vector? Pierce { get; internal init; }
|
public Vector? Pierce { get; internal init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How a common-line member's outside is cut once (see docs/common-line-cutting.md), with the
|
||||||
|
/// group's kerf. The member cuts its outside compensated, so each shared edge it cuts lands on the
|
||||||
|
/// kerf centerline: that edge is the common line. Spans an earlier member cut carry the Tab layer
|
||||||
|
/// and are never cut again. A member that cuts a common line itself (<see cref="Entry"/> set)
|
||||||
|
/// starts there: at <see cref="Entry"/> (part coordinates), the vertex where its shared edge
|
||||||
|
/// begins, with a straight lead-in from <see cref="Pierce"/> along that edge's axis, and runs on
|
||||||
|
/// round its perimeter with no lead-out, closing at the entry or stopping in an earlier member's
|
||||||
|
/// kerf. Its other stretches, and every stretch of a member without an entry, are remainder pieces
|
||||||
|
/// entered in the kerf before them.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CommonLinePerimeter(double Kerf, Vector? Entry = null, Vector? Pierce = null)
|
||||||
|
{
|
||||||
|
/// <summary>Whether the member starts its outside on a common line it cuts.</summary>
|
||||||
|
public bool StartsOnCommonLine => Entry != null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated
|
/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated
|
||||||
/// program and keep it stable during Capture. This is not a topology or lead-path verdict.
|
/// program and keep it stable during Capture. This is not a topology or lead-path verdict.
|
||||||
@@ -39,6 +57,8 @@ public sealed class PreparedContours
|
|||||||
// Closed cutouts with tab spans, in program order. Like break cuts they are not choices:
|
// Closed cutouts with tab spans, in program order. Like break cuts they are not choices:
|
||||||
// every full or prefix emission cuts their pieces after the break cuts.
|
// every full or prefix emission cuts their pieces after the break cuts.
|
||||||
private readonly List<Shape> tabbedCutouts;
|
private readonly List<Shape> tabbedCutouts;
|
||||||
|
// Pierce-zone resolutions by contour, shared with every copy: the contours are the same by ordinal.
|
||||||
|
private readonly PierceZoneMemo pierceZones;
|
||||||
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and
|
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and
|
||||||
// its own owned settings (the pin's lead-in, lead-out and tab).
|
// its own owned settings (the pin's lead-in, lead-out and tab).
|
||||||
private readonly ContourChoice[] pinned;
|
private readonly ContourChoice[] pinned;
|
||||||
@@ -53,6 +73,10 @@ public sealed class PreparedContours
|
|||||||
// never-executed DisplayPerimeterTail. Null for an ordinary cut perimeter.
|
// never-executed DisplayPerimeterTail. Null for an ordinary cut perimeter.
|
||||||
private readonly Shape displayOutline;
|
private readonly Shape displayOutline;
|
||||||
private bool hasCutPerimeter => displayOutline == null;
|
private bool hasCutPerimeter => displayOutline == null;
|
||||||
|
// Set for a common-line member, cut from one fixed entry: its whole perimeter from where its
|
||||||
|
// first shared edge begins, or its remainder pieces between the Tab spans earlier members cut.
|
||||||
|
private readonly CommonLinePerimeter commonLine;
|
||||||
|
private readonly ContourChoice commonLineEntry;
|
||||||
|
|
||||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
||||||
CuttingParameters parameters)
|
CuttingParameters parameters)
|
||||||
@@ -61,9 +85,12 @@ public sealed class PreparedContours
|
|||||||
}
|
}
|
||||||
|
|
||||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
||||||
CuttingParameters parameters, Shape displayOutline)
|
CuttingParameters parameters, Shape displayOutline, PierceZoneMemo pierceZones = null,
|
||||||
|
CommonLinePerimeter commonLine = null)
|
||||||
{
|
{
|
||||||
|
this.pierceZones = pierceZones ?? new PierceZoneMemo();
|
||||||
this.displayOutline = displayOutline;
|
this.displayOutline = displayOutline;
|
||||||
|
this.commonLine = commonLine;
|
||||||
this.shapes = shapes;
|
this.shapes = shapes;
|
||||||
this.scribes = scribes;
|
this.scribes = scribes;
|
||||||
this.breakCuts = breakCuts;
|
this.breakCuts = breakCuts;
|
||||||
@@ -71,11 +98,31 @@ public sealed class PreparedContours
|
|||||||
this.parameters = parameters;
|
this.parameters = parameters;
|
||||||
pinned = new ContourChoice[shapes.Length];
|
pinned = new ContourChoice[shapes.Length];
|
||||||
pinNumbers = new int[shapes.Length];
|
pinNumbers = new int[shapes.Length];
|
||||||
|
if (commonLine is { Entry: { } entry })
|
||||||
|
{
|
||||||
|
// The outside starts where a shared edge it cuts begins, pierced off it.
|
||||||
|
var perimeter = shapes[^1];
|
||||||
|
var entity = perimeter.Entities.FindIndex(e => e.Layer != SpecialLayers.Tab
|
||||||
|
&& Start(e).DistanceTo(entry) <= PostVerificationGeometry.Epsilon);
|
||||||
|
commonLineEntry = new ContourChoice(shapes.Length - 1, entity, Start(perimeter.Entities[entity]))
|
||||||
|
{
|
||||||
|
Owner = this,
|
||||||
|
Pierce = commonLine.Pierce,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (commonLine != null)
|
||||||
|
{
|
||||||
|
// The remainder's first piece starts where a shared span ends: the one fixed entry.
|
||||||
|
var perimeter = shapes[^1];
|
||||||
|
var first = ContourCuttingStrategy.TabbedCutoutPieces(perimeter)[0].Entities[0];
|
||||||
|
var entity = perimeter.Entities.IndexOf(first);
|
||||||
|
commonLineEntry = new ContourChoice(shapes.Length - 1, entity, Start(first)) { Owner = this };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
|
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
|
||||||
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters,
|
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters,
|
||||||
unpinned.displayOutline)
|
unpinned.displayOutline, unpinned.pierceZones, unpinned.commonLine)
|
||||||
{
|
{
|
||||||
contourParameters = new CuttingParameters[shapes.Length];
|
contourParameters = new CuttingParameters[shapes.Length];
|
||||||
var rejected = new List<CutPinResolution>();
|
var rejected = new List<CutPinResolution>();
|
||||||
@@ -92,8 +139,9 @@ public sealed class PreparedContours
|
|||||||
throw new ArgumentException("A pin resolution does not belong to these contours.", nameof(pins));
|
throw new ArgumentException("A pin resolution does not belong to these contours.", nameof(pins));
|
||||||
if (pinned[contour] != null)
|
if (pinned[contour] != null)
|
||||||
throw new ArgumentException("Two pins resolve to one contour.", nameof(pins));
|
throw new ArgumentException("Two pins resolve to one contour.", nameof(pins));
|
||||||
// The emitter tabs only the outside, so a tab elsewhere cannot be cut as pinned.
|
// The emitter tabs only the outside, so a tab elsewhere cannot be cut as pinned. A
|
||||||
if (pin.Pin.HasTab && contour != PerimeterOrdinal)
|
// common-line member's outside is cut from its shared spans, never from a pin.
|
||||||
|
if (pin.Pin.HasTab && contour != PerimeterOrdinal || commonLine != null && contour == PerimeterOrdinal)
|
||||||
{
|
{
|
||||||
rejected.Add(pin);
|
rejected.Add(pin);
|
||||||
continue;
|
continue;
|
||||||
@@ -113,7 +161,7 @@ public sealed class PreparedContours
|
|||||||
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review.
|
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review.
|
||||||
private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
|
private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
|
||||||
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
|
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
|
||||||
source.displayOutline)
|
source.displayOutline, source.pierceZones, source.commonLine)
|
||||||
{
|
{
|
||||||
for (var contour = 0; contour < Count; contour++)
|
for (var contour = 0; contour < Count; contour++)
|
||||||
{
|
{
|
||||||
@@ -152,6 +200,8 @@ public sealed class PreparedContours
|
|||||||
throw new ArgumentException("Support tabs need a complete analysis with a tab point.", nameof(support));
|
throw new ArgumentException("Support tabs need a complete analysis with a tab point.", nameof(support));
|
||||||
if (!hasCutPerimeter)
|
if (!hasCutPerimeter)
|
||||||
throw new ArgumentException("A display perimeter is never cut, so it cannot hold a support tab.", nameof(support));
|
throw new ArgumentException("A display perimeter is never cut, so it cannot hold a support tab.", nameof(support));
|
||||||
|
if (commonLine != null)
|
||||||
|
throw new ArgumentException("A common-line member is never support-tabbed.", nameof(support));
|
||||||
return new PreparedContours(this, support, location, tab);
|
return new PreparedContours(this, support, location, tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,12 +215,24 @@ public sealed class PreparedContours
|
|||||||
public Vector SupportOrigin => supportOrigin;
|
public Vector SupportOrigin => supportOrigin;
|
||||||
|
|
||||||
/// <summary>Whether the emitted perimeter is tabbed: by the plate settings, its pin or support.</summary>
|
/// <summary>Whether the emitted perimeter is tabbed: by the plate settings, its pin or support.</summary>
|
||||||
internal bool PerimeterTabbed => hasCutPerimeter && ParametersFor(PerimeterOrdinal).TabsEnabled;
|
internal bool PerimeterTabbed => hasCutPerimeter && commonLine == null && ParametersFor(PerimeterOrdinal).TabsEnabled;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True for a common-line member (see <see cref="CommonLinePerimeter"/>): its perimeter has one
|
||||||
|
/// fixed entry and is emitted whole from its common line, or as its remainder pieces.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsCommonLinePerimeter => commonLine != null;
|
||||||
|
|
||||||
|
/// <summary>How the member's outside is cut once, or null for any other part.</summary>
|
||||||
|
public CommonLinePerimeter CommonLine => commonLine;
|
||||||
|
|
||||||
public int Count => shapes.Length;
|
public int Count => shapes.Length;
|
||||||
|
|
||||||
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
|
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
|
||||||
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
|
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
|
||||||
|
|
||||||
|
/// <summary>The pierce-zone resolutions these contours' emissions share; for tests.</summary>
|
||||||
|
internal PierceZoneMemo PierceZones => pierceZones;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
|
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
|
||||||
/// is a display perimeter, so every contour below it is a hole.
|
/// is a display perimeter, so every contour below it is a hole.
|
||||||
@@ -180,7 +242,7 @@ public sealed class PreparedContours
|
|||||||
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
|
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
|
||||||
public bool HasCutPerimeter => hasCutPerimeter;
|
public bool HasCutPerimeter => hasCutPerimeter;
|
||||||
|
|
||||||
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
|
/// <summary>Resolved pins this preparation could not honor: a tab on an internal contour.</summary>
|
||||||
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
||||||
|
|
||||||
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
||||||
@@ -203,10 +265,10 @@ public sealed class PreparedContours
|
|||||||
/// contour keeps the automatic selection with <paramref name="parameters"/>.
|
/// contour keeps the automatic selection with <paramref name="parameters"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static PreparedContours Capture(Program program, CuttingParameters parameters,
|
public static PreparedContours Capture(Program program, CuttingParameters parameters,
|
||||||
IReadOnlyList<CutPinResolution> pins, CancellationToken token = default)
|
IReadOnlyList<CutPinResolution> pins, CancellationToken token = default, CommonLinePerimeter commonLine = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(pins);
|
ArgumentNullException.ThrowIfNull(pins);
|
||||||
var unpinned = Capture(program, parameters, token);
|
var unpinned = Capture(program, parameters, token, commonLine);
|
||||||
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
|
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +290,12 @@ public sealed class PreparedContours
|
|||||||
|
|
||||||
private CuttingParameters ParametersFor(int contourOrdinal) => contourParameters?[contourOrdinal] ?? parameters;
|
private CuttingParameters ParametersFor(int contourOrdinal) => contourParameters?[contourOrdinal] ?? parameters;
|
||||||
|
|
||||||
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default)
|
/// <param name="commonLine">For a common-line member: how its outside is cut once. With an
|
||||||
|
/// entry, the program's perimeter is closed and starts there. Without one, it must carry the
|
||||||
|
/// spans earlier members cut on the Tab layer (CommonLineCutting.MarkSharedSpans); it is then
|
||||||
|
/// the perimeter, cut in pieces between them, rather than a tabbed cutout.</param>
|
||||||
|
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default,
|
||||||
|
CommonLinePerimeter commonLine = null)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var ownedParameters = OwnedCuttingParameters.Copy(parameters);
|
var ownedParameters = OwnedCuttingParameters.Copy(parameters);
|
||||||
@@ -291,6 +358,60 @@ public sealed class PreparedContours
|
|||||||
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
|
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
|
||||||
FinishContour();
|
FinishContour();
|
||||||
}
|
}
|
||||||
|
if (commonLine != null)
|
||||||
|
{
|
||||||
|
if (displayPerimeter != null || current.Entities.Count != 0)
|
||||||
|
throw new ArgumentException("A common-line member needs one closed cut perimeter.");
|
||||||
|
if (!double.IsFinite(commonLine.Kerf) || commonLine.Kerf <= 0
|
||||||
|
|| (commonLine.Entry == null) != (commonLine.Pierce == null))
|
||||||
|
throw new ArgumentException("A common-line member needs a positive kerf, and an entry with its pierce.");
|
||||||
|
var outside = tabbedCutouts.Concat(contours).MaxBy(shape => shape.BoundingBox.Width * shape.BoundingBox.Length);
|
||||||
|
if (commonLine is { Entry: { } entry, Pierce: { } pierce })
|
||||||
|
{
|
||||||
|
// Cut from its entry vertex on a common line; every other contour lies inside it.
|
||||||
|
PostVerificationGeometry.Validate(entry);
|
||||||
|
PostVerificationGeometry.Validate(pierce);
|
||||||
|
if (outside == null
|
||||||
|
|| !outside.Entities.Exists(e => e.Layer != SpecialLayers.Tab
|
||||||
|
&& Start(e).DistanceTo(entry) <= PostVerificationGeometry.Epsilon)
|
||||||
|
|| pierce.DistanceTo(entry) <= PostVerificationGeometry.Epsilon)
|
||||||
|
throw new ArgumentException("A common-line member's perimeter does not start at its common line.");
|
||||||
|
if (contours.Contains(outside))
|
||||||
|
{
|
||||||
|
contours.Remove(outside);
|
||||||
|
contours.Add(outside);
|
||||||
|
foreach (var cut in breakCuts)
|
||||||
|
cut.UpdateBounds();
|
||||||
|
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, null, null,
|
||||||
|
commonLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The outside carries the shared spans earlier members cut as tabs; every other contour lies inside it.
|
||||||
|
else if (outside == null || !tabbedCutouts.Contains(outside)
|
||||||
|
|| ContourCuttingStrategy.TabbedCutoutPieces(outside).Count == 0)
|
||||||
|
throw new ArgumentException("A common-line member's perimeter does not carry its shared spans.");
|
||||||
|
// Each remainder piece is pierced in the kerf of the span before it, the external
|
||||||
|
// lead-in's reach back along it: the span must be that long. Never shortened.
|
||||||
|
var reach = ContourCuttingStrategy.CommonLineLeadInReach(ownedParameters.ExternalLeadIn ?? new NoLeadIn());
|
||||||
|
if (reach <= OpenNest.Math.Tolerance.Epsilon)
|
||||||
|
throw new NotSupportedException("A common-line member's remainder needs a lead-in with length: "
|
||||||
|
+ "its compensation starts in the shared kerf along the lead-in. Choose an external lead-in "
|
||||||
|
+ "with length, or cut the common lines twice.");
|
||||||
|
foreach (var piece in ContourCuttingStrategy.TabbedCutoutPieces(outside))
|
||||||
|
{
|
||||||
|
var index = outside.Entities.IndexOf(piece.Entities[0]);
|
||||||
|
var span = outside.Entities[(index + outside.Entities.Count - 1) % outside.Entities.Count];
|
||||||
|
if (span is not Line line || line.Length < reach + PostVerificationGeometry.Epsilon)
|
||||||
|
throw new NotSupportedException(FormattableString.Invariant(
|
||||||
|
$"One of its shared spans is {span.Length:0.####} long, shorter than the {reach:0.####} external lead-in its remainder starts along in that span's kerf. Shorten the lead-in, or cut the common lines twice."));
|
||||||
|
}
|
||||||
|
tabbedCutouts.Remove(outside);
|
||||||
|
contours.Add(outside);
|
||||||
|
foreach (var cut in breakCuts)
|
||||||
|
cut.UpdateBounds();
|
||||||
|
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, null, null,
|
||||||
|
commonLine);
|
||||||
|
}
|
||||||
if (displayPerimeter != null)
|
if (displayPerimeter != null)
|
||||||
{
|
{
|
||||||
if (current.Entities.Count != 0)
|
if (current.Entities.Count != 0)
|
||||||
@@ -360,6 +481,8 @@ public sealed class PreparedContours
|
|||||||
var shape = GetShape(contourOrdinal);
|
var shape = GetShape(contourOrdinal);
|
||||||
if (pinned[contourOrdinal] is { } pin)
|
if (pinned[contourOrdinal] is { } pin)
|
||||||
return pin;
|
return pin;
|
||||||
|
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
|
||||||
|
return fixedEntry;
|
||||||
var point = shape.ClosestPointTo(approach, out var entity);
|
var point = shape.ClosestPointTo(approach, out var entity);
|
||||||
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
|
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
|
||||||
}
|
}
|
||||||
@@ -378,6 +501,8 @@ public sealed class PreparedContours
|
|||||||
var shape = GetShape(contourOrdinal);
|
var shape = GetShape(contourOrdinal);
|
||||||
if (pinned[contourOrdinal] is { } pin)
|
if (pinned[contourOrdinal] is { } pin)
|
||||||
return new[] { pin };
|
return new[] { pin };
|
||||||
|
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
|
||||||
|
return new[] { fixedEntry };
|
||||||
var entries = new List<ContourChoice>();
|
var entries = new List<ContourChoice>();
|
||||||
entries.Add(ClosestEntry(contourOrdinal, approach));
|
entries.Add(ClosestEntry(contourOrdinal, approach));
|
||||||
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
|
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
|
||||||
@@ -387,8 +512,8 @@ public sealed class PreparedContours
|
|||||||
if (entity is Circle circle)
|
if (entity is Circle circle)
|
||||||
{
|
{
|
||||||
for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++)
|
for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++)
|
||||||
Add(i, circle.Center + new Vector(System.Math.Cos(angle * System.Math.PI / 4),
|
Add(i, circle.Center + new Vector(PortableMath.Cos(angle * System.Math.PI / 4),
|
||||||
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius);
|
PortableMath.Sin(angle * System.Math.PI / 4)) * circle.Radius);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -465,7 +590,9 @@ public sealed class PreparedContours
|
|||||||
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
|
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
|
||||||
return Array.Empty<ContourChoice>();
|
return Array.Empty<ContourChoice>();
|
||||||
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
|
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
|
||||||
var found = ErodedPierce.Find(shape, ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
|
var depth = System.Math.Max(ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
|
||||||
|
ApplicableLeadInLength(contourOrdinal));
|
||||||
|
var found = ErodedPierce.Find(shape, depth,
|
||||||
targets.Select(t => t.Point).ToArray(), max);
|
targets.Select(t => t.Point).ToArray(), max);
|
||||||
var entries = new List<ContourChoice>();
|
var entries = new List<ContourChoice>();
|
||||||
for (var i = 0; i < found.Pierces.Count; i++)
|
for (var i = 0; i < found.Pierces.Count; i++)
|
||||||
@@ -510,7 +637,7 @@ public sealed class PreparedContours
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The uncapped preferred automatic start catalogue for one contour, in preference then
|
/// The uncapped preferred automatic start catalog for one contour, in preference then
|
||||||
/// contour-travel order: convex corners of the contour's own winding, then straight-edge
|
/// contour-travel order: convex corners of the contour's own winding, then straight-edge
|
||||||
/// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear
|
/// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear
|
||||||
/// line/line splits, circles and interior points never appear; each geometric point is
|
/// line/line splits, circles and interior points never appear; each geometric point is
|
||||||
@@ -525,13 +652,15 @@ public sealed class PreparedContours
|
|||||||
var shape = GetShape(contourOrdinal);
|
var shape = GetShape(contourOrdinal);
|
||||||
if (pinned[contourOrdinal] is { } pin)
|
if (pinned[contourOrdinal] is { } pin)
|
||||||
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
||||||
|
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
|
||||||
|
return new[] { new ContourEntryCandidate(fixedEntry, AutomaticEntryKind.Pinned) };
|
||||||
if (IsSingleCircle(shape))
|
if (IsSingleCircle(shape))
|
||||||
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
|
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalog.");
|
||||||
return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
|
return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The complete uncapped automatic start catalogue: the preferred points of
|
/// The complete uncapped automatic start catalog: the preferred points of
|
||||||
/// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc
|
/// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc
|
||||||
/// midpoints, near-convex-corner points on straight edges, the eight compass points of a
|
/// midpoints, near-convex-corner points on straight edges, the eight compass points of a
|
||||||
/// whole circle, and the exact target-facing closest point toward
|
/// whole circle, and the exact target-facing closest point toward
|
||||||
@@ -539,7 +668,7 @@ public sealed class PreparedContours
|
|||||||
/// A pure-circle contour therefore yields compass points instead of refusing. Every
|
/// A pure-circle contour therefore yields compass points instead of refusing. Every
|
||||||
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
|
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
|
||||||
/// preferred tier; fallbacks never replace a preferred point at the same geometry. A
|
/// preferred tier; fallbacks never replace a preferred point at the same geometry. A
|
||||||
/// pinned contour's catalogue is its pinned entry alone: nothing may relocate it.
|
/// pinned contour's catalog is its pinned entry alone: nothing may relocate it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
||||||
int contourOrdinal,
|
int contourOrdinal,
|
||||||
@@ -551,6 +680,8 @@ public sealed class PreparedContours
|
|||||||
var shape = GetShape(contourOrdinal);
|
var shape = GetShape(contourOrdinal);
|
||||||
if (pinned[contourOrdinal] is { } pin)
|
if (pinned[contourOrdinal] is { } pin)
|
||||||
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
||||||
|
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
|
||||||
|
return new[] { new ContourEntryCandidate(fixedEntry, AutomaticEntryKind.Pinned) };
|
||||||
var all = IsSingleCircle(shape)
|
var all = IsSingleCircle(shape)
|
||||||
? new List<ContourEntryCandidate>()
|
? new List<ContourEntryCandidate>()
|
||||||
: PreferredCandidates(shape, contourOrdinal, token);
|
: PreferredCandidates(shape, contourOrdinal, token);
|
||||||
@@ -560,7 +691,7 @@ public sealed class PreparedContours
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
|
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
|
||||||
/// its automatic catalogue then keeps to steadying points unless asked for a fallback.
|
/// its automatic catalog then keeps to steadying points unless asked for a fallback.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
|
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
|
||||||
|
|
||||||
@@ -601,8 +732,8 @@ 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)
|
=> CatalogPoints(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>
|
||||||
@@ -634,8 +765,8 @@ public sealed class PreparedContours
|
|||||||
// The legacy eight compass points, same native construction.
|
// The legacy eight compass points, same native construction.
|
||||||
for (var angle = 0; angle < 8; angle++)
|
for (var angle = 0; angle < 8; angle++)
|
||||||
fallbacks.Add(Fallback(i, circle.Center + new Vector(
|
fallbacks.Add(Fallback(i, circle.Center + new Vector(
|
||||||
System.Math.Cos(angle * System.Math.PI / 4),
|
PortableMath.Cos(angle * System.Math.PI / 4),
|
||||||
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius,
|
PortableMath.Sin(angle * System.Math.PI / 4)) * circle.Radius,
|
||||||
AutomaticEntryKind.CircleCompass));
|
AutomaticEntryKind.CircleCompass));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -647,7 +778,7 @@ public sealed class PreparedContours
|
|||||||
// endpoint and so never lands on the reflex/cusp vertex at the far end.
|
// endpoint and so never lands on the reflex/cusp vertex at the far end.
|
||||||
if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle))
|
if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle))
|
||||||
{
|
{
|
||||||
foreach (var corner in CataloguePoints(shape, token)
|
foreach (var corner in CatalogPoints(shape, token)
|
||||||
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -761,14 +892,16 @@ public sealed class PreparedContours
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The preferred catalogue points for one contour: for each entity, its convex corner
|
/// The preferred catalog 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)> CatalogPoints(
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
@@ -783,7 +916,7 @@ public sealed class PreparedContours
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var entity = shape.Entities[i];
|
var entity = shape.Entities[i];
|
||||||
|
|
||||||
// Vertex reached by travelling along entity i (its end point), reported under
|
// Vertex reached by traveling along entity i (its end point), reported under
|
||||||
// entity i. The closed contour guarantees every vertex appears exactly once
|
// entity i. The closed contour guarantees every vertex appears exactly once
|
||||||
// this way; each is classified from the contour's own winding.
|
// this way; each is classified from the contour's own winding.
|
||||||
var vertex = End(entity);
|
var vertex = End(entity);
|
||||||
@@ -792,29 +925,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 * PortableMath.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>
|
||||||
@@ -842,8 +1011,8 @@ public sealed class PreparedContours
|
|||||||
// reads only the chosen contour and the contour count, so only that contour is cloned.
|
// reads only the chosen contour and the contour count, so only that contour is cloned.
|
||||||
var owned = new Shape[shapes.Length];
|
var owned = new Shape[shapes.Length];
|
||||||
owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
|
owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
|
||||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
|
return new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitCandidateIsolated(
|
||||||
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal);
|
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal, commonLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
|
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
|
||||||
@@ -855,11 +1024,11 @@ public sealed class PreparedContours
|
|||||||
|
|
||||||
// Only a complete emission carries a display perimeter's tail: prefixes are search probes.
|
// Only a complete emission carries a display perimeter's tail: prefixes are search probes.
|
||||||
private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) =>
|
private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) =>
|
||||||
new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
|
new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitPrepared(
|
||||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
|
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
|
||||||
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
|
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
|
||||||
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal,
|
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal,
|
||||||
complete ? (Shape)displayOutline?.Clone() : null);
|
complete ? (Shape)displayOutline?.Clone() : null, commonLine);
|
||||||
|
|
||||||
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
|
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
|
||||||
public int BreakCutCount => breakCuts.Count;
|
public int BreakCutCount => breakCuts.Count;
|
||||||
@@ -879,8 +1048,9 @@ public sealed class PreparedContours
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
|
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
|
||||||
// Only the outside is tabbed, with its own pin's tab when it is pinned.
|
// Only the outside is tabbed, with its own pin's tab when it is pinned. A common-line
|
||||||
if (!hasCutPerimeter)
|
// member's shared spans are accounted as the material's tabs, never as a tab gap.
|
||||||
|
if (!hasCutPerimeter || commonLine != null)
|
||||||
return new(this, choices, expected, 0);
|
return new(this, choices, expected, 0);
|
||||||
var perimeter = ParametersFor(PerimeterOrdinal);
|
var perimeter = ParametersFor(PerimeterOrdinal);
|
||||||
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
|
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
|
||||||
@@ -913,6 +1083,11 @@ public sealed class PreparedContours
|
|||||||
// A pinned contour is cut exactly as pinned: no other entry may be emitted for it.
|
// A pinned contour is cut exactly as pinned: no other entry may be emitted for it.
|
||||||
if (pinned[choice.ContourOrdinal] is { } pin && pin != choice)
|
if (pinned[choice.ContourOrdinal] is { } pin && pin != choice)
|
||||||
throw new ArgumentException("A pinned contour keeps its drawing pin's entry.");
|
throw new ArgumentException("A pinned contour keeps its drawing pin's entry.");
|
||||||
|
// A common-line perimeter is cut from its one fixed entry.
|
||||||
|
if (commonLineEntry != null && choice.ContourOrdinal == PerimeterOrdinal
|
||||||
|
&& (choice.EntityOrdinal != commonLineEntry.EntityOrdinal || choice.Point != commonLineEntry.Point
|
||||||
|
|| choice.Pierce != commonLineEntry.Pierce))
|
||||||
|
throw new ArgumentException("A common-line member's perimeter is cut from its fixed entry.");
|
||||||
if (choice.Pierce is { } pierce)
|
if (choice.Pierce is { } pierce)
|
||||||
{
|
{
|
||||||
PostVerificationGeometry.Validate(pierce);
|
PostVerificationGeometry.Validate(pierce);
|
||||||
@@ -921,24 +1096,27 @@ public sealed class PreparedContours
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ContourChoice FixedCommonLineEntry(int contourOrdinal) =>
|
||||||
|
commonLineEntry != null && contourOrdinal == PerimeterOrdinal ? commonLineEntry : null;
|
||||||
|
|
||||||
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
|
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
|
||||||
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
|
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Representative POINTS per contour ordinal (bounding-box centre) for hole routing
|
/// Representative POINTS per contour ordinal (bounding-box center) for hole routing
|
||||||
/// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the
|
/// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the
|
||||||
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
|
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal IReadOnlyList<Vector?> HoleCentres(CancellationToken token = default)
|
internal IReadOnlyList<Vector?> HoleCenters(CancellationToken token = default)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var centres = new Vector?[Count];
|
var centers = new Vector?[Count];
|
||||||
for (var contour = 0; contour < PerimeterOrdinal; contour++)
|
for (var contour = 0; contour < PerimeterOrdinal; contour++)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
centres[contour] = GetShape(contour).BoundingBox.Center;
|
centers[contour] = GetShape(contour).BoundingBox.Center;
|
||||||
}
|
}
|
||||||
return centres;
|
return centers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The bounding box of contour <paramref name="contourOrdinal"/>, local. A routing hint only.</summary>
|
/// <summary>The bounding box of contour <paramref name="contourOrdinal"/>, local. A routing hint only.</summary>
|
||||||
@@ -960,7 +1138,7 @@ public sealed class PreparedContours
|
|||||||
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
|
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
|
||||||
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
|
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
|
||||||
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
|
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
|
||||||
/// stored centre sits beside the chord's midpoint) is never cut.
|
/// stored center sits beside the chord's midpoint) is never cut.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
|
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ public sealed class ReleasedContourState
|
|||||||
{
|
{
|
||||||
private readonly List<Obstacle> obstacles = new();
|
private readonly List<Obstacle> obstacles = new();
|
||||||
private readonly bool reportMissingLeadIns;
|
private readonly bool reportMissingLeadIns;
|
||||||
|
// Parts (by caller part number) whose single tab may not hold them level: once cut, their
|
||||||
|
// tabbed perimeter is an obstacle as if cut free, since the part may tip into the head's path.
|
||||||
|
private readonly Func<int, bool> tabMayNotHold;
|
||||||
|
// Common-line members (by caller part number): their outside is cut as open remainder pieces
|
||||||
|
// that end in kerf their shared cuts opened, so once the part's program ends it is cut free.
|
||||||
|
private readonly Func<int, bool> commonLineMember;
|
||||||
// How the last analysed part ended: the contours it numbered, and whether nothing it left
|
// How the last analysed part ended: the contours it numbered, and whether nothing it left
|
||||||
// (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
|
// (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
|
||||||
// motions appended to it are judged. See Continue.
|
// motions appended to it are judged. See Continue.
|
||||||
@@ -22,14 +28,30 @@ public sealed class ReleasedContourState
|
|||||||
|
|
||||||
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
|
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
|
||||||
/// may pierce directly on a contour and checks travel without requiring a lead.</param>
|
/// may pierce directly on a contour and checks travel without requiring a lead.</param>
|
||||||
public ReleasedContourState(bool reportMissingLeadIns)
|
public ReleasedContourState(bool reportMissingLeadIns) : this(reportMissingLeadIns, null) { }
|
||||||
|
|
||||||
|
/// <param name="tabMayNotHold">Part numbers whose support tab may not hold them level (no
|
||||||
|
/// single tab can steady them): the longest open cut a part leaves, its tabbed
|
||||||
|
/// perimeter, becomes an obstacle when the part ends, closed across its tab gap.</param>
|
||||||
|
public ReleasedContourState(bool reportMissingLeadIns, Func<int, bool> tabMayNotHold)
|
||||||
|
: this(reportMissingLeadIns, tabMayNotHold, null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="commonLineMember">Part numbers of common-line members (docs/common-line-cutting.md):
|
||||||
|
/// a member's open cuts are its remainder pieces, which end in kerf its shared cuts opened. They
|
||||||
|
/// need no retention proof, and when the part ends they become one obstacle, closed across its
|
||||||
|
/// shared spans, since the part is then cut free.</param>
|
||||||
|
public ReleasedContourState(bool reportMissingLeadIns, Func<int, bool> tabMayNotHold, Func<int, bool> commonLineMember)
|
||||||
{
|
{
|
||||||
this.reportMissingLeadIns = reportMissingLeadIns;
|
this.reportMissingLeadIns = reportMissingLeadIns;
|
||||||
|
this.tabMayNotHold = tabMayNotHold;
|
||||||
|
this.commonLineMember = commonLineMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReleasedContourState Copy()
|
public ReleasedContourState Copy()
|
||||||
{
|
{
|
||||||
var copy = new ReleasedContourState(reportMissingLeadIns)
|
var copy = new ReleasedContourState(reportMissingLeadIns, tabMayNotHold, commonLineMember)
|
||||||
{
|
{
|
||||||
partContours = partContours,
|
partContours = partContours,
|
||||||
partClosed = partClosed,
|
partClosed = partClosed,
|
||||||
@@ -54,9 +76,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 +91,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 +123,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)
|
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>();
|
||||||
@@ -112,29 +138,42 @@ public sealed class ReleasedContourState
|
|||||||
var hasLead = false;
|
var hasLead = false;
|
||||||
var contourNumber = contoursBefore;
|
var contourNumber = contoursBefore;
|
||||||
var inBreakCut = false;
|
var inBreakCut = false;
|
||||||
|
var member = commonLineMember?.Invoke(part) == true;
|
||||||
var reportedTab = false;
|
var reportedTab = false;
|
||||||
|
// The contour last closed, and the one a lead-out has just left it into: a cutout's
|
||||||
|
// lead-out ends in its slug, so the rapid after it must leave over that cutout's own cut.
|
||||||
|
CutGroup lastClosed = null;
|
||||||
|
Obstacle lastObstacle = null;
|
||||||
|
Obstacle exited = null;
|
||||||
|
var exitPoint = Vector.Zero;
|
||||||
for (var index = 0; index < moves.Count; index++)
|
for (var index = 0; index < moves.Count; index++)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var move = moves[index];
|
var move = moves[index];
|
||||||
var owned = isFixed?.Invoke(index) == true;
|
var owned = isFixed?.Invoke(index) == true;
|
||||||
if (!move.Rapid && move.Layer == LayerType.Break)
|
if (!move.Rapid && move.Layer != LayerType.Leadout)
|
||||||
|
exited = null;
|
||||||
|
if (!move.Rapid && move.Layer is LayerType.Break or LayerType.CommonLine)
|
||||||
{
|
{
|
||||||
// A break cut is intentionally open and retained by its uncut tabs: it is never a
|
// A break cut is intentionally open and retained by its uncut tabs: it is never a
|
||||||
// rapid obstacle and never an unexplained fragment, but it is still pierced.
|
// rapid obstacle and never an unexplained fragment, but it is still pierced. A
|
||||||
|
// common line's shared cut runs in the kerf between two parts: it bounds and
|
||||||
|
// retains nothing, so it is never an obstacle and takes no part in the open-cut proof.
|
||||||
if (!inBreakCut)
|
if (!inBreakCut)
|
||||||
{
|
{
|
||||||
Finish();
|
Finish();
|
||||||
if (reportMissingLeadIns && !cutoff && !hasLead)
|
if (reportMissingLeadIns && !cutoff && !hasLead)
|
||||||
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||||
"A break cut is missing lead-in geometry.")
|
move.Layer == LayerType.Break ? "A break cut is missing lead-in geometry."
|
||||||
|
: "A common-line shared cut is missing lead-in geometry.")
|
||||||
{ Location = move.Curve.Start }, owned);
|
{ Location = move.Curve.Start }, owned);
|
||||||
inBreakCut = true;
|
inBreakCut = true;
|
||||||
hasLead = false;
|
hasLead = false;
|
||||||
previous = new CutGroup();
|
previous = new CutGroup();
|
||||||
previous.Leads.AddRange(leadIns);
|
previous.Leads.AddRange(leadIns);
|
||||||
leadIns.Clear();
|
leadIns.Clear();
|
||||||
otherOpen.Add(previous);
|
if (move.Layer == LayerType.Break)
|
||||||
|
otherOpen.Add(previous);
|
||||||
}
|
}
|
||||||
if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||||
previous.Curves.Add(move.Curve);
|
previous.Curves.Add(move.Curve);
|
||||||
@@ -148,8 +187,15 @@ public sealed class ReleasedContourState
|
|||||||
loose.Leads.AddRange(leadIns);
|
loose.Leads.AddRange(leadIns);
|
||||||
leadIns.Clear();
|
leadIns.Clear();
|
||||||
previous = null;
|
previous = null;
|
||||||
|
var leaving = exited;
|
||||||
|
exited = null;
|
||||||
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
|
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
|
||||||
continue;
|
continue;
|
||||||
|
// A raised rapid out of a slug its cutout's lead-out ended in is the designed way
|
||||||
|
// out: it may cross that cutout once, and passes over the rest with the head up.
|
||||||
|
if (move.HeadRaised && leaving != null && start.DistanceTo(exitPoint) <= PostVerificationGeometry.Epsilon
|
||||||
|
&& LeavesOnce(leaving, start, move.End, token))
|
||||||
|
continue;
|
||||||
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
|
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
|
||||||
System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X),
|
System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X),
|
||||||
System.Math.Max(start.Y, move.End.Y));
|
System.Math.Max(start.Y, move.End.Y));
|
||||||
@@ -166,16 +212,24 @@ 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 "
|
||||||
{
|
+ (obstacle.TabMayNotHold
|
||||||
Location = (start + move.End) * 0.5,
|
? $"the tabbed outside of part {obstacle.Part}, which its tab may not hold level."
|
||||||
RapidStart = start,
|
: $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}."))
|
||||||
RapidEnd = move.End,
|
{
|
||||||
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
Location = (start + move.End) * 0.5,
|
||||||
}, owned);
|
RapidStart = start,
|
||||||
|
RapidEnd = move.End,
|
||||||
|
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
||||||
|
};
|
||||||
|
// The head clears a cut part on a raised rapid; the crossing is the raise's reason.
|
||||||
|
if (move.HeadRaised)
|
||||||
|
raisedFindings?.Add(crossing);
|
||||||
|
else
|
||||||
|
Add(crossing, owned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (move.Layer == LayerType.Leadin)
|
else if (move.Layer == LayerType.Leadin)
|
||||||
@@ -196,6 +250,11 @@ public sealed class ReleasedContourState
|
|||||||
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
|
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
|
||||||
"Rapid safety for that contour requires manual review."), owned);
|
"Rapid safety for that contour requires manual review."), owned);
|
||||||
(previous ?? loose).Leads.Add(move.Curve);
|
(previous ?? loose).Leads.Add(move.Curve);
|
||||||
|
if (previous != null && ReferenceEquals(previous, lastClosed))
|
||||||
|
{
|
||||||
|
exited = lastObstacle;
|
||||||
|
exitPoint = move.Curve.End;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Finish();
|
Finish();
|
||||||
hasLead = false;
|
hasLead = false;
|
||||||
@@ -251,14 +310,16 @@ public sealed class ReleasedContourState
|
|||||||
Finish();
|
Finish();
|
||||||
loose.Leads.AddRange(leadIns);
|
loose.Leads.AddRange(leadIns);
|
||||||
// Open cuts that provably enclose nothing retain all their material, however many
|
// Open cuts that provably enclose nothing retain all their material, however many
|
||||||
// pieces a tabbed cutout was cut in. Otherwise they need manual review. Planning proves
|
// pieces a tabbed cutout was cut in, and so does a tabbed perimeter whose lead-out stays
|
||||||
// them too (its own tabbed cutouts are cut in pieces without lead-outs), but only
|
// out of its tab: planning and pre-post review prove them alike. Otherwise they need
|
||||||
// pre-post review lets the proof clear a lead-out: planning keeps refusing to certify
|
// manual review.
|
||||||
// its own tabs followed by a lead-out.
|
// A common-line member's open cuts are its remainder pieces: they end in shared kerf, not at
|
||||||
|
// tabs, and the planner's contour accounting proves they cut its outside exactly once.
|
||||||
|
var remainders = member && !cutoff && unfinished.Count > 0;
|
||||||
var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0)
|
var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0)
|
||||||
&& unfinished.All(LeadsClearOpenEnds)
|
&& (remainders || unfinished.All(LeadsClearOpenEnds)
|
||||||
&& OpenCutsEncloseNothing(closed, unfinished.Concat(otherOpen).Append(loose).ToList(), token);
|
&& OpenCutsEncloseNothing(closed, unfinished.Concat(otherOpen).Append(loose).ToList(), token));
|
||||||
for (var i = deferred.Count - 1; i >= 0 && !(retained && proveOpenCuts); i--)
|
for (var i = deferred.Count - 1; i >= 0 && !retained; i--)
|
||||||
deferred[i].Target.Insert(deferred[i].Index, deferred[i].Finding);
|
deferred[i].Target.Insert(deferred[i].Index, deferred[i].Finding);
|
||||||
// Only one open fragment per part can be the planner's own certified tab, so a
|
// Only one open fragment per part can be the planner's own certified tab, so a
|
||||||
// further fragment that a fixed emission cut makes this uncertainty that emission's.
|
// further fragment that a fixed emission cut makes this uncertainty that emission's.
|
||||||
@@ -267,6 +328,31 @@ public sealed class ReleasedContourState
|
|||||||
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
|
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
|
||||||
"they cannot be assumed to be retained by tabs. Review rapid travel manually."),
|
"they cannot be assumed to be retained by tabs. Review rapid travel manually."),
|
||||||
unfinished.Any(chain => chain.Fixed));
|
unfinished.Any(chain => chain.Fixed));
|
||||||
|
// A part that may tip once cut is avoided like one cut free. The retention proof above is
|
||||||
|
// unchanged: the part is still attached by its tab, just not held level by it.
|
||||||
|
if (remainders)
|
||||||
|
{
|
||||||
|
// Its remainder pieces in cutting order, joined across the shared spans between them:
|
||||||
|
// the member's outside, now cut free.
|
||||||
|
var curves = new List<PostVerificationGeometry.Curve>();
|
||||||
|
foreach (var chain in unfinished)
|
||||||
|
{
|
||||||
|
if (curves.Count > 0 && curves[^1].End.DistanceTo(chain.Curves[0].Start) > PostVerificationGeometry.Epsilon)
|
||||||
|
curves.Add(PostVerificationGeometry.Curve.Create(curves[^1].End, chain.Curves[0].Start, null, false));
|
||||||
|
curves.AddRange(chain.Curves);
|
||||||
|
}
|
||||||
|
if (curves[^1].End.DistanceTo(curves[0].Start) > PostVerificationGeometry.Epsilon)
|
||||||
|
curves.Add(PostVerificationGeometry.Curve.Create(curves[^1].End, curves[0].Start, null, false));
|
||||||
|
obstacles.Add(new(part, contourNumber, curves, null));
|
||||||
|
}
|
||||||
|
else if (!cutoff && unfinished.Count > 0 && tabMayNotHold?.Invoke(part) == true)
|
||||||
|
{
|
||||||
|
var perimeter = unfinished.MaxBy(chain => chain.Curves.Sum(curve => curve.Length));
|
||||||
|
var curves = perimeter.Curves.ToList();
|
||||||
|
if (curves[^1].End.DistanceTo(curves[0].Start) > PostVerificationGeometry.Epsilon)
|
||||||
|
curves.Add(PostVerificationGeometry.Curve.Create(curves[^1].End, curves[0].Start, null, false));
|
||||||
|
obstacles.Add(new(part, contourNumber, curves, null) { TabMayNotHold = true });
|
||||||
|
}
|
||||||
partContours = contourNumber;
|
partContours = contourNumber;
|
||||||
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
|
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
|
||||||
&& !inBreakCut && loose.Leads.Count == 0;
|
&& !inBreakCut && loose.Leads.Count == 0;
|
||||||
@@ -294,8 +380,10 @@ public sealed class ReleasedContourState
|
|||||||
// no flag or tab configuration is used as evidence of retention.
|
// no flag or tab configuration is used as evidence of retention.
|
||||||
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
|
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
|
||||||
{
|
{
|
||||||
obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd));
|
lastObstacle = new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd);
|
||||||
|
obstacles.Add(lastObstacle);
|
||||||
closed.Add(current);
|
closed.Add(current);
|
||||||
|
lastClosed = current;
|
||||||
}
|
}
|
||||||
else if (!cutoff)
|
else if (!cutoff)
|
||||||
unfinished.Add(current);
|
unfinished.Add(current);
|
||||||
@@ -303,6 +391,16 @@ public sealed class ReleasedContourState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the rapid from <paramref name="start"/> inside <paramref name="cutout"/> to
|
||||||
|
/// <paramref name="end"/> outside it meets its cut just once, on the way out: a rapid that
|
||||||
|
/// leaves a re-entrant cutout and passes back over it crosses the cut where the slug may lie.
|
||||||
|
/// </summary>
|
||||||
|
private static bool LeavesOnce(Obstacle cutout, Vector start, Vector end, CancellationToken token) =>
|
||||||
|
PostVerificationGeometry.Inside(start, cutout.Curves, token)
|
||||||
|
&& !PostVerificationGeometry.Inside(end, cutout.Curves, token)
|
||||||
|
&& PostVerificationGeometry.ContactPoints(start, end, cutout.Curves, token).Count == 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The closing piece of a contour cut on past its own start along its first cut, as a source
|
/// The closing piece of a contour cut on past its own start along its first cut, as a source
|
||||||
/// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
|
/// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
|
||||||
@@ -333,7 +431,7 @@ public sealed class ReleasedContourState
|
|||||||
/// contour with its own leads already encloses whatever it releases and counts as one node.
|
/// contour with its own leads already encloses whatever it releases and counts as one node.
|
||||||
/// Open cuts may touch any cut only at their own ends, and must form no loop through those
|
/// Open cuts may touch any cut only at their own ends, and must form no loop through those
|
||||||
/// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and
|
/// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and
|
||||||
/// tab strength are not modelled, exactly as for a single open contour.
|
/// tab strength are not modeled, exactly as for a single open contour.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const double JointTolerance = 1e-5;
|
private const double JointTolerance = 1e-5;
|
||||||
|
|
||||||
@@ -493,5 +591,8 @@ public sealed class ReleasedContourState
|
|||||||
{
|
{
|
||||||
internal PostVerificationGeometry.Extent Extent { get; } =
|
internal PostVerificationGeometry.Extent Extent { get; } =
|
||||||
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
|
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
|
||||||
|
|
||||||
|
/// <summary>A tabbed perimeter closed across its tab gap, for a part its tab may not hold level.</summary>
|
||||||
|
internal bool TabMayNotHold { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,11 +160,11 @@ internal static class SeamOvercut
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The angle travelled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary>
|
/// <summary>The angle traveled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary>
|
||||||
private static double Travel(Vector center, Vector from, Vector to, bool clockwise)
|
private static double Travel(Vector center, Vector from, Vector to, bool clockwise)
|
||||||
{
|
{
|
||||||
var start = System.Math.Atan2(from.Y - center.Y, from.X - center.X);
|
var start = PortableMath.Atan2(from.Y - center.Y, from.X - center.X);
|
||||||
var end = System.Math.Atan2(to.Y - center.Y, to.X - center.X);
|
var end = PortableMath.Atan2(to.Y - center.Y, to.X - center.X);
|
||||||
var travel = (clockwise ? start - end : end - start) % (2 * System.Math.PI);
|
var travel = (clockwise ? start - end : end - start) % (2 * System.Math.PI);
|
||||||
return travel < 0 ? travel + 2 * System.Math.PI : travel;
|
return travel < 0 ? travel + 2 * System.Math.PI : travel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ internal static class StateFingerprint
|
|||||||
TabSettings(p.TabConfig);
|
TabSettings(p.TabConfig);
|
||||||
Flag(p.TabsEnabled);
|
Flag(p.TabsEnabled);
|
||||||
Flag(p.SupportTabs);
|
Flag(p.SupportTabs);
|
||||||
Number(p.NoBedTabMinWidth);
|
Flag(p.TabBySize);
|
||||||
Number(p.NoBedTabMaxWidth);
|
Number(p.TabSizeMinWidth);
|
||||||
|
Number(p.TabSizeMaxWidth);
|
||||||
Number(p.TabHeldMaxLength);
|
Number(p.TabHeldMaxLength);
|
||||||
Sequence(p.Sequencing);
|
Sequence(p.Sequencing);
|
||||||
Assignment(p.Assignment);
|
Assignment(p.Assignment);
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
public CuttingParameters Parameters { get; set; }
|
public CuttingParameters Parameters { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remembers pierce-zone resolutions by contour ordinal across the emissions of one
|
||||||
|
/// preparation (see <see cref="PierceZoneMemo"/>); null resolves every emission afresh.
|
||||||
|
/// </summary>
|
||||||
|
internal PierceZoneMemo PierceZones { get; set; }
|
||||||
|
|
||||||
private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
|
private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
|
||||||
|
|
||||||
public CuttingResult Apply(Program partProgram, Vector approachPoint)
|
public CuttingResult Apply(Program partProgram, Vector approachPoint)
|
||||||
@@ -261,14 +267,16 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
|
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
|
||||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
|
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
|
||||||
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
|
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
|
||||||
IReadOnlyList<Shape> tabbedCutouts = null, int? perimeterOrdinal = null, Shape displayTail = null)
|
IReadOnlyList<Shape> tabbedCutouts = null, int? perimeterOrdinal = null, Shape displayTail = null,
|
||||||
|
CuttingPlanning.CommonLinePerimeter commonLine = null)
|
||||||
{
|
{
|
||||||
var result = new Program(Mode.Absolute);
|
var result = new Program(Mode.Absolute);
|
||||||
EmitScribeContours(result, scribes);
|
EmitScribeContours(result, scribes);
|
||||||
EmitBreakCuts(result, breakCuts, shapes);
|
EmitBreakCuts(result, breakCuts, shapes);
|
||||||
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
|
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
|
||||||
EmitTabbedCutout(result, cutout);
|
EmitTabbedCutout(result, cutout);
|
||||||
EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
|
EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1,
|
||||||
|
commonLine);
|
||||||
if (displayTail != null)
|
if (displayTail != null)
|
||||||
{
|
{
|
||||||
// Display only, after every cut: executed reads and posts stop at its marker.
|
// Display only, after every cut: executed reads and posts stop at its marker.
|
||||||
@@ -286,11 +294,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
// installed on a Part or accepted as a complete plan.
|
// installed on a Part or accepted as a complete plan.
|
||||||
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
|
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
|
||||||
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null,
|
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null,
|
||||||
int? perimeterOrdinal = null)
|
int? perimeterOrdinal = null, CuttingPlanning.CommonLinePerimeter commonLine = null)
|
||||||
{
|
{
|
||||||
var result = new Program(Mode.Absolute);
|
var result = new Program(Mode.Absolute);
|
||||||
EmitScribeContours(result, scribes);
|
EmitScribeContours(result, scribes);
|
||||||
EmitChosenContours(result, shapes, new[] { choice }, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
|
EmitChosenContours(result, shapes, new[] { choice }, contourParameters, perimeterOrdinal ?? shapes.Length - 1,
|
||||||
|
commonLine);
|
||||||
result.Mode = Mode.Incremental;
|
result.Mode = Mode.Incremental;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -301,20 +310,61 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
|
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
|
||||||
private void EmitChosenContours(Program result, Shape[] shapes,
|
private void EmitChosenContours(Program result, Shape[] shapes,
|
||||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters,
|
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters,
|
||||||
int perimeterOrdinal)
|
int perimeterOrdinal, CuttingPlanning.CommonLinePerimeter commonLine = null)
|
||||||
{
|
{
|
||||||
var shared = Parameters;
|
var shared = Parameters;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var inSlug = false;
|
||||||
foreach (var choice in choices)
|
foreach (var choice in choices)
|
||||||
{
|
{
|
||||||
|
if (commonLine is { StartsOnCommonLine: true } && choice.ContourOrdinal == perimeterOrdinal
|
||||||
|
&& !IsTabbedCutout(shapes[perimeterOrdinal]))
|
||||||
|
{
|
||||||
|
// Cut whole from its common line: the straight lead-in from the choice's
|
||||||
|
// pierce runs along the shared edge, and the perimeter ends where it began,
|
||||||
|
// beside its partner, so it has no lead-out and no tab.
|
||||||
|
var whole = CuttingPlanning.OwnedCuttingParameters.Copy(shared);
|
||||||
|
whole.ExternalLeadOut = new NoLeadOut();
|
||||||
|
whole.TabsEnabled = false;
|
||||||
|
Parameters = whole;
|
||||||
|
var start = result.Codes.Count;
|
||||||
|
EmitContour(result, shapes[perimeterOrdinal], choice.Point,
|
||||||
|
shapes[perimeterOrdinal].Entities[choice.EntityOrdinal], ContourType.External,
|
||||||
|
exactCirclePrograms: true, scrapPierce: choice.Pierce, stopAtCenterLine: false,
|
||||||
|
memoContour: choice.ContourOrdinal);
|
||||||
|
if (inSlug && start < result.Codes.Count && result.Codes[start] is RapidMove)
|
||||||
|
result.Codes.Insert(start, new Comment(HeadRaise.Marker));
|
||||||
|
inSlug = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (commonLine != null && choice.ContourOrdinal == perimeterOrdinal)
|
||||||
|
{
|
||||||
|
// The rapid out of a cutout's slug before it is still made head up.
|
||||||
|
Parameters = shared;
|
||||||
|
var first = result.Codes.Count;
|
||||||
|
EmitCommonLinePerimeter(result, shapes[perimeterOrdinal], commonLine);
|
||||||
|
if (inSlug && first < result.Codes.Count && result.Codes[first] is RapidMove)
|
||||||
|
result.Codes.Insert(first, new Comment(HeadRaise.Marker));
|
||||||
|
inSlug = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// A contour's own settings apply to its emission alone; scribes and every
|
// A contour's own settings apply to its emission alone; scribes and every
|
||||||
// other contour keep the shared settings.
|
// other contour keep the shared settings.
|
||||||
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
|
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
|
||||||
var shape = shapes[choice.ContourOrdinal];
|
var shape = shapes[choice.ContourOrdinal];
|
||||||
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
var arrival = result.Codes.Count;
|
||||||
|
var leftInSlug = EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||||
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
|
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
|
||||||
exactCirclePrograms: true, scrapPierce: choice.Pierce);
|
exactCirclePrograms: true, scrapPierce: choice.Pierce,
|
||||||
|
stopAtCenterLine: contourParameters?[choice.ContourOrdinal] == null,
|
||||||
|
memoContour: choice.ContourOrdinal);
|
||||||
|
// A cutout's lead-out ends in its slug, which may drop or tip once cut free, so
|
||||||
|
// the rapid out over the cut is made head up. An overcut or no lead-out leaves
|
||||||
|
// from the cut itself and stays head down.
|
||||||
|
if (inSlug && arrival < result.Codes.Count && result.Codes[arrival] is RapidMove or SubProgramCall)
|
||||||
|
result.Codes.Insert(arrival, new Comment(HeadRaise.Marker));
|
||||||
|
inSlug = leftInSlug;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -433,14 +483,17 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y);
|
private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y);
|
||||||
private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
|
private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
|
||||||
|
|
||||||
private void EmitContour(
|
/// <returns>Whether the contour is a cutout left by a lead-out, which ends in its slug.</returns>
|
||||||
|
private bool EmitContour(
|
||||||
Program program,
|
Program program,
|
||||||
Shape shape,
|
Shape shape,
|
||||||
Vector point,
|
Vector point,
|
||||||
Entity entity,
|
Entity entity,
|
||||||
ContourType? forceType = null,
|
ContourType? forceType = null,
|
||||||
bool exactCirclePrograms = false,
|
bool exactCirclePrograms = false,
|
||||||
Vector? scrapPierce = null
|
Vector? scrapPierce = null,
|
||||||
|
bool stopAtCenterLine = false,
|
||||||
|
int? memoContour = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var contourType = forceType ?? DetectContourType(shape);
|
var contourType = forceType ?? DetectContourType(shape);
|
||||||
@@ -463,8 +516,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
var outwardAngle = normal - System.Math.PI;
|
var outwardAngle = normal - System.Math.PI;
|
||||||
point = new Vector(
|
point = new Vector(
|
||||||
circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle),
|
circle.Center.X + circle.Radius * PortableMath.Cos(outwardAngle),
|
||||||
circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle)
|
circle.Center.Y + circle.Radius * PortableMath.Sin(outwardAngle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,9 +525,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
leadIn = StraightLeadIn(point, holePierce, out normal);
|
leadIn = StraightLeadIn(point, holePierce, out normal);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
if (stopAtCenterLine)
|
||||||
|
leadIn = StopAtCenterLine(shape, point, leadIn, normal, winding);
|
||||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
||||||
Parameters.GetEffectivePierceClearance());
|
Parameters.GetEffectivePierceClearance());
|
||||||
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius(), winding);
|
leadIn = PierceZone(memoContour, shape, point, leadIn, normal, winding);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build hole sub-program relative to (0,0)
|
// Build hole sub-program relative to (0,0)
|
||||||
@@ -492,7 +547,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
|
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
|
||||||
|
|
||||||
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
|
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
|
||||||
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding));
|
var holeLeadOut = leadOut.Generate(relativePoint, normal, winding);
|
||||||
|
subPgm.Codes.AddRange(holeLeadOut);
|
||||||
subPgm.Mode = Mode.Incremental;
|
subPgm.Mode = Mode.Incremental;
|
||||||
|
|
||||||
// Deduplicate: check if an identical sub-program already exists
|
// Deduplicate: check if an identical sub-program already exists
|
||||||
@@ -511,11 +567,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return holeLeadOut.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var turn = LeadTurn(contourType, winding);
|
var turn = LeadTurn(contourType, winding);
|
||||||
double leadInNormal;
|
double leadInNormal;
|
||||||
|
var configuredLeadIn = leadIn;
|
||||||
// A planner-placed scrap pierce replaces the configured lead-in with a straight one.
|
// A planner-placed scrap pierce replaces the configured lead-in with a straight one.
|
||||||
if (scrapPierce is { } pierce)
|
if (scrapPierce is { } pierce)
|
||||||
leadIn = StraightLeadIn(point, pierce, out leadInNormal);
|
leadIn = StraightLeadIn(point, pierce, out leadInNormal);
|
||||||
@@ -524,9 +581,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
||||||
Parameters.GetEffectivePierceClearance(), out leadInNormal);
|
Parameters.GetEffectivePierceClearance(), out leadInNormal);
|
||||||
if (contourType == ContourType.Internal)
|
if (contourType == ContourType.Internal)
|
||||||
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius(), turn);
|
{
|
||||||
|
if (stopAtCenterLine)
|
||||||
|
leadIn = StopAtCenterLine(shape, point, leadIn, leadInNormal, turn);
|
||||||
|
leadIn = PierceZone(memoContour, shape, point, leadIn, leadInNormal, turn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
|
|
||||||
|
|
||||||
var reindexedShape = shape.ReindexAt(point, entity);
|
var reindexedShape = shape.ReindexAt(point, entity);
|
||||||
|
|
||||||
@@ -539,6 +599,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var leadOutNormal = normal;
|
var leadOutNormal = normal;
|
||||||
if (tabbed)
|
if (tabbed)
|
||||||
{
|
{
|
||||||
|
var first = reindexedShape.Entities.Count > 0 ? reindexedShape.Entities[0] : null;
|
||||||
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
||||||
|
|
||||||
// Leave from where the trimmed cut actually ends, on that entity's normal:
|
// Leave from where the trimmed cut actually ends, on that entity's normal:
|
||||||
@@ -548,6 +609,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var last = reindexedShape.Entities[^1];
|
var last = reindexedShape.Entities[^1];
|
||||||
leadOutPoint = EntityEndPoint(last);
|
leadOutPoint = EntityEndPoint(last);
|
||||||
leadOutNormal = ComputeNormal(leadOutPoint, last, contourType, winding);
|
leadOutNormal = ComputeNormal(leadOutPoint, last, contourType, winding);
|
||||||
|
|
||||||
|
// Straight leads either side of a tab that wraps a corner share its bisector,
|
||||||
|
// as they share the edge normal on a straight edge.
|
||||||
|
if (ReferenceEquals(leadIn, configuredLeadIn) && leadIn is LineLeadIn lineIn
|
||||||
|
&& leadOut is LineLeadOut lineOut && first is Line or Arc
|
||||||
|
&& TabCornerNormal(shape, point, TravelTangent(first, point), leadInNormal, lineIn,
|
||||||
|
leadOutPoint, TravelTangent(last, leadOutPoint), leadOutNormal, lineOut, winding,
|
||||||
|
turn, Parameters.GetEffectivePierceClearance()) is { } tabNormal)
|
||||||
|
{
|
||||||
|
leadInNormal = tabNormal;
|
||||||
|
leadOutNormal = tabNormal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -556,8 +629,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
Parameters.GetEffectivePierceClearance(), out leadOutNormal);
|
Parameters.GetEffectivePierceClearance(), out leadOutNormal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
|
||||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||||
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, turn));
|
var leadOutCodes = leadOut.Generate(leadOutPoint, leadOutNormal, turn);
|
||||||
|
program.Codes.AddRange(leadOutCodes);
|
||||||
|
return contourType != ContourType.External && leadOutCodes.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -867,10 +943,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)
|
||||||
@@ -890,13 +974,53 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||||
// Sum unit normals rather than averaging angles (which fails at 0/2Ï€).
|
// Sum unit normals rather than averaging angles (which fails at 0/2Ï€).
|
||||||
// Winding makes this point into the scrap even at reflex corners.
|
// Winding makes this point into the scrap even at reflex corners.
|
||||||
var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal);
|
var x = PortableMath.Cos(normal) + PortableMath.Cos(adjacentNormal);
|
||||||
var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal);
|
var y = PortableMath.Sin(normal) + PortableMath.Sin(adjacentNormal);
|
||||||
if (!double.IsFinite(x) || !double.IsFinite(y)
|
if (!double.IsFinite(x) || !double.IsFinite(y)
|
||||||
|| x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
|
|| x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
|
||||||
return null; // Opposing normals at a cusp have no unique bisector.
|
return null; // Opposing normals at a cusp have no unique bisector.
|
||||||
|
|
||||||
return Angle.NormalizeRad(System.Math.Atan2(y, x));
|
return Angle.NormalizeRad(PortableMath.Atan2(y, x));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The normal straight leads either side of a perimeter tab share when the tab wraps a
|
||||||
|
/// convex corner: the bisector of the normals where the cut starts and where it stops, so
|
||||||
|
/// the leads form the V they form on a straight edge, centered on the corner. Null when the
|
||||||
|
/// tab lies on one edge or wraps a reflex turn, or when either lead would not keep
|
||||||
|
/// <paramref name="clearance"/> from the part or would cross it.
|
||||||
|
/// </summary>
|
||||||
|
private static double? TabCornerNormal(
|
||||||
|
Shape shape,
|
||||||
|
Vector start,
|
||||||
|
Vector tangentStart,
|
||||||
|
double startNormal,
|
||||||
|
LineLeadIn leadIn,
|
||||||
|
Vector end,
|
||||||
|
Vector tangentEnd,
|
||||||
|
double endNormal,
|
||||||
|
LineLeadOut leadOut,
|
||||||
|
RotationType winding,
|
||||||
|
RotationType turn,
|
||||||
|
double clearance
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (!IsFinite(tangentStart) || !IsFinite(tangentEnd)
|
||||||
|
|| ClassifyTurn(tangentEnd, tangentStart, winding) != CornerKind.Convex)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var x = PortableMath.Cos(startNormal) + PortableMath.Cos(endNormal);
|
||||||
|
var y = PortableMath.Sin(startNormal) + PortableMath.Sin(endNormal);
|
||||||
|
if (x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var normal = Angle.NormalizeRad(PortableMath.Atan2(y, x));
|
||||||
|
var pierce = leadIn.GetPiercePoint(start, normal, turn);
|
||||||
|
var leadOutEnd = ((LinearMove)leadOut.Generate(end, normal, turn)[0]).EndPoint;
|
||||||
|
return IsClearStraightLead(shape, start, pierce, clearance)
|
||||||
|
&& IsClearStraightLead(shape, end, leadOutEnd, clearance)
|
||||||
|
? normal
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1050,16 +1174,28 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Keeps a hole's lead-in pierce <paramref name="pierceRadius"/> clear of the hole wall.
|
/// Keeps a hole's lead-in pierce <paramref name="pierceRadius"/> clear of the hole wall.
|
||||||
/// A lead-in whose pierce already keeps it, or that pierces on the contour itself, is
|
/// A lead-in whose pierce already keeps it, or that pierces on the contour itself, is
|
||||||
/// returned as given. Otherwise the pierce moves onto the line from
|
/// returned as given. Otherwise the pierce moves straight in from the wall to the hole's
|
||||||
/// <paramref name="point"/> toward the hole's pole of inaccessibility (the centre of a
|
/// center line, or the lead's own length in when that comes first (see
|
||||||
/// round hole), as close to the contour as the zone allows, and the lead becomes a
|
/// <see cref="StopAtCenterLine"/>). A lead that cannot keep the zone there moves onto the
|
||||||
/// straight line from there, generated with <paramref name="normal"/>. A hole too small
|
/// line from <paramref name="point"/> toward the nearest point that keeps it (see
|
||||||
|
/// <see cref="ErodedPierce"/>) or else the hole's pole of inaccessibility (the center of a
|
||||||
|
/// round hole), as close to the contour as the zone allows. Either way the lead becomes
|
||||||
|
/// a straight line from there, generated with <paramref name="normal"/>. A hole too small
|
||||||
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and
|
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and
|
||||||
/// pre-post review still report the shortfall. Reads only the hole's own geometry, so it
|
/// pre-post review still report the shortfall. Reads only the hole's own geometry, so it
|
||||||
/// knows nothing of parts nested inside the hole. Shared by program generation and the
|
/// knows nothing of parts nested inside the hole. Shared by program generation and the
|
||||||
/// manual placement preview. <paramref name="turn"/> is the hole's
|
/// manual placement preview. <paramref name="turn"/> is the hole's
|
||||||
/// <see cref="LeadTurn"/>, which places a curved lead's pierce.
|
/// <see cref="LeadTurn"/>, which places a curved lead's pierce.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
// The memoized resolution for a contour the preparation emits by ordinal.
|
||||||
|
private LeadIn PierceZone(int? contour, Shape hole, Vector point, LeadIn leadIn, double normal, RotationType turn)
|
||||||
|
{
|
||||||
|
var radius = Parameters.GetPierceZoneRadius();
|
||||||
|
return PierceZones != null && contour is { } ordinal
|
||||||
|
? PierceZones.Resolve(ordinal, hole, point, leadIn, normal, radius, turn)
|
||||||
|
: ResolvePierceZone(hole, point, leadIn, normal, radius, turn);
|
||||||
|
}
|
||||||
|
|
||||||
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal,
|
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal,
|
||||||
double pierceRadius, RotationType turn)
|
double pierceRadius, RotationType turn)
|
||||||
{
|
{
|
||||||
@@ -1071,35 +1207,48 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var polygon = circle == null
|
var polygon = circle == null
|
||||||
? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance))
|
? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance))
|
||||||
: null;
|
: null;
|
||||||
if (Clear(leadIn.GetPiercePoint(point, normal, turn)))
|
var configured = leadIn.GetPiercePoint(point, normal, turn);
|
||||||
|
if (Clear(configured))
|
||||||
return leadIn;
|
return leadIn;
|
||||||
|
|
||||||
|
var depth = CenterLineDepth(hole, point, normal, configured.DistanceTo(point));
|
||||||
|
if (depth > Math.Tolerance.Epsilon && Clear(point + Inward(normal) * depth))
|
||||||
|
return new LineLeadIn { Length = depth, ApproachAngle = 90 };
|
||||||
|
|
||||||
var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
|
var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
|
||||||
System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells);
|
System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells);
|
||||||
if (!Inside(pole))
|
if (!Inside(pole))
|
||||||
return leadIn;
|
return leadIn;
|
||||||
|
|
||||||
// Clearance grows monotonically toward the pole of a convex hole, so the bisection
|
// The pole of a letter can be a stroke away: head for the stroke beside the entry, on
|
||||||
|
// its center line when the lead reaches that far.
|
||||||
|
var reach = configured.DistanceTo(point);
|
||||||
|
var toward = pole;
|
||||||
|
if (circle == null && ErodedPierce.Find(hole, System.Math.Max(pierceRadius, reach), new[] { point }, 1)
|
||||||
|
.Pierces is [var eroded] && Clear(eroded))
|
||||||
|
toward = eroded;
|
||||||
|
|
||||||
|
// Clearance grows monotonically along this line in a convex hole, so the bisection
|
||||||
// finds the shortest clear lead; in other holes it still ends on a clear point.
|
// finds the shortest clear lead; in other holes it still ends on a clear point.
|
||||||
var pierce = pole;
|
var pierce = toward;
|
||||||
if (Clear(pole))
|
if (Clear(toward) && (reach < pierceRadius || toward == pole))
|
||||||
{
|
{
|
||||||
double low = 0, high = 1;
|
double low = 0, high = 1;
|
||||||
for (var i = 0; i < 40; i++)
|
for (var i = 0; i < 40; i++)
|
||||||
{
|
{
|
||||||
var middle = (low + high) / 2;
|
var middle = (low + high) / 2;
|
||||||
if (Clear(point + (pole - point) * middle))
|
if (Clear(point + (toward - point) * middle))
|
||||||
high = middle;
|
high = middle;
|
||||||
else
|
else
|
||||||
low = middle;
|
low = middle;
|
||||||
}
|
}
|
||||||
pierce = point + (pole - point) * high;
|
pierce = point + (toward - point) * high;
|
||||||
}
|
}
|
||||||
|
|
||||||
var length = pierce.DistanceTo(point);
|
var length = pierce.DistanceTo(point);
|
||||||
if (length <= Math.Tolerance.Epsilon)
|
if (length <= Math.Tolerance.Epsilon)
|
||||||
return leadIn;
|
return leadIn;
|
||||||
var direction = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
var direction = PortableMath.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
||||||
return new LineLeadIn
|
return new LineLeadIn
|
||||||
{
|
{
|
||||||
Length = length,
|
Length = length,
|
||||||
@@ -1114,19 +1263,115 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
&& hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius;
|
&& hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps a cutout's lead-in out of the cutout's far half. A lead longer than the cutout is
|
||||||
|
/// deep at its entry, measured straight in from the wall to the center line where the walls
|
||||||
|
/// meet as the cutout erodes, reaches past the center line or runs along a narrow stroke, as
|
||||||
|
/// does one whose own line gets closer to a wall again. It becomes a straight one, generated
|
||||||
|
/// with <paramref name="normal"/>, from that center line (the center of a round hole) or
|
||||||
|
/// from the lead's own length in when that comes first. Any other lead keeps its shape, and
|
||||||
|
/// one that leaves a cutout other than a round hole is left for the lead check to refuse.
|
||||||
|
/// </summary>
|
||||||
|
public static LeadIn StopAtCenterLine(Shape hole, Vector point, LeadIn leadIn, double normal, RotationType turn)
|
||||||
|
{
|
||||||
|
if (leadIn is NoLeadIn || !hole.IsClosed())
|
||||||
|
return leadIn;
|
||||||
|
var pierce = leadIn.GetPiercePoint(point, normal, turn);
|
||||||
|
var length = pierce.DistanceTo(point);
|
||||||
|
if (!double.IsFinite(length) || length <= Math.Tolerance.Epsilon)
|
||||||
|
return leadIn;
|
||||||
|
|
||||||
|
var circle = hole.Entities.Count == 1 ? hole.Entities[0] as Circle : null;
|
||||||
|
var along = pierce - point;
|
||||||
|
var inward = Inward(normal);
|
||||||
|
// A pierce as far from every wall as it is deep has not come near another wall.
|
||||||
|
var inside = circle == null || pierce.DistanceTo(circle.Center) < circle.Radius;
|
||||||
|
if (inside && Clearance(hole, pierce) >= along.X * inward.X + along.Y * inward.Y - Math.Tolerance.Epsilon)
|
||||||
|
return leadIn;
|
||||||
|
|
||||||
|
var depth = CenterLineDepth(hole, point, normal, length);
|
||||||
|
if (depth <= Math.Tolerance.Epsilon
|
||||||
|
|| inside && depth >= length - Math.Tolerance.Epsilon && !PeaksBeforeItsEnd(hole, point, along))
|
||||||
|
return leadIn;
|
||||||
|
if (circle == null)
|
||||||
|
{
|
||||||
|
var polygon = hole.ToPolygonWithTolerance(Math.Tolerance.ChainTolerance);
|
||||||
|
for (var i = 1; i <= CenterLineSteps; i++)
|
||||||
|
if (!polygon.ContainsPoint(point + along * ((double)i / CenterLineSteps)))
|
||||||
|
return leadIn;
|
||||||
|
}
|
||||||
|
return new LineLeadIn { Length = depth, ApproachAngle = 90 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// How far a straight line in from the wall runs, up to maxDepth, before it reaches the
|
||||||
|
// center line: the distance to the wall grows at a steady rate until a second wall is as
|
||||||
|
// near, then levels off along a stroke or falls.
|
||||||
|
private static double CenterLineDepth(Shape hole, Vector point, double normal, double maxDepth)
|
||||||
|
{
|
||||||
|
if (hole.Entities.Count == 1 && hole.Entities[0] is Circle circle)
|
||||||
|
return System.Math.Min(maxDepth, circle.Radius);
|
||||||
|
|
||||||
|
var inward = Inward(normal);
|
||||||
|
var step = maxDepth / CenterLineSteps;
|
||||||
|
var rate = Clearance(hole, point + inward * step) / step;
|
||||||
|
// A corner's curved lead keeps an edge's normal, which runs along the other edge.
|
||||||
|
if (rate < MinimumCenterLineRate)
|
||||||
|
return maxDepth;
|
||||||
|
bool Rising(double depth) => Clearance(hole, point + inward * (depth + step / CenterLineSteps))
|
||||||
|
- Clearance(hole, point + inward * depth) >= rate / 2 * (step / CenterLineSteps);
|
||||||
|
for (var i = 1; i < CenterLineSteps; i++)
|
||||||
|
{
|
||||||
|
if (Rising(step * i))
|
||||||
|
continue;
|
||||||
|
double low = step * (i - 1), high = step * i;
|
||||||
|
for (var refine = 0; refine < CenterLineRefinements; refine++)
|
||||||
|
{
|
||||||
|
var middle = (low + high) / 2;
|
||||||
|
if (Rising(middle))
|
||||||
|
low = middle;
|
||||||
|
else
|
||||||
|
high = middle;
|
||||||
|
}
|
||||||
|
return low;
|
||||||
|
}
|
||||||
|
return maxDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PeaksBeforeItsEnd(Shape hole, Vector point, Vector along)
|
||||||
|
{
|
||||||
|
var peak = 0.0;
|
||||||
|
for (var i = 1; i <= CenterLineSteps; i++)
|
||||||
|
{
|
||||||
|
var clearance = Clearance(hole, point + along * ((double)i / CenterLineSteps));
|
||||||
|
if (clearance < peak - Math.Tolerance.Epsilon)
|
||||||
|
return true;
|
||||||
|
peak = System.Math.Max(peak, clearance);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Clearance(Shape hole, Vector point) => hole.ClosestPointTo(point, out _).DistanceTo(point);
|
||||||
|
|
||||||
|
private static Vector Inward(double normal) => new(PortableMath.Cos(normal), PortableMath.Sin(normal));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the
|
/// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the
|
||||||
/// normal to generate it with.
|
/// normal to generate it with.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static LeadIn StraightLeadIn(Vector point, Vector pierce, out double normal)
|
private static LeadIn StraightLeadIn(Vector point, Vector pierce, out double normal)
|
||||||
{
|
{
|
||||||
normal = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
normal = PortableMath.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
||||||
return new LineLeadIn { Length = pierce.DistanceTo(point), ApproachAngle = 90 };
|
return new LineLeadIn { Length = pierce.DistanceTo(point), ApproachAngle = 90 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bounds the pole search on long, thin holes; the result is checked before use.
|
// Bounds the pole search on long, thin holes; the result is checked before use.
|
||||||
private const int PoleSearchCells = 4096;
|
private const int PoleSearchCells = 4096;
|
||||||
|
|
||||||
|
// Samples along a lead when looking for the center line, then the steps narrowing in on it.
|
||||||
|
private const int CenterLineSteps = 64;
|
||||||
|
private const int CenterLineRefinements = 16;
|
||||||
|
private const double MinimumCenterLineRate = 0.01;
|
||||||
|
|
||||||
private LeadIn SelectLeadIn(ContourType contourType)
|
private LeadIn SelectLeadIn(ContourType contourType)
|
||||||
{
|
{
|
||||||
return contourType switch
|
return contourType switch
|
||||||
@@ -1147,6 +1392,22 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize)
|
private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize)
|
||||||
{
|
{
|
||||||
|
// A round perimeter is one circle entered at `center`: cut it as an arc from the entry
|
||||||
|
// that stops the tab's chord short of it, travelling the circle's own way.
|
||||||
|
if (shape.Entities is [Circle round] && round.Radius > tabSize / 2)
|
||||||
|
{
|
||||||
|
var entry = round.Center.AngleTo(center);
|
||||||
|
var gap = 2 * PortableMath.Asin(tabSize / (2 * round.Radius));
|
||||||
|
var reversed = round.Rotation == RotationType.CW;
|
||||||
|
var arc = new Arc(round.Center, round.Radius, entry, reversed ? entry + gap : entry - gap, reversed)
|
||||||
|
{
|
||||||
|
Layer = round.Layer,
|
||||||
|
};
|
||||||
|
var trimmed = new Shape();
|
||||||
|
trimmed.Entities.Add(arc);
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
var tabCircle = new Circle(center, tabSize);
|
var tabCircle = new Circle(center, tabSize);
|
||||||
var entities = new List<Entity>(shape.Entities);
|
var entities = new List<Entity>(shape.Entities);
|
||||||
|
|
||||||
@@ -1316,14 +1577,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
path = (Shape)cut.Clone();
|
path = (Shape)cut.Clone();
|
||||||
path.Reverse();
|
path.Reverse();
|
||||||
}
|
}
|
||||||
var normal = Angle.NormalizeRad(System.Math.Atan2(scrap.Y, scrap.X));
|
var normal = Angle.NormalizeRad(PortableMath.Atan2(scrap.Y, scrap.X));
|
||||||
program.Codes.AddRange(leadIn.Generate(entry, normal, fromEnd ? RotationType.CW : RotationType.CCW));
|
program.Codes.AddRange(leadIn.Generate(entry, normal, fromEnd ? RotationType.CW : RotationType.CCW));
|
||||||
program.Codes.AddRange(ConvertShapeToMoves(path, entry, LayerType.Break));
|
program.Codes.AddRange(ConvertShapeToMoves(path, entry, LayerType.Break));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Where a break cut (travelling with its scrap on the left) is entered: the end with more
|
/// Where a break cut (traveling with its scrap on the left) is entered: the end with more
|
||||||
/// scrap before <paramref name="contours"/>, the unit direction into the scrap there, and
|
/// scrap before <paramref name="contours"/>, the unit direction into the scrap there, and
|
||||||
/// whether that is the cut's far end.
|
/// whether that is the cut's far end.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1339,6 +1600,78 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
: (start, startScrap, false);
|
: (start, startScrap, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A common-line member's outside around the shared spans earlier members cut (on the Tab
|
||||||
|
/// layer; docs/common-line-cutting.md), each piece in the perimeter's own direction. With a
|
||||||
|
/// common-line entry, the first piece starts there, pierced in scrap with a straight lead-in
|
||||||
|
/// along the shared edge, and runs to the next span; the stretch before the entry is cut
|
||||||
|
/// last and ends at it. Every other piece starts where a shared span ends. It is pierced in that span's opened
|
||||||
|
/// kerf on the centerline, the external lead-in's reach back along the span, and enters along
|
||||||
|
/// a straight lead-in that ends at the piece's start, so compensation starts in the kerf. It
|
||||||
|
/// has no lead-out: it ends in the kerf at the next shared span. Capture checks that every
|
||||||
|
/// lead fits; emission never shortens one.
|
||||||
|
/// </summary>
|
||||||
|
private void EmitCommonLinePerimeter(Program program, Shape perimeter, CuttingPlanning.CommonLinePerimeter commonLine)
|
||||||
|
{
|
||||||
|
var winding = DetermineWinding(perimeter);
|
||||||
|
var leadIn = Parameters?.ExternalLeadIn ?? new NoLeadIn();
|
||||||
|
var entities = perimeter.Entities;
|
||||||
|
var pieces = TabbedCutoutPieces(perimeter);
|
||||||
|
if (commonLine.Entry is { } start && commonLine.Pierce is { } pierce)
|
||||||
|
{
|
||||||
|
// The piece holding the entry is cut from it to its end first, then the pieces after
|
||||||
|
// it in travel order, and its stretch before the entry last.
|
||||||
|
var at = pieces.FindIndex(p => p.Entities.Exists(e => EntityStartPoint(e).DistanceTo(start) <= Tolerance.Epsilon));
|
||||||
|
var piece = pieces[at];
|
||||||
|
var split = piece.Entities.FindIndex(e => EntityStartPoint(e).DistanceTo(start) <= Tolerance.Epsilon);
|
||||||
|
var head = new Shape();
|
||||||
|
head.Entities.AddRange(piece.Entities.Skip(split));
|
||||||
|
program.Codes.Add(new RapidMove(pierce));
|
||||||
|
program.Codes.Add(new Comment(CommonLineCuts.RemainderMarker(winding)));
|
||||||
|
program.Codes.Add(new LinearMove(start) { Layer = LayerType.Leadin });
|
||||||
|
program.Codes.AddRange(ConvertShapeToMoves(head, start));
|
||||||
|
var rest = pieces.Skip(at + 1).Concat(pieces.Take(at)).ToList();
|
||||||
|
if (split > 0)
|
||||||
|
{
|
||||||
|
var tail = new Shape();
|
||||||
|
tail.Entities.AddRange(piece.Entities.Take(split));
|
||||||
|
rest.Add(tail);
|
||||||
|
}
|
||||||
|
pieces = rest;
|
||||||
|
}
|
||||||
|
foreach (var piece in pieces)
|
||||||
|
{
|
||||||
|
var first = piece.Entities[0];
|
||||||
|
var index = entities.IndexOf(first);
|
||||||
|
var span = (Line)entities[(index + entities.Count - 1) % entities.Count];
|
||||||
|
var entry = EntityStartPoint(first);
|
||||||
|
program.Codes.Add(new RapidMove(CommonLineRemainderPierce(span, winding, commonLine.Kerf,
|
||||||
|
CommonLineLeadInReach(leadIn))));
|
||||||
|
// Posts compensate a remainder on its perimeter's side: it encloses nothing itself.
|
||||||
|
program.Codes.Add(new Comment(CommonLineCuts.RemainderMarker(winding)));
|
||||||
|
program.Codes.Add(new LinearMove(entry) { Layer = LayerType.Leadin });
|
||||||
|
program.Codes.AddRange(ConvertShapeToMoves(piece, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The pierce of a common-line remainder entered at the end of <paramref name="span"/> (a
|
||||||
|
/// straight shared span of a perimeter wound <paramref name="winding"/>): on the shared
|
||||||
|
/// centerline, half the <paramref name="kerf"/> outside the span, <paramref name="reach"/>
|
||||||
|
/// back along it from the piece's start.
|
||||||
|
/// </summary>
|
||||||
|
internal static Vector CommonLineRemainderPierce(Line span, RotationType winding, double kerf, double reach)
|
||||||
|
{
|
||||||
|
var along = (span.EndPoint - span.StartPoint).Normalize();
|
||||||
|
// Material lies left of a counterclockwise perimeter's travel, so outside is right.
|
||||||
|
var outward = winding == RotationType.CCW ? new Vector(along.Y, -along.X) : new Vector(-along.Y, along.X);
|
||||||
|
return span.EndPoint - along * reach + outward * (kerf / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How far a common-line lead-in reaches: the external lead-in's pierce distance.</summary>
|
||||||
|
internal static double CommonLineLeadInReach(LeadIn leadIn) =>
|
||||||
|
leadIn.GetPiercePoint(Vector.Zero, 0, RotationType.CW).DistanceTo(Vector.Zero);
|
||||||
|
|
||||||
/// <summary>Whether <paramref name="shape"/> has <see cref="SpecialLayers.Tab"/> spans: a tabbed cutout.</summary>
|
/// <summary>Whether <paramref name="shape"/> has <see cref="SpecialLayers.Tab"/> spans: a tabbed cutout.</summary>
|
||||||
internal static bool IsTabbedCutout(Shape shape) => shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab);
|
internal static bool IsTabbedCutout(Shape shape) => shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab);
|
||||||
|
|
||||||
@@ -1449,9 +1782,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
private static bool OnArc(Vector center, Vector start, Vector end, Vector point, RotationType rotation)
|
private static bool OnArc(Vector center, Vector start, Vector end, Vector point, RotationType rotation)
|
||||||
{
|
{
|
||||||
var a0 = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
|
var a0 = PortableMath.Atan2(start.Y - center.Y, start.X - center.X);
|
||||||
var a1 = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
|
var a1 = PortableMath.Atan2(end.Y - center.Y, end.X - center.X);
|
||||||
var ap = System.Math.Atan2(point.Y - center.Y, point.X - center.X);
|
var ap = PortableMath.Atan2(point.Y - center.Y, point.X - center.X);
|
||||||
var sign = rotation == RotationType.CCW ? 1 : -1;
|
var sign = rotation == RotationType.CCW ? 1 : -1;
|
||||||
var sweep = Angle.NormalizeRad(sign * (a1 - a0));
|
var sweep = Angle.NormalizeRad(sign * (a1 - a0));
|
||||||
if (sweep <= Tolerance.Epsilon)
|
if (sweep <= Tolerance.Epsilon)
|
||||||
@@ -1508,8 +1841,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
var angle = atEnd ? arc.EndAngle : arc.StartAngle;
|
var angle = atEnd ? arc.EndAngle : arc.StartAngle;
|
||||||
return arc.IsReversed
|
return arc.IsReversed
|
||||||
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
|
? new Vector(PortableMath.Sin(angle), -PortableMath.Cos(angle))
|
||||||
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
|
: new Vector(-PortableMath.Sin(angle), PortableMath.Cos(angle));
|
||||||
}
|
}
|
||||||
var line = (Line)entity;
|
var line = (Line)entity;
|
||||||
return line.EndPoint - line.StartPoint;
|
return line.EndPoint - line.StartPoint;
|
||||||
|
|||||||
@@ -42,48 +42,63 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public double LeadInAngleIncrement { get; set; } = 5.0;
|
public double LeadInAngleIncrement { get; set; } = 5.0;
|
||||||
|
|
||||||
public Tab TabConfig { get; set; }
|
public Tab TabConfig { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a contour is cut with a tab at its entry. The cutting settings never set it for
|
||||||
|
/// every part: Plan Cutting, Place Lead-in and drawing pins set it on the copies of the
|
||||||
|
/// settings they give the parts they tab. Parameters recorded with it on replay as planned.
|
||||||
|
/// </summary>
|
||||||
public bool TabsEnabled { get; set; }
|
public bool TabsEnabled { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tabs each part its plate's support bed cannot hold level once it is cut free, at the
|
/// Tabs the parts that need one (see docs/support-tabs.md): with <see cref="TabBySize"/>
|
||||||
/// perimeter point that best keeps its center of gravity over the bed's contacts (see
|
/// off, each part its plate's support bed cannot hold level once it is cut free, at the
|
||||||
/// docs/support-tabs.md). Needs <see cref="TabConfig"/> for the tab size.
|
/// perimeter point that best keeps its center of gravity over the bed's contacts, or on a
|
||||||
|
/// plate with no bed each part in the size range; with it on, each part in the size range.
|
||||||
|
/// Needs <see cref="TabConfig"/> for the tab size.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool SupportTabs { get; set; }
|
public bool SupportTabs { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the narrowest part
|
/// With <see cref="SupportTabs"/>, ignore the support bed and tab every part whose width is
|
||||||
/// width that is tabbed instead (see <see cref="TabsWithoutBedAt"/>).
|
/// in <see cref="TabSizeMinWidth"/> to <see cref="TabSizeMaxWidth"/>
|
||||||
|
/// (<see cref="TabsBySizeAt"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double NoBedTabMinWidth { get; set; }
|
public bool TabBySize { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the widest part
|
/// The narrowest part width that is tabbed by size: with <see cref="TabBySize"/>, or with
|
||||||
/// width that is tabbed instead; 0 turns the rule off, so nothing is tabbed without a bed.
|
/// <see cref="SupportTabs"/> on a plate that has no support bed (see <see cref="TabsBySizeAt"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double NoBedTabMaxWidth { get; set; }
|
public double TabSizeMinWidth { get; set; }
|
||||||
|
|
||||||
/// <summary>Whether <see cref="NoBedTabMinWidth"/> to <see cref="NoBedTabMaxWidth"/> is a usable range.</summary>
|
/// <summary>
|
||||||
public bool HasNoBedTabRange() =>
|
/// The widest part width that is tabbed by size; 0 turns the rule off, so nothing is
|
||||||
NoBedTabMaxWidth > 0 && double.IsFinite(NoBedTabMaxWidth)
|
/// tabbed by size.
|
||||||
&& NoBedTabMinWidth >= 0 && NoBedTabMinWidth <= NoBedTabMaxWidth;
|
/// </summary>
|
||||||
|
public double TabSizeMaxWidth { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether <see cref="TabSizeMinWidth"/> to <see cref="TabSizeMaxWidth"/> is a usable range.</summary>
|
||||||
|
public bool HasTabSizeRange() =>
|
||||||
|
TabSizeMaxWidth > 0 && double.IsFinite(TabSizeMaxWidth)
|
||||||
|
&& TabSizeMinWidth >= 0 && TabSizeMinWidth <= TabSizeMaxWidth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether a part <paramref name="width"/> wide (the short side of its minimum-area
|
/// Whether a part <paramref name="width"/> wide (the short side of its minimum-area
|
||||||
/// bounding rectangle) is tabbed when its plate has no support bed.
|
/// bounding rectangle) is tabbed by size.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool TabsWithoutBedAt(double width) =>
|
public bool TabsBySizeAt(double width) =>
|
||||||
HasNoBedTabRange()
|
HasTabSizeRange()
|
||||||
&& width >= NoBedTabMinWidth - Math.Tolerance.Epsilon
|
&& width >= TabSizeMinWidth - Math.Tolerance.Epsilon
|
||||||
&& width <= NoBedTabMaxWidth + Math.Tolerance.Epsilon;
|
&& width <= TabSizeMaxWidth + Math.Tolerance.Epsilon;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// With <see cref="SupportTabs"/> on a plate with a support bed, the longest part (the
|
/// With <see cref="SupportTabs"/> on a plate with a support bed, the longest part (the
|
||||||
/// long side of its outline's minimum-area bounding rectangle) its tab alone holds: such a
|
/// long side of its outline's minimum-area bounding rectangle) its tab alone holds: such a
|
||||||
/// part the bed cannot hold level is tabbed, and any tab clear of inside corners steadies
|
/// part the bed cannot hold level is tabbed, and any tab clear of inside corners steadies
|
||||||
/// it. 0 turns the rule off.
|
/// it. 0 turns the rule off. New settings start at 6; settings saved without it read as 0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double TabHeldMaxLength { get; set; }
|
public double TabHeldMaxLength { get; set; } = 6;
|
||||||
|
|
||||||
/// <summary>Whether a part <paramref name="length"/> long is held by its tab alone (see <see cref="TabHeldMaxLength"/>).</summary>
|
/// <summary>Whether a part <paramref name="length"/> long is held by its tab alone (see <see cref="TabHeldMaxLength"/>).</summary>
|
||||||
public bool TabHoldsAt(double length) =>
|
public bool TabHoldsAt(double length) =>
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The pierce search for cutouts too small for their configured lead-in (text, slots, small
|
/// The pierce search for cutouts too small for their configured lead-in (text, slots, small
|
||||||
/// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
|
/// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
|
||||||
/// The erosion goes as deep as the pierce clearance when the cutout has room for it, else
|
/// The erosion goes as deep as the caller asks when the cutout has room for it, else almost
|
||||||
/// almost to its pole of inaccessibility. Each target, an entry the planner ranked, gets the
|
/// to its pole of inaccessibility; a round hole is pierced at its center. Each target, an entry the planner ranked, gets the
|
||||||
/// nearest point of the deepest erosion that still reaches the wall beside it, stepping down to
|
/// nearest point of the deepest erosion that still reaches the wall beside it, stepping down to
|
||||||
/// half that depth: a letter stroke is pierced on its own centre line next to the entry, and
|
/// half that depth: a letter stroke is pierced on its own center line next to the entry, and
|
||||||
/// only a target with no stroke beside it falls back to the deepest place. The lead then runs
|
/// only a target with no stroke beside it falls back to the deepest place. The lead then runs
|
||||||
/// straight from the pierce to the wall.
|
/// straight from the pierce to the wall.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -30,7 +30,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
|
/// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
|
||||||
/// not fit: deep enough to stay near the centre line, with room for every place the
|
/// not fit: deep enough to stay near the center line, with room for every place the
|
||||||
/// cutout is about as wide as its widest.
|
/// cutout is about as wide as its widest.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const double DepthFraction = 0.9;
|
public const double DepthFraction = 0.9;
|
||||||
@@ -47,10 +47,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
/// too small to erode is pierced at its pole for every target. Nothing for an open or
|
/// too small to erode is pierced at its pole for every target. Nothing for an open or
|
||||||
/// degenerate contour.
|
/// degenerate contour.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ErodedPierces Find(Shape hole, double clearance, IReadOnlyList<Vector> targets, int max)
|
public static ErodedPierces Find(Shape hole, double depth, IReadOnlyList<Vector> targets, int max)
|
||||||
{
|
{
|
||||||
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|
||||||
|| !double.IsFinite(clearance))
|
|| !double.IsFinite(depth))
|
||||||
return ErodedPierces.None;
|
return ErodedPierces.None;
|
||||||
|
|
||||||
// The inscribed radius, from the true wall: the deepest any erosion can go.
|
// The inscribed radius, from the true wall: the deepest any erosion can go.
|
||||||
@@ -64,10 +64,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
|
var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
|
||||||
if (!(inscribed > 0))
|
if (!(inscribed > 0))
|
||||||
return ErodedPierces.None;
|
return ErodedPierces.None;
|
||||||
|
if (hole.Entities.Count == 1 && hole.Entities[0] is Circle circle)
|
||||||
|
return new(targets.Take(max).Select(_ => circle.Center).ToArray(), circle.Radius);
|
||||||
|
|
||||||
// Inscribed flattening can stand off a concave arc, and round joins cut their arcs,
|
// Inscribed flattening can stand off a concave arc, and round joins cut their arcs,
|
||||||
// each by the tolerance; Clipper rounds to its precision. Erode past both.
|
// each by the tolerance; Clipper rounds to its precision. Erode past both.
|
||||||
var depth = System.Math.Min(System.Math.Max(clearance, 0), inscribed * DepthFraction);
|
depth = System.Math.Min(System.Math.Max(depth, 0), inscribed * DepthFraction);
|
||||||
var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision));
|
var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision));
|
||||||
var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
|
var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
|
||||||
if (depth + margin >= inscribed - tolerance)
|
if (depth + margin >= inscribed - tolerance)
|
||||||
@@ -84,7 +86,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
|
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
|
||||||
|
|
||||||
// Each target gets the deepest level that reaches the wall beside it: a stroke
|
// Each target gets the deepest level that reaches the wall beside it: a stroke
|
||||||
// narrower than the cutout's widest place is still pierced on its own centre line, near
|
// narrower than the cutout's widest place is still pierced on its own center line, near
|
||||||
// the entry, rather than only at the widest place.
|
// the entry, rather than only at the widest place.
|
||||||
var pierces = new List<Vector>();
|
var pierces = new List<Vector>();
|
||||||
var shallowest = depth;
|
var shallowest = depth;
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var angle = contourNormalAngle + (winding == RotationType.CCW ? shortfall : -shortfall);
|
var angle = contourNormalAngle + (winding == RotationType.CCW ? shortfall : -shortfall);
|
||||||
var center = ArcCenter(contourStartPoint, contourNormalAngle);
|
var center = ArcCenter(contourStartPoint, contourNormalAngle);
|
||||||
return new Vector(
|
return new Vector(
|
||||||
center.X + Radius * System.Math.Cos(angle),
|
center.X + Radius * PortableMath.Cos(angle),
|
||||||
center.Y + Radius * System.Math.Sin(angle)
|
center.Y + Radius * PortableMath.Sin(angle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +55,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
new ArcLeadIn { Radius = Radius * factor, SweepAngle = SweepAngle };
|
new ArcLeadIn { Radius = Radius * factor, SweepAngle = SweepAngle };
|
||||||
|
|
||||||
private Vector ArcCenter(Vector contourStartPoint, double contourNormalAngle) => new Vector(
|
private Vector ArcCenter(Vector contourStartPoint, double contourNormalAngle) => new Vector(
|
||||||
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
|
contourStartPoint.X + Radius * PortableMath.Cos(contourNormalAngle),
|
||||||
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
|
contourStartPoint.Y + Radius * PortableMath.Sin(contourNormalAngle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,22 +28,22 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public static LineArcApproach Create(Vector contourStartPoint, double contourNormalAngle,
|
public static LineArcApproach Create(Vector contourStartPoint, double contourNormalAngle,
|
||||||
double arcRadius, double approachAngle, double lineLength, RotationType winding)
|
double arcRadius, double approachAngle, double lineLength, RotationType winding)
|
||||||
{
|
{
|
||||||
var arcCenterX = contourStartPoint.X + arcRadius * System.Math.Cos(contourNormalAngle);
|
var arcCenterX = contourStartPoint.X + arcRadius * PortableMath.Cos(contourNormalAngle);
|
||||||
var arcCenterY = contourStartPoint.Y + arcRadius * System.Math.Sin(contourNormalAngle);
|
var arcCenterY = contourStartPoint.Y + arcRadius * PortableMath.Sin(contourNormalAngle);
|
||||||
|
|
||||||
// The approach angle is measured back from the normal against the turn, so the arc
|
// The approach angle is measured back from the normal against the turn, so the arc
|
||||||
// runs the short way round to the contour point whichever way it turns.
|
// runs the short way round to the contour point whichever way it turns.
|
||||||
var approach = Angle.ToRadians(approachAngle);
|
var approach = Angle.ToRadians(approachAngle);
|
||||||
var lineAngle = contourNormalAngle + (winding == RotationType.CCW ? approach : -approach);
|
var lineAngle = contourNormalAngle + (winding == RotationType.CCW ? approach : -approach);
|
||||||
var arcStartX = arcCenterX + arcRadius * System.Math.Cos(lineAngle);
|
var arcStartX = arcCenterX + arcRadius * PortableMath.Cos(lineAngle);
|
||||||
var arcStartY = arcCenterY + arcRadius * System.Math.Sin(lineAngle);
|
var arcStartY = arcCenterY + arcRadius * PortableMath.Sin(lineAngle);
|
||||||
|
|
||||||
return new LineArcApproach(
|
return new LineArcApproach(
|
||||||
new Vector(arcCenterX, arcCenterY),
|
new Vector(arcCenterX, arcCenterY),
|
||||||
new Vector(arcStartX, arcStartY),
|
new Vector(arcStartX, arcStartY),
|
||||||
new Vector(
|
new Vector(
|
||||||
arcStartX + lineLength * System.Math.Cos(lineAngle),
|
arcStartX + lineLength * PortableMath.Cos(lineAngle),
|
||||||
arcStartY + lineLength * System.Math.Sin(lineAngle)));
|
arcStartY + lineLength * PortableMath.Sin(lineAngle)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Rapid to the pierce point, then the line and arc on the lead-in layer.</summary>
|
/// <summary>Rapid to the pierce point, then the line and arc on the lead-in layer.</summary>
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
||||||
return new Vector(
|
return new Vector(
|
||||||
contourStartPoint.X + Length * System.Math.Cos(approachAngle),
|
contourStartPoint.X + Length * PortableMath.Cos(approachAngle),
|
||||||
contourStartPoint.Y + Length * System.Math.Sin(approachAngle)
|
contourStartPoint.Y + Length * PortableMath.Sin(approachAngle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||||
var midPoint = new Vector(
|
var midPoint = new Vector(
|
||||||
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
|
contourStartPoint.X + Length2 * PortableMath.Cos(secondAngle),
|
||||||
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle)
|
contourStartPoint.Y + Length2 * PortableMath.Sin(secondAngle)
|
||||||
);
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
@@ -37,13 +37,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
RotationType winding = RotationType.CW)
|
RotationType winding = RotationType.CW)
|
||||||
{
|
{
|
||||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||||
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
|
var midX = contourStartPoint.X + Length2 * PortableMath.Cos(secondAngle);
|
||||||
var midY = contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle);
|
var midY = contourStartPoint.Y + Length2 * PortableMath.Sin(secondAngle);
|
||||||
|
|
||||||
var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2);
|
var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2);
|
||||||
return new Vector(
|
return new Vector(
|
||||||
midX + Length1 * System.Math.Cos(firstAngle),
|
midX + Length1 * PortableMath.Cos(firstAngle),
|
||||||
midY + Length1 * System.Math.Sin(firstAngle)
|
midY + Length1 * PortableMath.Sin(firstAngle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -17,16 +18,16 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle);
|
var arcCenterX = contourEndPoint.X + Radius * PortableMath.Cos(contourNormalAngle);
|
||||||
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
var arcCenterY = contourEndPoint.Y + Radius * PortableMath.Sin(contourNormalAngle);
|
||||||
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
||||||
|
|
||||||
// The contour point sits opposite the normal from the center; turn a quarter on.
|
// The contour point sits opposite the normal from the center; turn a quarter on.
|
||||||
var endAngle = contourNormalAngle
|
var endAngle = contourNormalAngle
|
||||||
+ (winding == RotationType.CCW ? -System.Math.PI / 2 : System.Math.PI / 2);
|
+ (winding == RotationType.CCW ? -System.Math.PI / 2 : System.Math.PI / 2);
|
||||||
var endPoint = new Vector(
|
var endPoint = new Vector(
|
||||||
arcCenterX + Radius * System.Math.Cos(endAngle),
|
arcCenterX + Radius * PortableMath.Cos(endAngle),
|
||||||
arcCenterY + Radius * System.Math.Sin(endAngle)
|
arcCenterY + Radius * PortableMath.Sin(endAngle)
|
||||||
);
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
|
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
|
||||||
var endPoint = new Vector(
|
var endPoint = new Vector(
|
||||||
contourEndPoint.X + Length * System.Math.Cos(overcutAngle),
|
contourEndPoint.X + Length * PortableMath.Cos(overcutAngle),
|
||||||
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle)
|
contourEndPoint.Y + Length * PortableMath.Sin(overcutAngle)
|
||||||
);
|
);
|
||||||
|
|
||||||
return new List<ICode> { new LinearMove(endPoint) { Layer = LayerType.Leadout } };
|
return new List<ICode> { new LinearMove(endPoint) { Layer = LayerType.Leadout } };
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Remembers <see cref="ContourCuttingStrategy.ResolvePierceZone"/> for one preparation's
|
||||||
|
/// contours. The resolution reads only the cutout's own geometry, the entry, the configured
|
||||||
|
/// lead-in and the zone, so for a given contour it is a pure function of those, yet the
|
||||||
|
/// search emits every contour of a part's chain again on every expansion, and on a cutout
|
||||||
|
/// whose configured pierce is inside the zone each resolution flattens the cutout, finds its
|
||||||
|
/// pole and erodes it. One memo serves every emission of one <c>PreparedContours</c> and
|
||||||
|
/// its copies, which share the contours by ordinal. A lead-in a memo returns is shared and
|
||||||
|
/// must not be mutated.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PierceZoneMemo
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Key, LeadIn> resolved = new();
|
||||||
|
private readonly object gate = new();
|
||||||
|
|
||||||
|
/// <summary>How many resolutions the memo answered without computing; for tests.</summary>
|
||||||
|
internal int Hits { get; private set; }
|
||||||
|
|
||||||
|
internal LeadIn Resolve(int contour, Shape hole, Vector point, LeadIn leadIn, double normal,
|
||||||
|
double pierceRadius, RotationType turn)
|
||||||
|
{
|
||||||
|
if (KeyOf(leadIn) is not { } lead)
|
||||||
|
return ContourCuttingStrategy.ResolvePierceZone(hole, point, leadIn, normal, pierceRadius, turn);
|
||||||
|
var key = new Key(contour, point.X, point.Y, normal, pierceRadius, turn, lead);
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (resolved.TryGetValue(key, out var known))
|
||||||
|
{
|
||||||
|
Hits++;
|
||||||
|
return known;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var result = ContourCuttingStrategy.ResolvePierceZone(hole, point, leadIn, normal, pierceRadius, turn);
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
resolved[key] = result;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The configured lead-in by value: an equal lead-in resolves the same way whichever
|
||||||
|
// instance carries it. A lead-in type this does not know is resolved every time.
|
||||||
|
private static LeadKey? KeyOf(LeadIn leadIn) => leadIn switch
|
||||||
|
{
|
||||||
|
NoLeadIn => new(0, 0, 0, 0, 0),
|
||||||
|
LineLeadIn line => new(1, line.Length, line.ApproachAngle, 0, 0),
|
||||||
|
ArcLeadIn arc => new(2, arc.Radius, arc.SweepAngle, 0, 0),
|
||||||
|
LineArcLeadIn lineArc => new(3, lineArc.LineLength, lineArc.ApproachAngle, lineArc.ArcRadius, 0),
|
||||||
|
CleanHoleLeadIn cleanHole => new(4, cleanHole.LineLength, cleanHole.ArcRadius, cleanHole.Kerf, 0),
|
||||||
|
LineLineLeadIn lineLine => new(5, lineLine.Length1, lineLine.ApproachAngle1, lineLine.Length2, lineLine.ApproachAngle2),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly record struct LeadKey(int Kind, double A, double B, double C, double D);
|
||||||
|
|
||||||
|
private readonly record struct Key(int Contour, double X, double Y, double Normal, double PierceRadius,
|
||||||
|
RotationType Turn, LeadKey Lead);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -23,8 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
var scoreAngle = contourNormalAngle + System.Math.PI;
|
var scoreAngle = contourNormalAngle + System.Math.PI;
|
||||||
var scoreEnd = new Vector(
|
var scoreEnd = new Vector(
|
||||||
tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle),
|
tabStartPoint.X + BreakerDepth * PortableMath.Cos(scoreAngle),
|
||||||
tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle)
|
tabStartPoint.Y + BreakerDepth * PortableMath.Sin(scoreAngle)
|
||||||
);
|
);
|
||||||
codes.Add(new LinearMove(scoreEnd));
|
codes.Add(new LinearMove(scoreEnd));
|
||||||
codes.Add(new RapidMove(tabEndPoint));
|
codes.Add(new RapidMove(tabEndPoint));
|
||||||
|
|||||||
@@ -73,31 +73,12 @@ namespace OpenNest.CNC
|
|||||||
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked
|
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked
|
||||||
/// overcut needs the post's lock and unlock codes, and its markers must be well formed.
|
/// overcut needs the post's lock and unlock codes, and its markers must be well formed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void EnsurePostable(Nest nest, bool hasCodes, string postName)
|
public static void EnsurePostable(Nest nest, bool hasCodes, string postName) =>
|
||||||
{
|
PostMarkers.EnsurePostable(nest, hasCodes, IsMarked, program => LockedMask(program.Codes),
|
||||||
ArgumentNullException.ThrowIfNull(nest);
|
name => $"{name} overcuts past the sheet edge with the head height locked, but the "
|
||||||
foreach (var plate in nest.Plates)
|
+ $"{postName} post has no head-height lock and unlock codes. Enter them in "
|
||||||
{
|
+ $"\"Head height lock\" and \"Head height unlock\" in the {postName} Settings "
|
||||||
foreach (var part in plate.Parts)
|
+ "dialog, which opens when you post, or delete the cut-off and add it again "
|
||||||
{
|
+ "with \"Lock head height and overcut the far edge\" unchecked.");
|
||||||
if (part?.Program == null || !IsMarked(part.Program))
|
|
||||||
continue;
|
|
||||||
var name = DisplayPerimeterTail.PartName(part);
|
|
||||||
if (!hasCodes)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"{name} overcuts past the sheet edge with the head height locked, but the {postName} post "
|
|
||||||
+ "has no head-height lock and unlock codes. Enter them in the post's configuration, "
|
|
||||||
+ "or delete the cut-off and add it again with the head-height lock off.");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
LockedMask(part.Program.Codes);
|
|
||||||
}
|
|
||||||
catch (ArgumentException ex)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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, and always
|
||||||
|
/// marks the rapid out of a cutout's slug after its lead-out (see docs/cutting-planner.md): a
|
||||||
|
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>, or before the
|
||||||
|
/// <see cref="SubProgramCall"/> whose arrival rapid it is, since a sub-program may be shared and
|
||||||
|
/// is never marked itself. 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, or a
|
||||||
|
/// sub-program call whose arrival rapid is. A marker must be followed directly by a rapid or
|
||||||
|
/// a call; 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 or SubProgramCall))
|
||||||
|
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 or sub-program calls 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 or SubProgramCall) || marked[i])
|
||||||
|
throw new ArgumentException("Only an unmarked rapid or sub-program call 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) =>
|
||||||
|
PostMarkers.EnsurePostable(nest, hasCode, IsMarked, program => RaisedMask(program.Codes),
|
||||||
|
name => $"{name} has rapids that must raise the head over parts already cut, but "
|
||||||
|
+ $"the {postName} post has no head-raise code. Enter it in \"Head raise\" in "
|
||||||
|
+ $"the {postName} Settings dialog, which opens when you post.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,5 +30,13 @@ namespace OpenNest.CNC
|
|||||||
/// closed contour enclosing every other contour.
|
/// closed contour enclosing every other contour.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
DisplayPerimeter,
|
DisplayPerimeter,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A shared cut of a common-line group (see docs/common-line-cutting.md): an open cut on the
|
||||||
|
/// centerline between two parts placed one kerf apart, which cuts both parts' facing edges
|
||||||
|
/// at once. It is cut without kerf compensation, bounds no material and is never a rapid
|
||||||
|
/// obstacle. Posts that cannot write it refuse the nest.
|
||||||
|
/// </summary>
|
||||||
|
CommonLine,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC
|
||||||
|
{
|
||||||
|
internal static class PostMarkers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Refuses a nest whose marked programs a post cannot write: every marked part needs the
|
||||||
|
/// post's <paramref name="hasCode"/>, and <paramref name="validate"/> rejects a malformed
|
||||||
|
/// program with an <see cref="ArgumentException"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static void EnsurePostable(Nest nest, bool hasCode, Func<Program, bool> isMarked,
|
||||||
|
Action<Program> validate, Func<string, string> missingCode)
|
||||||
|
{
|
||||||
|
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(missingCode(name));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
validate(part.Program);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,8 +103,8 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
if (subpgm.Offset.X != 0 || subpgm.Offset.Y != 0)
|
if (subpgm.Offset.X != 0 || subpgm.Offset.Y != 0)
|
||||||
{
|
{
|
||||||
var cos = System.Math.Cos(angle);
|
var cos = PortableMath.Cos(angle);
|
||||||
var sin = System.Math.Sin(angle);
|
var sin = PortableMath.Sin(angle);
|
||||||
var dx = subpgm.Offset.X - origin.X;
|
var dx = subpgm.Offset.X - origin.X;
|
||||||
var dy = subpgm.Offset.Y - origin.Y;
|
var dy = subpgm.Offset.Y - origin.Y;
|
||||||
subpgm.Offset = new Geometry.Vector(
|
subpgm.Offset = new Geometry.Vector(
|
||||||
@@ -158,35 +158,7 @@ namespace OpenNest.CNC
|
|||||||
SetModeInc();
|
SetModeInc();
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual void Offset(Vector voffset)
|
public virtual void Offset(Vector voffset) => Offset(voffset.X, voffset.Y);
|
||||||
{
|
|
||||||
var mode = Mode;
|
|
||||||
|
|
||||||
SetModeAbs();
|
|
||||||
|
|
||||||
for (int i = 0; i < Codes.Count; ++i)
|
|
||||||
{
|
|
||||||
var code = Codes[i];
|
|
||||||
|
|
||||||
if (code is SubProgramCall subpgm)
|
|
||||||
{
|
|
||||||
subpgm.Offset = new Geometry.Vector(
|
|
||||||
subpgm.Offset.X + voffset.X,
|
|
||||||
subpgm.Offset.Y + voffset.Y
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code is Motion == false)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var code2 = (Motion)code;
|
|
||||||
|
|
||||||
code2.Offset(voffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode == Mode.Incremental)
|
|
||||||
SetModeInc();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void LineTo(double x, double y)
|
public void LineTo(double x, double y)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest
|
namespace OpenNest
|
||||||
{
|
{
|
||||||
@@ -123,7 +124,7 @@ namespace OpenNest
|
|||||||
// keeps offsets lying exactly on an axis (mirror-symmetric parts) away from the
|
// keeps offsets lying exactly on an axis (mirror-symmetric parts) away from the
|
||||||
// interval edge so floating-point noise cannot flip the choice.
|
// interval edge so floating-point noise cannot flip the choice.
|
||||||
var halfPi = System.Math.PI / 2.0;
|
var halfPi = System.Math.PI / 2.0;
|
||||||
var direction = System.Math.Atan2(dy, dx);
|
var direction = PortableMath.Atan2(dy, dx);
|
||||||
for (var turns = 0; turns < 4; turns++)
|
for (var turns = 0; turns < 4; turns++)
|
||||||
{
|
{
|
||||||
var relative = direction + turns * halfPi - (System.Math.PI - PreferenceMargin);
|
var relative = direction + turns * halfPi - (System.Math.PI - PreferenceMargin);
|
||||||
|
|||||||
@@ -26,14 +26,16 @@ namespace OpenNest
|
|||||||
double width,
|
double width,
|
||||||
double depth,
|
double depth,
|
||||||
IEnumerable<double> centers,
|
IEnumerable<double> centers,
|
||||||
double removalMinutes = DefaultRemovalMinutes
|
double removalMinutes = DefaultRemovalMinutes,
|
||||||
|
double? clearance = null
|
||||||
)
|
)
|
||||||
: this(
|
: this(
|
||||||
width,
|
width,
|
||||||
depth,
|
depth,
|
||||||
(centers ?? throw new ArgumentNullException(nameof(centers)))
|
(centers ?? throw new ArgumentNullException(nameof(centers)))
|
||||||
.Select(c => new RailClamp(ClampEdge.OriginSide, c)),
|
.Select(c => new RailClamp(ClampEdge.OriginSide, c)),
|
||||||
removalMinutes
|
removalMinutes,
|
||||||
|
clearance
|
||||||
)
|
)
|
||||||
{ }
|
{ }
|
||||||
|
|
||||||
@@ -41,7 +43,8 @@ namespace OpenNest
|
|||||||
double width,
|
double width,
|
||||||
double depth,
|
double depth,
|
||||||
IEnumerable<RailClamp> clamps,
|
IEnumerable<RailClamp> clamps,
|
||||||
double removalMinutes = DefaultRemovalMinutes
|
double removalMinutes = DefaultRemovalMinutes,
|
||||||
|
double? clearance = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(clamps);
|
ArgumentNullException.ThrowIfNull(clamps);
|
||||||
@@ -60,6 +63,11 @@ namespace OpenNest
|
|||||||
nameof(removalMinutes),
|
nameof(removalMinutes),
|
||||||
"Clamp removal time must be finite and zero or more."
|
"Clamp removal time must be finite and zero or more."
|
||||||
);
|
);
|
||||||
|
if (clearance is double c && (!double.IsFinite(c) || c < 0))
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(clearance),
|
||||||
|
"Clamp clearance must be finite and zero or more."
|
||||||
|
);
|
||||||
|
|
||||||
var sorted = new SortedSet<RailClamp>();
|
var sorted = new SortedSet<RailClamp>();
|
||||||
foreach (var clamp in clamps)
|
foreach (var clamp in clamps)
|
||||||
@@ -77,6 +85,7 @@ namespace OpenNest
|
|||||||
Width = width;
|
Width = width;
|
||||||
Depth = depth;
|
Depth = depth;
|
||||||
RemovalMinutes = removalMinutes;
|
RemovalMinutes = removalMinutes;
|
||||||
|
Clearance = clearance;
|
||||||
Clamps = sorted.ToArray();
|
Clamps = sorted.ToArray();
|
||||||
Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray();
|
Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray();
|
||||||
}
|
}
|
||||||
@@ -93,6 +102,14 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public double RemovalMinutes { get; }
|
public double RemovalMinutes { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The gap parts keep from every clamp footprint, independent of part spacing; null
|
||||||
|
/// keeps the sheet's bottom edge spacing (D6), the clearance before clamps had their
|
||||||
|
/// own. A clamp drawn as a support block under the sheet can use close to zero, so the
|
||||||
|
/// laser only stays off it. See <see cref="Plate.ClampClearance"/>.
|
||||||
|
/// </summary>
|
||||||
|
public double? Clearance { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Distinct clamps ordered by edge, then center. A clamp's position in this list
|
/// Distinct clamps ordered by edge, then center. A clamp's position in this list
|
||||||
/// gives its <c>clamp-N</c> id, so a rail with only origin-side clamps keeps the ids
|
/// gives its <c>clamp-N</c> id, so a rail with only origin-side clamps keeps the ids
|
||||||
@@ -103,9 +120,9 @@ namespace OpenNest
|
|||||||
/// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary>
|
/// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary>
|
||||||
public IReadOnlyList<double> Centers { get; }
|
public IReadOnlyList<double> Centers { get; }
|
||||||
|
|
||||||
/// <summary>A copy with the same size and removal time and these clamps.</summary>
|
/// <summary>A copy with the same size, removal time and clearance and these clamps.</summary>
|
||||||
public ClampRail WithClamps(IEnumerable<RailClamp> clamps) =>
|
public ClampRail WithClamps(IEnumerable<RailClamp> clamps) =>
|
||||||
new(Width, Depth, clamps, RemovalMinutes);
|
new(Width, Depth, clamps, RemovalMinutes, Clearance);
|
||||||
|
|
||||||
/// <summary>The <c>clamp-N</c> id of the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
|
/// <summary>The <c>clamp-N</c> id of the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
|
||||||
public static string IdOf(int index) => $"clamp-{index + 1}";
|
public static string IdOf(int index) => $"clamp-{index + 1}";
|
||||||
@@ -225,20 +242,22 @@ namespace OpenNest
|
|||||||
&& Width.Equals(other.Width)
|
&& Width.Equals(other.Width)
|
||||||
&& Depth.Equals(other.Depth)
|
&& Depth.Equals(other.Depth)
|
||||||
&& RemovalMinutes.Equals(other.RemovalMinutes)
|
&& RemovalMinutes.Equals(other.RemovalMinutes)
|
||||||
|
&& Nullable.Equals(Clearance, other.Clearance)
|
||||||
&& Clamps.SequenceEqual(other.Clamps);
|
&& Clamps.SequenceEqual(other.Clamps);
|
||||||
|
|
||||||
public override bool Equals(object obj) => Equals(obj as ClampRail);
|
public override bool Equals(object obj) => Equals(obj as ClampRail);
|
||||||
|
|
||||||
public override int GetHashCode()
|
public override int GetHashCode()
|
||||||
{
|
{
|
||||||
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clamps.Count);
|
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clearance, Clamps.Count);
|
||||||
foreach (var clamp in Clamps)
|
foreach (var clamp in Clamps)
|
||||||
hash = HashCode.Combine(hash, clamp);
|
hash = HashCode.Combine(hash, clamp);
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() =>
|
public override string ToString() =>
|
||||||
$"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Clamps.Select(Describe))}]";
|
$"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Clamps.Select(Describe))}]"
|
||||||
|
+ (Clearance is double clearance ? $" clearance {clearance:R}" : "");
|
||||||
|
|
||||||
private static string Describe(RailClamp clamp) =>
|
private static string Describe(RailClamp clamp) =>
|
||||||
clamp.Edge == ClampEdge.OriginSide
|
clamp.Edge == ClampEdge.OriginSide
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One shared cut of a common-line group: a straight span, or spans chained at corners (a Z or
|
||||||
|
/// L shared line). <see cref="Owner"/>, the member of the pair cut first, cuts it as part of its
|
||||||
|
/// own compensated perimeter, which lands on the kerf centerline. <see cref="Partner"/> is cut
|
||||||
|
/// later; its remainder ends in the kerf this cut opened.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CommonLineSharedCut(CommonLinePath Path, Part Owner, Part Partner);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How a common-line group is cut once (see docs/common-line-cutting.md): its members in cutting
|
||||||
|
/// order, and each shared cut with the member that cuts it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CommonLineCutOrder(CommonLineGroup Group, IReadOnlyList<Part> Members,
|
||||||
|
IReadOnlyList<CommonLineSharedCut> Cuts)
|
||||||
|
{
|
||||||
|
/// <summary>The shared cuts <paramref name="member"/> cuts.</summary>
|
||||||
|
public IEnumerable<CommonLineSharedCut> CutsOwnedBy(Part member) =>
|
||||||
|
Cuts.Where(cut => ReferenceEquals(cut.Owner, member));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shared paths an earlier member cuts and <paramref name="member"/> therefore never
|
||||||
|
/// does: its remainder ends in their kerf.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<CommonLinePath> PathsCutBefore(Part member) => Cuts
|
||||||
|
.Where(cut => ReferenceEquals(cut.Partner, member))
|
||||||
|
.Select(cut => cut.Path);
|
||||||
|
|
||||||
|
/// <summary>Every shared path <paramref name="member"/> shares, whoever cuts it.</summary>
|
||||||
|
public IEnumerable<CommonLinePath> PathsOf(Part member) => Cuts
|
||||||
|
.Where(cut => ReferenceEquals(cut.Owner, member) || ReferenceEquals(cut.Partner, member))
|
||||||
|
.Select(cut => cut.Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single-pass common-line cutting: which member cuts each shared span, and the planner's owned
|
||||||
|
/// clean perimeter with its shared spans marked as never cut by that member.
|
||||||
|
/// </summary>
|
||||||
|
public static class CommonLineCutting
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The cutting order of an intact group: members breadth-first from its first member along
|
||||||
|
/// shared spans (ties in member order), each shared cut owned by the member cut first. A
|
||||||
|
/// member's remainder therefore only ever ends in kerf cut before it.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentException">The group is not intact.</exception>
|
||||||
|
public static CommonLineCutOrder Order(CommonLineGroup group, CommonLineCheck check)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(group);
|
||||||
|
ArgumentNullException.ThrowIfNull(check);
|
||||||
|
if (!check.IsIntact)
|
||||||
|
throw new ArgumentException(check.Problems[0].Message, nameof(check));
|
||||||
|
var members = group.Members;
|
||||||
|
int IndexOf(Part part) => members.ToList().FindIndex(member => ReferenceEquals(member, part));
|
||||||
|
var order = new List<Part>();
|
||||||
|
var seen = new bool[members.Count];
|
||||||
|
var queue = new Queue<int>();
|
||||||
|
queue.Enqueue(0);
|
||||||
|
seen[0] = true;
|
||||||
|
while (queue.Count > 0)
|
||||||
|
{
|
||||||
|
var current = members[queue.Dequeue()];
|
||||||
|
order.Add(current);
|
||||||
|
var neighbors = check.Spans
|
||||||
|
.Select(span => ReferenceEquals(span.First, current) ? span.Second
|
||||||
|
: ReferenceEquals(span.Second, current) ? span.First : null)
|
||||||
|
.Where(part => part != null)
|
||||||
|
.Select(IndexOf)
|
||||||
|
.Distinct()
|
||||||
|
.Order();
|
||||||
|
foreach (var next in neighbors)
|
||||||
|
if (!seen[next])
|
||||||
|
{
|
||||||
|
seen[next] = true;
|
||||||
|
queue.Enqueue(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (order.Count != members.Count)
|
||||||
|
throw new ArgumentException("The common-line group has split into separate pieces.", nameof(check));
|
||||||
|
int Rank(Part part) => order.FindIndex(member => ReferenceEquals(member, part));
|
||||||
|
var cuts = CommonLineGeometry.Paths(check.Spans, group.Kerf)
|
||||||
|
.Select(path => Rank(path.First) < Rank(path.Second)
|
||||||
|
? new CommonLineSharedCut(path, path.First, path.Second)
|
||||||
|
: new CommonLineSharedCut(path, path.Second, path.First))
|
||||||
|
.OrderBy(cut => Rank(cut.Owner))
|
||||||
|
.ToList();
|
||||||
|
return new CommonLineCutOrder(group, order, cuts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="MarkSharedSpans(Program, Vector, IReadOnlyList{CommonLinePath}, double)"/> with
|
||||||
|
/// every span a straight path of its own.
|
||||||
|
/// </summary>
|
||||||
|
public static Program MarkSharedSpans(Program clean, Vector location, IReadOnlyList<CommonLineSpan> spans,
|
||||||
|
double kerf)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(spans);
|
||||||
|
return MarkSharedSpans(clean, location, spans
|
||||||
|
.Select(span => new CommonLinePath(span.First, span.Second, [span], [span.Start, span.End]))
|
||||||
|
.ToArray(), kerf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy of <paramref name="clean"/> (a member's clean program, rotated as placed, in part
|
||||||
|
/// coordinates; placed at <paramref name="location"/>) whose perimeter edges are split at
|
||||||
|
/// every end of the shared <paramref name="paths"/> and whose shared portions are on the
|
||||||
|
/// <see cref="LayerType.Tab"/> layer: material that this member never cuts, because the
|
||||||
|
/// shared cut does. At a path's corner each leg's portion runs to the member's own vertex
|
||||||
|
/// there, so the legs' portions join. The result is absolute. Only straight top-level
|
||||||
|
/// moves can share.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentException">A path's leg does not lie along the program's edges.</exception>
|
||||||
|
public static Program MarkSharedSpans(Program clean, Vector location, IReadOnlyList<CommonLinePath> paths,
|
||||||
|
double kerf)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(clean);
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
if (!double.IsFinite(kerf) || kerf <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(kerf));
|
||||||
|
var program = (Program)clean.Clone();
|
||||||
|
program.Mode = Mode.Absolute;
|
||||||
|
var legs = paths.SelectMany(path => Legs(path, location, kerf)).ToArray();
|
||||||
|
var covered = new double[legs.Length];
|
||||||
|
var codes = new List<ICode>();
|
||||||
|
Vector? position = null;
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
{
|
||||||
|
if (code is LinearMove line && position is { } start
|
||||||
|
&& line.Layer is LayerType.Cut or LayerType.Display)
|
||||||
|
{
|
||||||
|
codes.AddRange(Split(line, start, legs, covered, kerf));
|
||||||
|
position = line.EndPoint;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
codes.Add(code);
|
||||||
|
position = code switch
|
||||||
|
{
|
||||||
|
Motion motion => motion.EndPoint,
|
||||||
|
SubProgramCall => null,
|
||||||
|
_ => position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (var i = 0; i < legs.Length; i++)
|
||||||
|
{
|
||||||
|
var leg = legs[i];
|
||||||
|
// At a corner the member's portion ends at its own vertex, up to the snap off the centerline's.
|
||||||
|
var slack = leg.StartSnap + leg.EndSnap + CommonLineGeometry.GapTolerance;
|
||||||
|
if (covered[i] <= Math.Tolerance.Epsilon
|
||||||
|
|| System.Math.Abs(covered[i] - leg.Start.DistanceTo(leg.End)) > slack)
|
||||||
|
{
|
||||||
|
var (a, b) = (leg.Start + location, leg.End + location);
|
||||||
|
throw new ArgumentException(FormattableString.Invariant(
|
||||||
|
$"The shared span from ({a.X:0.####}, {a.Y:0.####}) to ({b.X:0.####}, {b.Y:0.####}) does not lie along one straight edge of the part's program."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
program.Codes.Clear();
|
||||||
|
program.Codes.AddRange(codes);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a member that cuts <paramref name="paths"/> as part of its own perimeter can start,
|
||||||
|
/// so that each common line leads straight into the rest of the perimeter: for each path,
|
||||||
|
/// the start of the perimeter edge (a top-level straight move of <paramref name="clean"/>)
|
||||||
|
/// along which the member's own travel enters the path, a pierce <paramref name="reach"/>
|
||||||
|
/// back along that edge's axis, and the edge's end. Part coordinates (the program placed at
|
||||||
|
/// <paramref name="location"/>), in path order.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<(Vector Entry, Vector Pierce, Vector Next)> PerimeterEntries(Program clean,
|
||||||
|
Vector location, IReadOnlyList<CommonLinePath> paths, double kerf, double reach)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(clean);
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
if (!double.IsFinite(kerf) || kerf <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(kerf));
|
||||||
|
var program = (Program)clean.Clone();
|
||||||
|
program.Mode = Mode.Absolute;
|
||||||
|
var edges = new List<(Vector Start, Vector End)>();
|
||||||
|
Vector? position = null;
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
{
|
||||||
|
if (code is LinearMove line && position is { } from && line.Layer is LayerType.Cut or LayerType.Display)
|
||||||
|
edges.Add((from, line.EndPoint));
|
||||||
|
position = code switch
|
||||||
|
{
|
||||||
|
Motion motion => motion.EndPoint,
|
||||||
|
SubProgramCall => null,
|
||||||
|
_ => position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var entries = new List<(Vector Entry, Vector Pierce, Vector Next)>();
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
var points = path.Points.Select(point => point - location).ToArray();
|
||||||
|
// Each end of the path, with the direction into the path from there.
|
||||||
|
foreach (var (end, inward) in new[]
|
||||||
|
{
|
||||||
|
(points[0], (points[1] - points[0]).Normalize()),
|
||||||
|
(points[^1], (points[^2] - points[^1]).Normalize()),
|
||||||
|
})
|
||||||
|
foreach (var (start, stop) in edges)
|
||||||
|
{
|
||||||
|
var length = start.DistanceTo(stop);
|
||||||
|
if (length <= Math.Tolerance.Epsilon)
|
||||||
|
continue;
|
||||||
|
var along = new Vector((stop.X - start.X) / length, (stop.Y - start.Y) / length);
|
||||||
|
if (System.Math.Abs(Cross(along, inward)) > 1e-6 || Dot(along, inward) <= 0)
|
||||||
|
continue;
|
||||||
|
if (System.Math.Abs(System.Math.Abs(Cross(along, end - start)) - kerf / 2) > CommonLineGeometry.GapTolerance)
|
||||||
|
continue;
|
||||||
|
var t = Dot(end - start, along);
|
||||||
|
if (t < -CommonLineGeometry.GapTolerance || t > length + CommonLineGeometry.GapTolerance)
|
||||||
|
continue;
|
||||||
|
var entry = (start, start - along * reach, stop);
|
||||||
|
if (!entries.Contains(entry))
|
||||||
|
entries.Add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One straight leg of a shared path in part coordinates. An end at a corner snaps to the
|
||||||
|
// edge's own vertex within kerf/2 · tan(turn/2) of the centerline corner's projection.
|
||||||
|
private readonly record struct Leg(Vector Start, Vector End, double StartSnap, double EndSnap);
|
||||||
|
|
||||||
|
private static IEnumerable<Leg> Legs(CommonLinePath path, Vector location, double kerf)
|
||||||
|
{
|
||||||
|
var points = path.Points.Select(point => point - location).ToArray();
|
||||||
|
var snaps = new double[points.Length];
|
||||||
|
for (var k = 1; k < points.Length - 1; k++)
|
||||||
|
{
|
||||||
|
var din = (points[k] - points[k - 1]).Normalize();
|
||||||
|
var dout = (points[k + 1] - points[k]).Normalize();
|
||||||
|
var turn = PortableMath.Atan2(System.Math.Abs(Cross(din, dout)), Dot(din, dout));
|
||||||
|
snaps[k] = kerf / 2 * PortableMath.Tan(turn / 2) + 2 * CommonLineGeometry.GapTolerance;
|
||||||
|
}
|
||||||
|
for (var k = 0; k + 1 < points.Length; k++)
|
||||||
|
yield return new Leg(points[k], points[k + 1], snaps[k], snaps[k + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The line split where shared legs one half kerf off it begin and end; shared pieces on Tab.
|
||||||
|
private static IEnumerable<ICode> Split(LinearMove line, Vector start, Leg[] legs, double[] covered,
|
||||||
|
double kerf)
|
||||||
|
{
|
||||||
|
var end = line.EndPoint;
|
||||||
|
var length = start.DistanceTo(end);
|
||||||
|
if (length <= Math.Tolerance.Epsilon)
|
||||||
|
return [line];
|
||||||
|
var direction = new Vector((end.X - start.X) / length, (end.Y - start.Y) / length);
|
||||||
|
var intervals = new List<(double From, double To)>();
|
||||||
|
for (var i = 0; i < legs.Length; i++)
|
||||||
|
{
|
||||||
|
var (a, b, snapA, snapB) = legs[i];
|
||||||
|
var legLength = a.DistanceTo(b);
|
||||||
|
if (legLength <= Math.Tolerance.Epsilon)
|
||||||
|
continue;
|
||||||
|
var along = new Vector((b.X - a.X) / legLength, (b.Y - a.Y) / legLength);
|
||||||
|
if (System.Math.Abs(Cross(direction, along)) > 1e-6)
|
||||||
|
continue;
|
||||||
|
var offset = System.Math.Abs(Cross(direction, a - start));
|
||||||
|
if (System.Math.Abs(offset - kerf / 2) > CommonLineGeometry.GapTolerance)
|
||||||
|
continue;
|
||||||
|
var ta = Snap(Dot(a - start, direction), snapA, length);
|
||||||
|
var tb = Snap(Dot(b - start, direction), snapB, length);
|
||||||
|
var from = System.Math.Max(0, System.Math.Min(ta, tb));
|
||||||
|
var to = System.Math.Min(length, System.Math.Max(ta, tb));
|
||||||
|
if (to - from <= Math.Tolerance.Epsilon)
|
||||||
|
continue;
|
||||||
|
// An end within the snap tolerance of a vertex is that vertex: no sliver is left uncut.
|
||||||
|
if (from <= Math.Tolerance.Epsilon)
|
||||||
|
from = 0;
|
||||||
|
if (length - to <= Math.Tolerance.Epsilon)
|
||||||
|
to = length;
|
||||||
|
covered[i] += to - from;
|
||||||
|
intervals.Add((from, to));
|
||||||
|
}
|
||||||
|
if (intervals.Count == 0)
|
||||||
|
return [line];
|
||||||
|
var pieces = new List<ICode>();
|
||||||
|
var at = 0.0;
|
||||||
|
foreach (var (from, to) in intervals.OrderBy(interval => interval.From))
|
||||||
|
{
|
||||||
|
if (from < at - Math.Tolerance.Epsilon)
|
||||||
|
throw new ArgumentException("Shared spans overlap along one edge.");
|
||||||
|
if (from - at > Math.Tolerance.Epsilon)
|
||||||
|
pieces.Add(new LinearMove(Point(from)) { Layer = line.Layer });
|
||||||
|
pieces.Add(new LinearMove(to >= length ? end : Point(to)) { Layer = LayerType.Tab });
|
||||||
|
at = to;
|
||||||
|
}
|
||||||
|
if (length - at > Math.Tolerance.Epsilon)
|
||||||
|
pieces.Add(new LinearMove(end) { Layer = line.Layer });
|
||||||
|
else if (pieces[^1] is LinearMove last)
|
||||||
|
last.EndPoint = end;
|
||||||
|
return pieces;
|
||||||
|
|
||||||
|
Vector Point(double t) => new(start.X + direction.X * t, start.Y + direction.Y * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A corner end within snap of either end of the edge is that end.
|
||||||
|
private static double Snap(double t, double snap, double length)
|
||||||
|
{
|
||||||
|
if (snap <= 0)
|
||||||
|
return t;
|
||||||
|
if (System.Math.Abs(t) <= snap)
|
||||||
|
return 0;
|
||||||
|
if (System.Math.Abs(t - length) <= snap)
|
||||||
|
return length;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
||||||
|
|
||||||
|
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>One shared cut: the centerline between two parts' facing edges, one kerf apart.</summary>
|
||||||
|
public readonly record struct CommonLineSpan(Part First, Part Second, Vector Start, Vector End)
|
||||||
|
{
|
||||||
|
public double Length => Start.DistanceTo(End);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One shared cut's path: the spans of one pair chained end to end at corners (a Z or L shared
|
||||||
|
/// line), or a single straight span. <see cref="Points"/> runs from the first span's free end
|
||||||
|
/// through each corner, where the spans' centerlines meet, to the last span's free end.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CommonLinePath(Part First, Part Second, IReadOnlyList<CommonLineSpan> Spans,
|
||||||
|
IReadOnlyList<Vector> Points)
|
||||||
|
{
|
||||||
|
public Vector Start => Points[0];
|
||||||
|
|
||||||
|
public Vector End => Points[^1];
|
||||||
|
|
||||||
|
/// <summary>The kerf-centerline length of the whole path, corners included.</summary>
|
||||||
|
public double Length
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var length = 0.0;
|
||||||
|
for (var i = 1; i < Points.Count; i++)
|
||||||
|
length += Points[i - 1].DistanceTo(Points[i]);
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A member of a group that no longer shares a span, or could not be read.</summary>
|
||||||
|
public readonly record struct CommonLineProblem(Part Part, string Message);
|
||||||
|
|
||||||
|
/// <summary>Spans a group's members share now, and what breaks the group.</summary>
|
||||||
|
public sealed record CommonLineCheck(IReadOnlyList<CommonLineSpan> Spans, IReadOnlyList<CommonLineProblem> Problems)
|
||||||
|
{
|
||||||
|
public bool IsIntact => Problems.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives common-line spans from placed parts' clean perimeters (leads excluded, holes never
|
||||||
|
/// share). Two straight perimeter edges share a span when they face each other (antiparallel,
|
||||||
|
/// outward normals opposed), lie one kerf apart within <see cref="GapTolerance"/> and overlap
|
||||||
|
/// along the line by at least <see cref="MinSharedLength"/>. Arcs never share.
|
||||||
|
/// </summary>
|
||||||
|
public static class CommonLineGeometry
|
||||||
|
{
|
||||||
|
/// <summary>How far a shared edge pair's gap may differ from the group's kerf (inches).</summary>
|
||||||
|
public const double GapTolerance = 0.0005;
|
||||||
|
|
||||||
|
/// <summary>The shortest overlap that counts as a shared span (inches).</summary>
|
||||||
|
public const double MinSharedLength = 0.25;
|
||||||
|
|
||||||
|
private const double ParallelTolerance = 1e-6;
|
||||||
|
|
||||||
|
/// <summary>A placed perimeter edge with its outward unit normal.</summary>
|
||||||
|
internal readonly record struct Edge(Vector Start, Vector End, Vector Direction, Vector Outward, double Length);
|
||||||
|
|
||||||
|
/// <summary>A facing edge pair: <see cref="Gap"/> along the first edge's outward normal,
|
||||||
|
/// the overlap as distances along the first edge from its start.</summary>
|
||||||
|
internal readonly record struct Facing(Edge First, Edge Second, double Gap, double From, double To)
|
||||||
|
{
|
||||||
|
public double Overlap => To - From;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The spans <paramref name="first"/> and <paramref name="second"/> share at <paramref name="kerf"/>.</summary>
|
||||||
|
public static IReadOnlyList<CommonLineSpan> Spans(Part first, Part second, double kerf)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(first);
|
||||||
|
ArgumentNullException.ThrowIfNull(second);
|
||||||
|
var cache = new ProfileCache();
|
||||||
|
return Spans(first, Edges(first, cache), second, Edges(second, cache), kerf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks that every member of <paramref name="group"/> shares at least one span with another
|
||||||
|
/// member at the group's kerf, and that the members form one connected group.
|
||||||
|
/// </summary>
|
||||||
|
public static CommonLineCheck Check(CommonLineGroup group)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(group);
|
||||||
|
var members = group.Members;
|
||||||
|
var cache = new ProfileCache();
|
||||||
|
var edges = new IReadOnlyList<Edge>[members.Count];
|
||||||
|
var problems = new List<CommonLineProblem>();
|
||||||
|
for (var i = 0; i < members.Count; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
edges[i] = Edges(members[i], cache);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
|
{
|
||||||
|
problems.Add(new(members[i], $"could not be read ({exception.Message})."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var spans = new List<CommonLineSpan>();
|
||||||
|
var links = members.Select(_ => new List<int>()).ToArray();
|
||||||
|
for (var i = 0; i < members.Count; i++)
|
||||||
|
for (var j = i + 1; j < members.Count; j++)
|
||||||
|
{
|
||||||
|
if (edges[i] == null || edges[j] == null)
|
||||||
|
continue;
|
||||||
|
var shared = Spans(members[i], edges[i], members[j], edges[j], group.Kerf);
|
||||||
|
if (shared.Count == 0)
|
||||||
|
continue;
|
||||||
|
spans.AddRange(shared);
|
||||||
|
links[i].Add(j);
|
||||||
|
links[j].Add(i);
|
||||||
|
}
|
||||||
|
for (var i = 0; i < members.Count; i++)
|
||||||
|
if (edges[i] != null && links[i].Count == 0)
|
||||||
|
problems.Add(new(members[i],
|
||||||
|
$"no longer shares a straight edge one kerf ({group.Kerf:0.####}) apart with another part in its common-line group."));
|
||||||
|
if (problems.Count == 0 && !Connected(links))
|
||||||
|
problems.Add(new(members[0], "its common-line group has split into separate pieces."));
|
||||||
|
return new CommonLineCheck(spans, problems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The move that puts <paramref name="moving"/> one kerf from <paramref name="anchor"/> along
|
||||||
|
/// their best facing edge pair: the longest overlap, then the smallest gap. Only pairs closer
|
||||||
|
/// than <paramref name="maxGap"/> (or already overlapping by up to that much) are considered;
|
||||||
|
/// null when there are none.
|
||||||
|
/// </summary>
|
||||||
|
public static Vector? JoinOffset(Part anchor, Part moving, double kerf, double maxGap)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(anchor);
|
||||||
|
ArgumentNullException.ThrowIfNull(moving);
|
||||||
|
if (!double.IsFinite(kerf) || kerf <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(kerf));
|
||||||
|
var cache = new ProfileCache();
|
||||||
|
var anchorEdges = Edges(anchor, cache);
|
||||||
|
var movingEdges = Edges(moving, cache);
|
||||||
|
var best = FacingEdges(anchorEdges, movingEdges, -maxGap, maxGap)
|
||||||
|
.OrderByDescending(facing => System.Math.Round(facing.Overlap, 6))
|
||||||
|
.ThenBy(facing => System.Math.Abs(facing.Gap - kerf))
|
||||||
|
.Cast<Facing?>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (best is not { } chosen)
|
||||||
|
return null;
|
||||||
|
var shift = kerf - chosen.Gap;
|
||||||
|
var offset = new Vector(chosen.First.Outward.X * shift, chosen.First.Outward.Y * shift);
|
||||||
|
return Slide(anchorEdges, movingEdges, chosen, offset, kerf, maxGap) ?? offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The offset slid along the chosen edge pair so that a facing pair across it (the step of a
|
||||||
|
// Z or L shared line) lands one kerf apart too, keeping the chosen pair's gap and span; null
|
||||||
|
// when no such pair is within reach.
|
||||||
|
private static Vector? Slide(IReadOnlyList<Edge> anchor, IReadOnlyList<Edge> moving, Facing chosen,
|
||||||
|
Vector offset, double kerf, double maxGap)
|
||||||
|
{
|
||||||
|
var along = chosen.First.Direction;
|
||||||
|
var minimum = PortableMath.Sin(MinCornerTurn);
|
||||||
|
var shifted = Shift(moving, offset);
|
||||||
|
var slides = FacingEdges(anchor, shifted, -maxGap, maxGap)
|
||||||
|
.Select(facing => (Facing: facing, Across: Dot(along, facing.First.Outward)))
|
||||||
|
.Where(choice => System.Math.Abs(choice.Across) >= minimum)
|
||||||
|
.Select(choice => (choice.Facing, Slide: (kerf - choice.Facing.Gap) / choice.Across))
|
||||||
|
.Where(choice => System.Math.Abs(choice.Slide) <= maxGap)
|
||||||
|
.OrderByDescending(choice => System.Math.Round(choice.Facing.Overlap, 6))
|
||||||
|
.ThenBy(choice => System.Math.Abs(choice.Slide));
|
||||||
|
foreach (var (_, slide) in slides)
|
||||||
|
{
|
||||||
|
var candidate = new Vector(offset.X + along.X * slide, offset.Y + along.Y * slide);
|
||||||
|
var placed = Shift(moving, candidate);
|
||||||
|
var spans = FacingEdges(anchor, placed, kerf - GapTolerance, kerf + GapTolerance).ToList();
|
||||||
|
// The chosen pair must still share, and the slid pair must now share across it.
|
||||||
|
if (spans.Any(facing => System.Math.Abs(Cross(facing.First.Direction, along)) <= ParallelTolerance)
|
||||||
|
&& spans.Any(facing => System.Math.Abs(Dot(along, facing.First.Outward)) >= minimum))
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<Edge> Shift(IReadOnlyList<Edge> edges, Vector offset) =>
|
||||||
|
edges.Select(edge => edge with { Start = edge.Start + offset, End = edge.End + offset }).ToArray();
|
||||||
|
|
||||||
|
/// <summary>The sharpest corner a shared path may turn (radians): 135°.</summary>
|
||||||
|
public const double MaxCornerTurn = 3 * System.Math.PI / 4;
|
||||||
|
|
||||||
|
/// <summary>The gentlest corner a shared path may turn (radians): 10°. Gentler meets are not corners.</summary>
|
||||||
|
public const double MinCornerTurn = System.Math.PI / 18;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shared cuts of <paramref name="spans"/> at <paramref name="kerf"/>: spans of one pair
|
||||||
|
/// whose ends meet at a corner (a Z or L shared line) chain into one path; every other span
|
||||||
|
/// is a path of its own. Two spans meet when their centerlines cross beyond both ends, each
|
||||||
|
/// end short of the crossing by what the kerf leaves there, <c>kerf/2 · tan(turn/2)</c>, and
|
||||||
|
/// the path turns between <see cref="MinCornerTurn"/> and <see cref="MaxCornerTurn"/>. Paths
|
||||||
|
/// keep the spans' order of first appearance.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<CommonLinePath> Paths(IReadOnlyList<CommonLineSpan> spans, double kerf)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(spans);
|
||||||
|
if (!double.IsFinite(kerf) || kerf <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(kerf));
|
||||||
|
// links[i, end]: the span and end met at span i's end (0 = Start, 1 = End), and the corner.
|
||||||
|
var links = new (int Span, int End, Vector Corner)?[spans.Count, 2];
|
||||||
|
for (var i = 0; i < spans.Count; i++)
|
||||||
|
for (var j = i + 1; j < spans.Count; j++)
|
||||||
|
{
|
||||||
|
if (!SamePair(spans[i], spans[j]))
|
||||||
|
continue;
|
||||||
|
for (var ei = 0; ei < 2; ei++)
|
||||||
|
for (var ej = 0; ej < 2; ej++)
|
||||||
|
if (links[i, ei] == null && links[j, ej] == null
|
||||||
|
&& Corner(spans[i], ei, spans[j], ej, kerf) is { } corner)
|
||||||
|
{
|
||||||
|
links[i, ei] = (j, ej, corner);
|
||||||
|
links[j, ej] = (i, ei, corner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var used = new bool[spans.Count];
|
||||||
|
var paths = new List<CommonLinePath>();
|
||||||
|
for (var i = 0; i < spans.Count; i++)
|
||||||
|
{
|
||||||
|
if (used[i])
|
||||||
|
continue;
|
||||||
|
// Walk back to a free end (or round a loop back to this span), then trace forward.
|
||||||
|
var (span, entry) = (i, links[i, 0] == null ? 0 : links[i, 1] == null ? 1 : 0);
|
||||||
|
for (var guard = 0; guard < spans.Count && links[span, entry] is { } back && back.Span != i; guard++)
|
||||||
|
(span, entry) = (back.Span, 1 - back.End);
|
||||||
|
var chain = new List<CommonLineSpan>();
|
||||||
|
var points = new List<Vector> { EndOf(spans[span], entry) };
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
used[span] = true;
|
||||||
|
chain.Add(spans[span]);
|
||||||
|
var exit = 1 - entry;
|
||||||
|
if (links[span, exit] is not { } next || used[next.Span])
|
||||||
|
{
|
||||||
|
points.Add(EndOf(spans[span], exit));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
points.Add(next.Corner);
|
||||||
|
(span, entry) = (next.Span, next.End);
|
||||||
|
}
|
||||||
|
paths.Add(new CommonLinePath(spans[i].First, spans[i].Second, chain, points));
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where span a's end ea meets span b's end eb at a corner, or null.
|
||||||
|
private static Vector? Corner(CommonLineSpan a, int ea, CommonLineSpan b, int eb, double kerf)
|
||||||
|
{
|
||||||
|
var aEnd = EndOf(a, ea);
|
||||||
|
var aOther = EndOf(a, 1 - ea);
|
||||||
|
var bEnd = EndOf(b, eb);
|
||||||
|
var bOther = EndOf(b, 1 - eb);
|
||||||
|
var aLength = aEnd.DistanceTo(aOther);
|
||||||
|
var bLength = bEnd.DistanceTo(bOther);
|
||||||
|
if (aLength <= Math.Tolerance.Epsilon || bLength <= Math.Tolerance.Epsilon)
|
||||||
|
return null;
|
||||||
|
// Travel along a into its end, then out along b from its end.
|
||||||
|
var din = new Vector((aEnd.X - aOther.X) / aLength, (aEnd.Y - aOther.Y) / aLength);
|
||||||
|
var dout = new Vector((bOther.X - bEnd.X) / bLength, (bOther.Y - bEnd.Y) / bLength);
|
||||||
|
var cross = Cross(din, dout);
|
||||||
|
var turn = PortableMath.Atan2(System.Math.Abs(cross), Dot(din, dout));
|
||||||
|
if (turn < MinCornerTurn || turn > MaxCornerTurn)
|
||||||
|
return null;
|
||||||
|
// aEnd + din·s = bEnd − dout·t: the corner lies s past a's end and t before b's end.
|
||||||
|
var gap = bEnd - aEnd;
|
||||||
|
var s = Cross(gap, dout) / cross;
|
||||||
|
var t = Cross(din, gap) / cross;
|
||||||
|
var reach = kerf / 2 * PortableMath.Tan(turn / 2) + 2 * GapTolerance;
|
||||||
|
if (s < -GapTolerance || t < -GapTolerance || s > reach || t > reach)
|
||||||
|
return null;
|
||||||
|
return new Vector(aEnd.X + din.X * s, aEnd.Y + din.Y * s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector EndOf(CommonLineSpan span, int end) => end == 0 ? span.Start : span.End;
|
||||||
|
|
||||||
|
private static bool SamePair(CommonLineSpan a, CommonLineSpan b) =>
|
||||||
|
(ReferenceEquals(a.First, b.First) && ReferenceEquals(a.Second, b.Second))
|
||||||
|
|| (ReferenceEquals(a.First, b.Second) && ReferenceEquals(a.Second, b.First));
|
||||||
|
|
||||||
|
private static IReadOnlyList<CommonLineSpan> Spans(Part first, IReadOnlyList<Edge> a, Part second,
|
||||||
|
IReadOnlyList<Edge> b, double kerf)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(kerf) || kerf <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(kerf));
|
||||||
|
var spans = new List<CommonLineSpan>();
|
||||||
|
foreach (var facing in FacingEdges(a, b, kerf - GapTolerance, kerf + GapTolerance))
|
||||||
|
{
|
||||||
|
var edge = facing.First;
|
||||||
|
var half = facing.Gap / 2;
|
||||||
|
Vector At(double t) => new(edge.Start.X + edge.Direction.X * t + edge.Outward.X * half,
|
||||||
|
edge.Start.Y + edge.Direction.Y * t + edge.Outward.Y * half);
|
||||||
|
spans.Add(new CommonLineSpan(first, second, At(facing.From), At(facing.To)));
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IEnumerable<Facing> FacingEdges(IReadOnlyList<Edge> a, IReadOnlyList<Edge> b,
|
||||||
|
double minGap, double maxGap)
|
||||||
|
{
|
||||||
|
foreach (var first in a)
|
||||||
|
foreach (var second in b)
|
||||||
|
{
|
||||||
|
// Antiparallel edges with opposed outward normals face each other.
|
||||||
|
if (System.Math.Abs(Cross(first.Direction, second.Direction)) > ParallelTolerance
|
||||||
|
|| Dot(first.Outward, second.Outward) > -1 + ParallelTolerance)
|
||||||
|
continue;
|
||||||
|
var gap = Dot(second.Start - first.Start, first.Outward);
|
||||||
|
if (gap < minGap || gap > maxGap)
|
||||||
|
continue;
|
||||||
|
var t0 = Dot(second.Start - first.Start, first.Direction);
|
||||||
|
var t1 = Dot(second.End - first.Start, first.Direction);
|
||||||
|
var from = System.Math.Max(0, System.Math.Min(t0, t1));
|
||||||
|
var to = System.Math.Min(first.Length, System.Math.Max(t0, t1));
|
||||||
|
if (to - from >= MinSharedLength)
|
||||||
|
yield return new Facing(first, second, gap, from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The straight edges of a part's placed clean perimeter, with outward normals.</summary>
|
||||||
|
internal static IReadOnlyList<Edge> Edges(Part part, ProfileCache cache)
|
||||||
|
{
|
||||||
|
var profile = PartSpacingReview.Profile(part, cache.Profiles)
|
||||||
|
?? throw new ArgumentException("Part has no material.");
|
||||||
|
var location = part.Location;
|
||||||
|
var ring = profile.Perimeter.ToPolygon();
|
||||||
|
var edges = new List<Edge>();
|
||||||
|
foreach (var line in profile.Perimeter.Entities.OfType<Line>())
|
||||||
|
{
|
||||||
|
var start = line.StartPoint + location;
|
||||||
|
var end = line.EndPoint + location;
|
||||||
|
var length = start.DistanceTo(end);
|
||||||
|
if (length < MinSharedLength)
|
||||||
|
continue;
|
||||||
|
var direction = new Vector((end.X - start.X) / length, (end.Y - start.Y) / length);
|
||||||
|
var outward = new Vector(direction.Y, -direction.X);
|
||||||
|
// The probe is in part-local coordinates: the ring is not moved to the location.
|
||||||
|
var mid = new Vector((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2);
|
||||||
|
var step = System.Math.Min(0.001, length / 100);
|
||||||
|
if (Inside(ring.Vertices, new Vector(mid.X + outward.X * step, mid.Y + outward.Y * step)))
|
||||||
|
outward = new Vector(-outward.X, -outward.Y);
|
||||||
|
edges.Add(new Edge(start, end, direction, outward, length));
|
||||||
|
}
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ProfileCache
|
||||||
|
{
|
||||||
|
public readonly Dictionary<(Program, long), ShapeProfile> Profiles = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Connected(IReadOnlyList<List<int>> links)
|
||||||
|
{
|
||||||
|
var seen = new bool[links.Count];
|
||||||
|
var stack = new Stack<int>();
|
||||||
|
stack.Push(0);
|
||||||
|
seen[0] = true;
|
||||||
|
while (stack.Count > 0)
|
||||||
|
foreach (var next in links[stack.Pop()])
|
||||||
|
if (!seen[next])
|
||||||
|
{
|
||||||
|
seen[next] = true;
|
||||||
|
stack.Push(next);
|
||||||
|
}
|
||||||
|
return seen.All(s => s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Inside(IReadOnlyList<Vector> ring, Vector point)
|
||||||
|
{
|
||||||
|
var inside = false;
|
||||||
|
for (int i = 0, j = ring.Count - 1; i < ring.Count; j = i++)
|
||||||
|
{
|
||||||
|
var a = ring[i];
|
||||||
|
var b = ring[j];
|
||||||
|
if ((a.Y > point.Y) != (b.Y > point.Y)
|
||||||
|
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
||||||
|
|
||||||
|
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||||
|
|
||||||
|
private static bool IsInvalid(Exception exception) => exception is
|
||||||
|
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parts placed exactly one kerf apart along collinear straight edges, so one centerline cut
|
||||||
|
/// makes both edges (see docs/common-line-cutting.md). Membership is by reference; the shared
|
||||||
|
/// spans are derived from the parts' placed geometry (<see cref="CommonLineGeometry"/>), never
|
||||||
|
/// stored, so a moved member breaks the group rather than leaving a stale span behind.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CommonLineGroup
|
||||||
|
{
|
||||||
|
private readonly List<Part> members;
|
||||||
|
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">The kerf is not finite and positive.</exception>
|
||||||
|
/// <exception cref="ArgumentException">Fewer than two distinct parts, a null part or a cut-off.</exception>
|
||||||
|
public CommonLineGroup(IEnumerable<Part> members, double kerf)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(members);
|
||||||
|
if (!double.IsFinite(kerf) || kerf <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(kerf), kerf, "A common line needs a finite, positive kerf.");
|
||||||
|
var distinct = new List<Part>();
|
||||||
|
foreach (var part in members)
|
||||||
|
{
|
||||||
|
if (part == null)
|
||||||
|
throw new ArgumentException("A common-line group cannot hold a null part.", nameof(members));
|
||||||
|
if (part.BaseDrawing?.IsCutOff == true)
|
||||||
|
throw new ArgumentException("A cut-off cannot share a common line.", nameof(members));
|
||||||
|
if (!distinct.Any(existing => ReferenceEquals(existing, part)))
|
||||||
|
distinct.Add(part);
|
||||||
|
}
|
||||||
|
if (distinct.Count < 2)
|
||||||
|
throw new ArgumentException("A common-line group needs at least two parts.", nameof(members));
|
||||||
|
this.members = distinct;
|
||||||
|
Kerf = kerf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The parts in the group, in the order they were joined.</summary>
|
||||||
|
public IReadOnlyList<Part> Members => members;
|
||||||
|
|
||||||
|
/// <summary>The gap the members were placed at: the kerf width when the group was made.</summary>
|
||||||
|
public double Kerf { get; }
|
||||||
|
|
||||||
|
public bool Contains(Part part) => members.Any(member => ReferenceEquals(member, part));
|
||||||
|
|
||||||
|
/// <summary>Drops a member (a part removed from the plate); true when it was one.</summary>
|
||||||
|
internal bool Remove(Part part) => members.RemoveAll(member => ReferenceEquals(member, part)) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>The outcome of <see cref="CommonLineJoiner.Join"/>: the group made, or why not.</summary>
|
||||||
|
public sealed record CommonLineJoinResult(CommonLineGroup Group, string Failure)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Group != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slides parts together one kerf apart along their best facing straight edges and groups them
|
||||||
|
/// for common-line cutting. Parts already in a group move with their whole group, which joins
|
||||||
|
/// the new one. Nothing moves unless every part finds a partner and the result is an intact group.
|
||||||
|
/// </summary>
|
||||||
|
public static class CommonLineJoiner
|
||||||
|
{
|
||||||
|
/// <param name="maxGap">How far apart facing edges may start (or overlap) and still be joined.</param>
|
||||||
|
public static CommonLineJoinResult Join(Plate plate, IReadOnlyList<Part> parts, double kerf, double maxGap)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plate);
|
||||||
|
ArgumentNullException.ThrowIfNull(parts);
|
||||||
|
if (!double.IsFinite(kerf) || kerf <= 0)
|
||||||
|
return new(null, "The plate has no kerf for its material and thickness, so parts cannot share a common line.");
|
||||||
|
if (!double.IsFinite(maxGap) || maxGap < kerf)
|
||||||
|
maxGap = kerf;
|
||||||
|
var selected = parts.Where(part => part != null && !part.BaseDrawing.IsCutOff).Distinct().ToList();
|
||||||
|
if (selected.Any(part => !plate.Parts.Contains(part)))
|
||||||
|
return new(null, "Every part in a common line must be on the plate.");
|
||||||
|
|
||||||
|
// A part already in a group brings its whole group, which moves as one unit.
|
||||||
|
var units = new List<List<Part>>();
|
||||||
|
foreach (var part in selected)
|
||||||
|
{
|
||||||
|
if (units.Any(unit => unit.Contains(part)))
|
||||||
|
continue;
|
||||||
|
units.Add(plate.CommonLineGroupOf(part) is { } group ? group.Members.ToList() : new List<Part> { part });
|
||||||
|
}
|
||||||
|
if (units.Sum(unit => unit.Count) < 2)
|
||||||
|
return new(null, "Select at least two parts to share a common line.");
|
||||||
|
|
||||||
|
var start = units.SelectMany(unit => unit).ToDictionary(part => part, part => part.Location);
|
||||||
|
// Anchor the unit nearest the plate origin; join the rest to whatever is placed.
|
||||||
|
var placed = units.OrderBy(unit => unit.Min(part => part.BoundingBox.Left + part.BoundingBox.Bottom)).First();
|
||||||
|
var anchored = new List<Part>(placed);
|
||||||
|
var remaining = units.Where(unit => unit != placed).ToList();
|
||||||
|
while (remaining.Count > 0)
|
||||||
|
{
|
||||||
|
var best = remaining
|
||||||
|
.SelectMany(unit => unit.SelectMany(candidate => anchored.Select(anchor =>
|
||||||
|
(Unit: unit, Offset: CommonLineGeometry.JoinOffset(anchor, candidate, kerf, maxGap)))))
|
||||||
|
.Where(choice => choice.Offset != null)
|
||||||
|
.OrderBy(choice => choice.Offset!.Value.DistanceTo(Vector.Zero))
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (best.Unit == null)
|
||||||
|
{
|
||||||
|
Restore(start);
|
||||||
|
var names = string.Join(", ", remaining.SelectMany(unit => unit).Select(part => part.BaseDrawing.Name).Distinct());
|
||||||
|
return new(null, $"No straight edge of {names} faces another selected part within {maxGap:0.###}. "
|
||||||
|
+ "Place the parts edge to edge first.");
|
||||||
|
}
|
||||||
|
foreach (var part in best.Unit)
|
||||||
|
part.Offset(best.Offset!.Value);
|
||||||
|
anchored.AddRange(best.Unit);
|
||||||
|
remaining.Remove(best.Unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
var trial = new CommonLineGroup(anchored, kerf);
|
||||||
|
var check = CommonLineGeometry.Check(trial);
|
||||||
|
if (!check.IsIntact)
|
||||||
|
{
|
||||||
|
Restore(start);
|
||||||
|
return new(null, $"'{check.Problems[0].Part.BaseDrawing.Name}' {check.Problems[0].Message}");
|
||||||
|
}
|
||||||
|
return new(plate.JoinCommonLine(anchored, kerf), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Restore(Dictionary<Part, Vector> start)
|
||||||
|
{
|
||||||
|
foreach (var (part, location) in start)
|
||||||
|
if (part.Location != location)
|
||||||
|
part.Offset(location - part.Location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// When automatic nesting and fills may place parts one kerf apart on a common line (see
|
||||||
|
/// docs/common-line-cutting.md). Only drawings with <see cref="Drawing.AllowCommonLine"/> take part.
|
||||||
|
/// </summary>
|
||||||
|
public enum CommonLineUsage
|
||||||
|
{
|
||||||
|
/// <summary>Never: engines and fills behave exactly as without common lines.</summary>
|
||||||
|
Off,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Only when it places more parts (or, in a whole job, uses fewer sheets) than the best
|
||||||
|
/// layout without common lines; a tie keeps the layout without them.
|
||||||
|
/// </summary>
|
||||||
|
WhenItAddsParts,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whenever it shortens the cut or uses less material, never at the cost of a part:
|
||||||
|
/// part count still ranks first.
|
||||||
|
/// </summary>
|
||||||
|
Whenever,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>The names <see cref="CommonLineUsage"/> is saved under (nest files and defaults.json).</summary>
|
||||||
|
public static class CommonLineUsageNames
|
||||||
|
{
|
||||||
|
/// <summary>The saved name, or null for <see cref="CommonLineUsage.Off"/> (omitted).</summary>
|
||||||
|
public static string Format(CommonLineUsage usage) => usage switch
|
||||||
|
{
|
||||||
|
CommonLineUsage.WhenItAddsParts => "whenItAddsParts",
|
||||||
|
CommonLineUsage.Whenever => "whenever",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Reads a saved name case-insensitively; anything else is off.</summary>
|
||||||
|
public static CommonLineUsage Parse(string name) =>
|
||||||
|
string.Equals(name, "whenItAddsParts", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
? CommonLineUsage.WhenItAddsParts
|
||||||
|
: string.Equals(name, "whenever", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
? CommonLineUsage.Whenever
|
||||||
|
: CommonLineUsage.Off;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,6 +186,9 @@ namespace OpenNest.Converters
|
|||||||
if (string.Equals(name, SpecialLayers.DisplayPerimeter.Name, System.StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(name, SpecialLayers.DisplayPerimeter.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||||
return LayerType.DisplayPerimeter;
|
return LayerType.DisplayPerimeter;
|
||||||
|
|
||||||
|
if (string.Equals(name, SpecialLayers.CommonLine.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||||
|
return LayerType.CommonLine;
|
||||||
|
|
||||||
return LayerType.Cut;
|
return LayerType.Cut;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ namespace OpenNest.Converters
|
|||||||
case LayerType.DisplayPerimeter:
|
case LayerType.DisplayPerimeter:
|
||||||
return SpecialLayers.DisplayPerimeter;
|
return SpecialLayers.DisplayPerimeter;
|
||||||
|
|
||||||
|
case LayerType.CommonLine:
|
||||||
|
return SpecialLayers.CommonLine;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return new Layer(layer.ToString());
|
return new Layer(layer.ToString());
|
||||||
}
|
}
|
||||||
|
|||||||
+223
-22
@@ -11,9 +11,13 @@ namespace OpenNest
|
|||||||
Vertical,
|
Vertical,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A stretch of a cut-off's line, along its axis, left uncut.</summary>
|
||||||
|
public readonly record struct CutOffGap(double Start, double End);
|
||||||
|
|
||||||
public class CutOff
|
public class CutOff
|
||||||
{
|
{
|
||||||
private const double OffsetTolerance = 0.001;
|
private const double OffsetTolerance = 0.001;
|
||||||
|
private IReadOnlyList<CutOffGap> gaps = System.Array.Empty<CutOffGap>();
|
||||||
|
|
||||||
public Vector Position { get; set; }
|
public Vector Position { get; set; }
|
||||||
public CutOffAxis Axis { get; set; }
|
public CutOffAxis Axis { get; set; }
|
||||||
@@ -21,6 +25,66 @@ namespace OpenNest
|
|||||||
public double? EndLimit { get; set; }
|
public double? EndLimit { get; set; }
|
||||||
public Drawing Drawing { get; private set; }
|
public Drawing Drawing { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stretches between the limits left uncut, so the line splits into pieces as it does
|
||||||
|
/// around a part; a trim of a middle piece makes one from crossing line to crossing line
|
||||||
|
/// (<see cref="CutOffTrim"/>). Kept sorted along the axis, with touching gaps merged and
|
||||||
|
/// empty or nonfinite ones dropped.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CutOffGap> Gaps
|
||||||
|
{
|
||||||
|
get => gaps;
|
||||||
|
set => gaps = Normalize(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The nominal line from <paramref name="start"/> to <paramref name="end"/> with its gaps
|
||||||
|
/// taken out, in ascending order: the pieces a consumer of the limits treats as cut.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<(double Start, double End)> Pieces(double start, double end) =>
|
||||||
|
Pieces(start, end, gaps);
|
||||||
|
|
||||||
|
internal static IReadOnlyList<(double Start, double End)> Pieces(double start, double end,
|
||||||
|
IReadOnlyList<CutOffGap> gaps)
|
||||||
|
{
|
||||||
|
var (low, high) = (System.Math.Min(start, end), System.Math.Max(start, end));
|
||||||
|
var pieces = new List<(double Start, double End)>();
|
||||||
|
if (gaps.Count == 0)
|
||||||
|
{
|
||||||
|
pieces.Add((low, high));
|
||||||
|
return pieces;
|
||||||
|
}
|
||||||
|
var current = low;
|
||||||
|
foreach (var gap in gaps)
|
||||||
|
{
|
||||||
|
if (gap.End <= current || gap.Start >= high)
|
||||||
|
continue;
|
||||||
|
if (gap.Start > current)
|
||||||
|
pieces.Add((current, gap.Start));
|
||||||
|
current = System.Math.Max(current, gap.End);
|
||||||
|
}
|
||||||
|
if (current < high)
|
||||||
|
pieces.Add((current, high));
|
||||||
|
return pieces;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<CutOffGap> Normalize(IEnumerable<CutOffGap> value)
|
||||||
|
{
|
||||||
|
var sorted = (value ?? Enumerable.Empty<CutOffGap>())
|
||||||
|
.Where(g => double.IsFinite(g.Start) && double.IsFinite(g.End) && g.End > g.Start)
|
||||||
|
.OrderBy(g => g.Start)
|
||||||
|
.ToList();
|
||||||
|
var merged = new List<CutOffGap>();
|
||||||
|
foreach (var gap in sorted)
|
||||||
|
{
|
||||||
|
if (merged.Count > 0 && gap.Start <= merged[^1].End + Math.Tolerance.Epsilon)
|
||||||
|
merged[^1] = merged[^1] with { End = System.Math.Max(merged[^1].End, gap.End) };
|
||||||
|
else
|
||||||
|
merged.Add(gap);
|
||||||
|
}
|
||||||
|
return merged.Count == 0 ? System.Array.Empty<CutOffGap>() : merged.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the
|
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the
|
||||||
/// segment reaching that edge cuts with the head height locked; zero for none. Stock can
|
/// segment reaching that edge cuts with the head height locked; zero for none. Stock can
|
||||||
@@ -85,6 +149,28 @@ namespace OpenNest
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cut strokes of a cut-off <paramref name="program"/>, offset by <paramref name="offset"/>:
|
||||||
|
/// one line per cutting move, from wherever the previous motion ended. Walks the motions rather
|
||||||
|
/// than pairing rapid and cut codes, because a head-locked far-edge segment carries lock
|
||||||
|
/// comments and a rapid return between its cuts.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Line> CutStrokes(Program program, Vector offset = default)
|
||||||
|
{
|
||||||
|
if (program == null)
|
||||||
|
yield break;
|
||||||
|
Vector? position = null;
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
{
|
||||||
|
if (code is not Motion motion)
|
||||||
|
continue;
|
||||||
|
var end = motion.EndPoint + offset;
|
||||||
|
if (motion is LinearMove && position is { } start)
|
||||||
|
yield return new Line(start, end);
|
||||||
|
position = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the far edge, along this axis on <paramref name="plate"/>, is the high
|
/// Whether the far edge, along this axis on <paramref name="plate"/>, is the high
|
||||||
/// coordinate: the sheet runs from the origin toward positive coordinates on that axis.
|
/// coordinate: the sheet runs from the origin toward positive coordinates on that axis.
|
||||||
@@ -189,21 +275,82 @@ namespace OpenNest
|
|||||||
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts
|
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts
|
||||||
/// one continuous straight line on the definition's axis across the whole sheet, so it
|
/// one continuous straight line on the definition's axis across the whole sheet, so it
|
||||||
/// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
|
/// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
|
||||||
/// line on the sheet edge, a stale or unrecognised program) does not separate the sheet.
|
/// line on the sheet edge, a stale or unrecognized program) does not separate the sheet.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet)
|
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet) =>
|
||||||
|
ContinuousCut(part, definition, sheet) is { } cut
|
||||||
|
&& cut.Low <= (definition.Axis == CutOffAxis.Vertical ? sheet.Bottom : sheet.Left) + Math.Tolerance.Epsilon
|
||||||
|
&& cut.High >= (definition.Axis == CutOffAxis.Vertical ? sheet.Top : sheet.Right) - Math.Tolerance.Epsilon;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cut-off parts among <paramref name="parts"/> that split off a piece of the sheet,
|
||||||
|
/// each with the round it was found in. Round 0 holds the lines that
|
||||||
|
/// <see cref="SeparatesSheet"/> across the whole sheet; a line of a later round is one
|
||||||
|
/// continuous cut from a sheet edge or an earlier round's line to another, such as the
|
||||||
|
/// last strip line stopping at a remnant's horizontal separator. It frees a piece together
|
||||||
|
/// with the lines it stops on, so it is cut after every other part and before those lines
|
||||||
|
/// (higher rounds first), keeping the remnant's mass on the sheet until the end.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyDictionary<Part, int> Separators(IEnumerable<Part> parts, IEnumerable<CutOff> definitions, Box sheet)
|
||||||
|
{
|
||||||
|
var rounds = new Dictionary<Part, int>(ReferenceEqualityComparer.Instance);
|
||||||
|
if (parts == null || definitions == null || sheet == null)
|
||||||
|
return rounds;
|
||||||
|
var byDrawing = new Dictionary<Drawing, CutOff>(ReferenceEqualityComparer.Instance);
|
||||||
|
foreach (var definition in definitions)
|
||||||
|
if (definition?.Drawing != null)
|
||||||
|
byDrawing[definition.Drawing] = definition;
|
||||||
|
var cuts = new List<(Part Part, bool Vertical, double Line, double Low, double High)>();
|
||||||
|
foreach (var part in parts)
|
||||||
|
if (part?.BaseDrawing is { IsCutOff: true } drawing && byDrawing.TryGetValue(drawing, out var definition)
|
||||||
|
&& ContinuousCut(part, definition, sheet) is { } cut)
|
||||||
|
{
|
||||||
|
var vertical = definition.Axis == CutOffAxis.Vertical;
|
||||||
|
cuts.Add((part, vertical, vertical ? definition.Position.X : definition.Position.Y, cut.Low, cut.High));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var round = 0; ; round++)
|
||||||
|
{
|
||||||
|
var found = cuts.Where(cut => !rounds.ContainsKey(cut.Part) && Bounded(cut)).ToList();
|
||||||
|
if (found.Count == 0)
|
||||||
|
return rounds;
|
||||||
|
foreach (var cut in found)
|
||||||
|
rounds[cut.Part] = round;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cut runs between two boundaries: sheet edges, or lines found in an earlier round
|
||||||
|
// that cross this one where they are cut. The stretch between them is cut, as the cut
|
||||||
|
// is continuous.
|
||||||
|
bool Bounded((Part Part, bool Vertical, double Line, double Low, double High) cut)
|
||||||
|
{
|
||||||
|
var reached = cuts.Where(other => other.Vertical != cut.Vertical && rounds.ContainsKey(other.Part)
|
||||||
|
&& other.Low <= cut.Line + Math.Tolerance.Epsilon && other.High >= cut.Line - Math.Tolerance.Epsilon)
|
||||||
|
.Select(other => other.Line)
|
||||||
|
.Append(cut.Vertical ? sheet.Bottom : sheet.Left)
|
||||||
|
.Append(cut.Vertical ? sheet.Top : sheet.Right)
|
||||||
|
.Where(b => b >= cut.Low - Math.Tolerance.Epsilon && b <= cut.High + Math.Tolerance.Epsilon)
|
||||||
|
.ToList();
|
||||||
|
return reached.Count > 1 && reached.Max() - reached.Min() > Math.Tolerance.Epsilon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The cut-off parts on <paramref name="plate"/> that split off a piece of it, by round.</summary>
|
||||||
|
public static IReadOnlyDictionary<Part, int> Separators(Plate plate) =>
|
||||||
|
Separators(plate.Parts, plate.CutOffs, plate.BoundingBox(includeParts: false));
|
||||||
|
|
||||||
|
// The extent along the line of a part that cuts one continuous straight line on its
|
||||||
|
// definition's line strictly inside the sheet, or null for anything else.
|
||||||
|
private static (double Low, double High)? ContinuousCut(Part part, CutOff definition, Box sheet)
|
||||||
{
|
{
|
||||||
if (part?.Program == null || definition == null || sheet == null
|
if (part?.Program == null || definition == null || sheet == null
|
||||||
|| part.Program.Mode != Mode.Absolute)
|
|| part.Program.Mode != Mode.Absolute)
|
||||||
return false;
|
return null;
|
||||||
var vertical = definition.Axis == CutOffAxis.Vertical;
|
var vertical = definition.Axis == CutOffAxis.Vertical;
|
||||||
var line = vertical ? definition.Position.X : definition.Position.Y;
|
var line = vertical ? definition.Position.X : definition.Position.Y;
|
||||||
var acrossMin = vertical ? sheet.Left : sheet.Bottom;
|
var acrossMin = vertical ? sheet.Left : sheet.Bottom;
|
||||||
var acrossMax = vertical ? sheet.Right : sheet.Top;
|
var acrossMax = vertical ? sheet.Right : sheet.Top;
|
||||||
var alongMin = vertical ? sheet.Bottom : sheet.Left;
|
|
||||||
var alongMax = vertical ? sheet.Top : sheet.Right;
|
|
||||||
if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon))
|
if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon))
|
||||||
return false;
|
return null;
|
||||||
|
|
||||||
Vector? position = null;
|
Vector? position = null;
|
||||||
double? low = null, high = null;
|
double? low = null, high = null;
|
||||||
@@ -221,13 +368,13 @@ namespace OpenNest
|
|||||||
// A second pierce leaves an uncut gap; a head-locked rapid returns along
|
// A second pierce leaves an uncut gap; a head-locked rapid returns along
|
||||||
// the overcut to where the cut toward the origin begins.
|
// the overcut to where the cut toward the origin begins.
|
||||||
if (cutting && !locked)
|
if (cutting && !locked)
|
||||||
return false;
|
return null;
|
||||||
position = rapid.EndPoint + part.Location;
|
position = rapid.EndPoint + part.Location;
|
||||||
break;
|
break;
|
||||||
case LinearMove move when position != null:
|
case LinearMove move when position != null:
|
||||||
var end = move.EndPoint + part.Location;
|
var end = move.EndPoint + part.Location;
|
||||||
if (!OnLine(position.Value) || !OnLine(end))
|
if (!OnLine(position.Value) || !OnLine(end))
|
||||||
return false;
|
return null;
|
||||||
foreach (var value in new[] { Along(position.Value), Along(end) })
|
foreach (var value in new[] { Along(position.Value), Along(end) })
|
||||||
{
|
{
|
||||||
low = low == null ? value : System.Math.Min(low.Value, value);
|
low = low == null ? value : System.Math.Min(low.Value, value);
|
||||||
@@ -237,10 +384,10 @@ namespace OpenNest
|
|||||||
cutting = true;
|
cutting = true;
|
||||||
break;
|
break;
|
||||||
case Motion:
|
case Motion:
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cutting && low <= alongMin + Math.Tolerance.Epsilon && high >= alongMax - Math.Tolerance.Epsilon;
|
return cutting ? (low.Value, high.Value) : null;
|
||||||
|
|
||||||
bool OnLine(Vector point) =>
|
bool OnLine(Vector point) =>
|
||||||
System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon;
|
System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon;
|
||||||
@@ -262,7 +409,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 +429,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 overtraveled 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)
|
||||||
@@ -313,6 +468,8 @@ namespace OpenNest
|
|||||||
);
|
);
|
||||||
exclusions.AddRange(partExclusions);
|
exclusions.AddRange(partExclusions);
|
||||||
}
|
}
|
||||||
|
foreach (var gap in gaps)
|
||||||
|
exclusions.Add((gap.Start, gap.End));
|
||||||
|
|
||||||
exclusions.Sort((a, b) => a.Start.CompareTo(b.Start));
|
exclusions.Sort((a, b) => a.Start.CompareTo(b.Start));
|
||||||
var merged = new List<(double Start, double End)>();
|
var merged = new List<(double Start, double End)>();
|
||||||
@@ -325,27 +482,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(
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ namespace OpenNest
|
|||||||
/// One placed cutting segment of a cut-off: the part that cuts it, where its cut runs along
|
/// One placed cutting segment of a cut-off: the part that cuts it, where its cut runs along
|
||||||
/// the cut-off's axis, and the span of the nominal line it stands for, from the end of the
|
/// the cut-off's axis, and the span of the nominal line it stands for, from the end of the
|
||||||
/// segment before it (or the line's start) to the start of the one after it (or the line's
|
/// segment before it (or the line's start) to the start of the one after it (or the line's
|
||||||
/// end). The parts the nominal line passes through lie in the gaps between segments, so each
|
/// end), stopping short of a <see cref="CutOff.Gaps">gap</see> between them. The parts the
|
||||||
/// is inside the span of the segment on either side of it; a segment is cut before exactly
|
/// nominal line passes through lie in the gaps between segments, so each is inside the span
|
||||||
/// those parts.
|
/// of the segment on either side of it; a segment is cut before exactly those parts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd);
|
public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd);
|
||||||
|
|
||||||
@@ -55,6 +55,14 @@ namespace OpenNest
|
|||||||
var (part, start, end) = measured[i];
|
var (part, start, end) = measured[i];
|
||||||
var spanStart = i == 0 ? System.Math.Min(low, start) : measured[i - 1].End;
|
var spanStart = i == 0 ? System.Math.Min(low, start) : measured[i - 1].End;
|
||||||
var spanEnd = i == measured.Count - 1 ? System.Math.Max(high, end) : measured[i + 1].Start;
|
var spanEnd = i == measured.Count - 1 ? System.Math.Max(high, end) : measured[i + 1].Start;
|
||||||
|
// A gap between two segments belongs to neither: each span stops at its edge.
|
||||||
|
foreach (var gap in definition.Gaps)
|
||||||
|
{
|
||||||
|
if (i > 0 && gap.End <= start + Math.Tolerance.Epsilon && gap.End > spanStart)
|
||||||
|
spanStart = gap.End;
|
||||||
|
if (i < measured.Count - 1 && gap.Start >= end - Math.Tolerance.Epsilon && gap.Start < spanEnd)
|
||||||
|
spanEnd = gap.Start;
|
||||||
|
}
|
||||||
result.Add(new CutOffSegment(part, start, end, spanStart, spanEnd));
|
result.Add(new CutOffSegment(part, start, end, spanStart, spanEnd));
|
||||||
}
|
}
|
||||||
result.AddRange(whole);
|
result.AddRange(whole);
|
||||||
|
|||||||
@@ -25,5 +25,7 @@ namespace OpenNest
|
|||||||
|
|
||||||
/// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary>
|
/// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary>
|
||||||
public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0;
|
public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0;
|
||||||
|
|
||||||
|
public CutOffSettings Copy() => (CutOffSettings)MemberwiseClone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The limits and gaps one cut-off takes when a <see cref="CutOffEdit"/> is applied; null
|
||||||
|
/// <paramref name="Gaps"/> keeps the cut-off's gaps.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CutOffLimitChange(CutOff CutOff, double? StartLimit, double? EndLimit,
|
||||||
|
IReadOnlyList<CutOffGap> Gaps = null)
|
||||||
|
{
|
||||||
|
/// <summary>The gaps the cut-off has once the change is applied.</summary>
|
||||||
|
public IReadOnlyList<CutOffGap> NewGaps => Gaps ?? CutOff.Gaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A worked-out trim, extend or swap: the new limits and gaps of each cut-off it changes, or
|
||||||
|
/// why it was refused. Nothing changes until <see cref="Apply"/>; the caller then regenerates
|
||||||
|
/// the plate's cut-offs and places their segments in the cutting order.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CutOffEdit
|
||||||
|
{
|
||||||
|
private CutOffEdit(IReadOnlyList<CutOffLimitChange> changes, string refusal, string notice)
|
||||||
|
{
|
||||||
|
Changes = changes;
|
||||||
|
Refusal = refusal;
|
||||||
|
Notice = notice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<CutOffLimitChange> Changes { get; }
|
||||||
|
|
||||||
|
/// <summary>Why the edit cannot be made; null when it can.</summary>
|
||||||
|
public string Refusal { get; }
|
||||||
|
|
||||||
|
/// <summary>Something the operator should know once it is made, such as a head-locked overcut turning off.</summary>
|
||||||
|
public string Notice { get; }
|
||||||
|
|
||||||
|
public bool Succeeded => Refusal == null;
|
||||||
|
|
||||||
|
internal static CutOffEdit Refused(string reason) => new(System.Array.Empty<CutOffLimitChange>(), reason, null);
|
||||||
|
|
||||||
|
internal static CutOffEdit Of(IReadOnlyList<CutOffLimitChange> changes, string notice) => new(changes, null, notice);
|
||||||
|
|
||||||
|
public void Apply()
|
||||||
|
{
|
||||||
|
if (!Succeeded)
|
||||||
|
throw new System.InvalidOperationException(Refusal);
|
||||||
|
foreach (var change in Changes)
|
||||||
|
{
|
||||||
|
change.CutOff.StartLimit = change.StartLimit;
|
||||||
|
change.CutOff.EndLimit = change.EndLimit;
|
||||||
|
if (change.Gaps != null)
|
||||||
|
change.CutOff.Gaps = change.Gaps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The perpendicular lines that meet a cut-off as a drag of it starts: their limits or gap
|
||||||
|
/// edges sit on its center, where a trim or extend left them. <see cref="MoveTo"/> takes those
|
||||||
|
/// stops along, so each line is extended or trimmed to keep meeting it. Every move works from
|
||||||
|
/// the lines as they were at the start, so a stop held back partway is restored on the way
|
||||||
|
/// back. A stop never passes the next one on its line: it stays a minimum segment short of it,
|
||||||
|
/// or of the sheet edge.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CutOffJoints
|
||||||
|
{
|
||||||
|
private readonly List<(CutOff CutOff, double? StartLimit, double? EndLimit, IReadOnlyList<CutOffGap> Gaps)> joined;
|
||||||
|
private readonly double from;
|
||||||
|
private readonly double sheetLow;
|
||||||
|
private readonly double sheetHigh;
|
||||||
|
private readonly double minimum;
|
||||||
|
|
||||||
|
internal CutOffJoints(IEnumerable<CutOff> lines, double from, double sheetLow, double sheetHigh, double minimum)
|
||||||
|
{
|
||||||
|
joined = lines.Select(c => (c, c.StartLimit, c.EndLimit, c.Gaps)).ToList();
|
||||||
|
this.from = from;
|
||||||
|
this.sheetLow = sheetLow;
|
||||||
|
this.sheetHigh = sheetHigh;
|
||||||
|
this.minimum = minimum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The lines that follow the cut-off.</summary>
|
||||||
|
public IReadOnlyList<CutOff> Lines => joined.Select(j => j.CutOff).ToList();
|
||||||
|
|
||||||
|
/// <summary>Moves the joined stops to <paramref name="line"/>, the cut-off's new center.</summary>
|
||||||
|
public void MoveTo(double line)
|
||||||
|
{
|
||||||
|
foreach (var (cutOff, startLimit, endLimit, gaps) in joined)
|
||||||
|
{
|
||||||
|
var stops = gaps.SelectMany(g => new[] { g.Start, g.End })
|
||||||
|
.Append(startLimit ?? sheetLow)
|
||||||
|
.Append(endLimit ?? sheetHigh)
|
||||||
|
.ToList();
|
||||||
|
var below = stops.Where(s => s < from - Tolerance.Epsilon).DefaultIfEmpty(sheetLow).Max();
|
||||||
|
var above = stops.Where(s => s > from + Tolerance.Epsilon).DefaultIfEmpty(sheetHigh).Min();
|
||||||
|
var at = System.Math.Clamp(line,
|
||||||
|
System.Math.Min(below + minimum, from), System.Math.Max(above - minimum, from));
|
||||||
|
|
||||||
|
cutOff.StartLimit = startLimit is double start ? Follow(start, at) : null;
|
||||||
|
cutOff.EndLimit = endLimit is double end ? Follow(end, at) : null;
|
||||||
|
cutOff.Gaps = gaps.Select(g => new CutOffGap(Follow(g.Start, at), Follow(g.End, at))).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double Follow(double stop, double at) =>
|
||||||
|
System.Math.Abs(stop - from) <= Tolerance.Epsilon ? at : stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trims and extends cut-offs against the cut-offs that cross them, by changing only their
|
||||||
|
/// start and end limits and their gaps (see docs/automatic-scrap-cutoffs.md, "Trim and
|
||||||
|
/// extend"). A trim stops on the crossing line's center, inside its kerf; trimming a piece
|
||||||
|
/// between two crossing lines leaves a gap from one center to the other. An extend closes the
|
||||||
|
/// gap at the picked end, or runs the line's end to the next crossing line beyond it, or to
|
||||||
|
/// the sheet edge, which clears the limit.
|
||||||
|
/// </summary>
|
||||||
|
public static class CutOffTrim
|
||||||
|
{
|
||||||
|
public const string NotOnPlate = "That cut-off is not on this plate.";
|
||||||
|
public const string NothingToTrimTo = "No cut-off crosses this line to trim it to; delete it instead.";
|
||||||
|
public const string InGap = "That stretch of the line is already uncut.";
|
||||||
|
public const string TooShort = "Trimming here would leave a line shorter than the minimum segment length.";
|
||||||
|
public const string AtSheetEdge = "This end already runs to the sheet edge.";
|
||||||
|
public const string NotAJunction = "These cut-offs do not meet with one running through and the other stopping at it.";
|
||||||
|
public const string GapAtJunction = "A gap in one of these lines meets the junction; extend to close it before swapping.";
|
||||||
|
public const string OvercutOff = "The head-locked overcut is off while this line stops short of the far edge.";
|
||||||
|
public const string OvercutOn = "The head-locked overcut is back on at the far edge.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the piece of <paramref name="target"/> picked at <paramref name="pick"/>, between
|
||||||
|
/// the crossing cut-offs on either side of it, or a gap or the line's end where there is no
|
||||||
|
/// crossing on that side. An end piece moves that end's limit; a piece between two crossing
|
||||||
|
/// lines becomes a gap, merged with any gap it touches.
|
||||||
|
/// </summary>
|
||||||
|
public static CutOffEdit Trim(Plate plate, CutOff target, Vector pick, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
if (!plate.CutOffs.Contains(target))
|
||||||
|
return CutOffEdit.Refused(NotOnPlate);
|
||||||
|
var (start, end) = NominalSpan(plate, target);
|
||||||
|
var at = System.Math.Clamp(Along(target, pick), start, end);
|
||||||
|
var pieces = target.Pieces(start, end);
|
||||||
|
var piece = pieces.Where(p => at >= p.Start - Tolerance.Epsilon && at <= p.End + Tolerance.Epsilon)
|
||||||
|
.Cast<(double Start, double End)?>().FirstOrDefault();
|
||||||
|
if (piece is not var (pieceStart, pieceEnd))
|
||||||
|
return CutOffEdit.Refused(InGap);
|
||||||
|
|
||||||
|
var crossings = Crossings(plate, target)
|
||||||
|
.Where(c => c > pieceStart + Tolerance.Epsilon && c < pieceEnd - Tolerance.Epsilon)
|
||||||
|
.ToList();
|
||||||
|
var low = crossings.Where(c => c <= at).DefaultIfEmpty(pieceStart).Max();
|
||||||
|
var high = crossings.Where(c => c > at).DefaultIfEmpty(pieceEnd).Min();
|
||||||
|
if (pieces.Count == 1 && low == start && high == end)
|
||||||
|
return CutOffEdit.Refused(NothingToTrimTo);
|
||||||
|
|
||||||
|
var remaining = new List<(double Start, double End)>();
|
||||||
|
foreach (var (from, to) in pieces)
|
||||||
|
{
|
||||||
|
if (from < low)
|
||||||
|
remaining.Add((from, System.Math.Min(to, low)));
|
||||||
|
if (to > high)
|
||||||
|
remaining.Add((System.Math.Max(from, high), to));
|
||||||
|
}
|
||||||
|
var minimum = System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon);
|
||||||
|
if (remaining.Count == 0
|
||||||
|
|| remaining.Any(p => (p.End == low || p.Start == high) && p.End - p.Start < minimum))
|
||||||
|
return CutOffEdit.Refused(TooShort);
|
||||||
|
return Single(plate, Change(target, start, end, remaining));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lengthens <paramref name="target"/> at the end of the piece nearer <paramref name="pick"/>:
|
||||||
|
/// a gap there closes; the line's own end runs to the next crossing cut-off beyond it, or to
|
||||||
|
/// the sheet edge when there is none. A cut-off sitting at the current end is skipped, so
|
||||||
|
/// picking again extends one more step. A pick inside a gap closes that gap.
|
||||||
|
/// </summary>
|
||||||
|
public static CutOffEdit Extend(Plate plate, CutOff target, Vector pick)
|
||||||
|
{
|
||||||
|
if (!plate.CutOffs.Contains(target))
|
||||||
|
return CutOffEdit.Refused(NotOnPlate);
|
||||||
|
var (start, end) = NominalSpan(plate, target);
|
||||||
|
var at = Along(target, pick);
|
||||||
|
var gaps = target.Gaps;
|
||||||
|
var inside = gaps.Where(g => at > g.Start && at < g.End).ToList();
|
||||||
|
if (inside.Count == 0)
|
||||||
|
{
|
||||||
|
var pieces = target.Pieces(start, end);
|
||||||
|
var piece = pieces.OrderBy(p => Distance(at, p)).First();
|
||||||
|
var high = System.Math.Abs(at - piece.End) <= System.Math.Abs(at - piece.Start);
|
||||||
|
var edge = high ? piece.End : piece.Start;
|
||||||
|
inside = gaps.Where(g => System.Math.Abs((high ? g.Start : g.End) - edge) <= Tolerance.Epsilon).ToList();
|
||||||
|
if (inside.Count == 0)
|
||||||
|
{
|
||||||
|
return ExtendEnd(plate, target, high, out var limit)
|
||||||
|
? Single(plate, new CutOffLimitChange(target,
|
||||||
|
high ? target.StartLimit : limit, high ? limit : target.EndLimit))
|
||||||
|
: CutOffEdit.Refused(AtSheetEdge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Single(plate, new CutOffLimitChange(target, target.StartLimit, target.EndLimit,
|
||||||
|
gaps.Except(inside).ToList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops a new <paramref name="target"/> at the first crossing cut-off from its start edge,
|
||||||
|
/// the origin side unless <paramref name="settings"/> cut toward the origin. It stays full
|
||||||
|
/// length, with no changes, when nothing crosses it or the line left would be shorter than
|
||||||
|
/// the minimum segment length. <paramref name="target"/> need not be on the plate yet.
|
||||||
|
/// </summary>
|
||||||
|
public static CutOffEdit StopAtFirstCrossing(Plate plate, CutOff target, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
var (start, end) = NominalSpan(plate, target);
|
||||||
|
var crossings = Crossings(plate, target)
|
||||||
|
.Where(c => c > start + Tolerance.Epsilon && c < end - Tolerance.Epsilon)
|
||||||
|
.ToList();
|
||||||
|
if (crossings.Count == 0)
|
||||||
|
return CutOffEdit.Of(System.Array.Empty<CutOffLimitChange>(), null);
|
||||||
|
|
||||||
|
var towardOrigin = settings?.CutDirection == CutDirection.TowardOrigin;
|
||||||
|
var startsHigh = towardOrigin == CutOff.FarEdgeIsHigh(target.Axis, plate.Quadrant);
|
||||||
|
var stop = startsHigh ? crossings.Max() : crossings.Min();
|
||||||
|
var remaining = startsHigh ? end - stop : stop - start;
|
||||||
|
if (remaining < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
|
||||||
|
return CutOffEdit.Of(System.Array.Empty<CutOffLimitChange>(), null);
|
||||||
|
return startsHigh
|
||||||
|
? Single(plate, new CutOffLimitChange(target, stop, target.EndLimit))
|
||||||
|
: Single(plate, new CutOffLimitChange(target, target.StartLimit, stop));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The lines that meet <paramref name="moved"/> and follow it when it moves: perpendicular
|
||||||
|
/// cut-offs with a limit or gap edge on its center, where its own cut pieces reach them. A
|
||||||
|
/// line that only ends level with it, past its end or in one of its gaps, stays put.
|
||||||
|
/// </summary>
|
||||||
|
public static CutOffJoints Joints(Plate plate, CutOff moved, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
var at = Line(moved);
|
||||||
|
var (movedStart, movedEnd) = NominalSpan(plate, moved);
|
||||||
|
var pieces = moved.Pieces(movedStart, movedEnd);
|
||||||
|
var lines = plate.CutOffs
|
||||||
|
.Where(other => other != moved && other.Axis != moved.Axis)
|
||||||
|
.Where(other => pieces.Any(p => Within(Line(other), p.Start, p.End)))
|
||||||
|
.Where(other => Stops(other).Any(stop => System.Math.Abs(stop - at) <= Tolerance.Epsilon));
|
||||||
|
|
||||||
|
var sheet = plate.BoundingBox(includeParts: false);
|
||||||
|
var horizontal = moved.Axis == CutOffAxis.Vertical;
|
||||||
|
return new CutOffJoints(lines, at, horizontal ? sheet.Left : sheet.Bottom,
|
||||||
|
horizontal ? sheet.Right : sheet.Top,
|
||||||
|
System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the cut-off's own limits and gaps stop it, not counting the sheet edges.
|
||||||
|
private static IEnumerable<double> Stops(CutOff cutOff)
|
||||||
|
{
|
||||||
|
if (cutOff.StartLimit is double start)
|
||||||
|
yield return start;
|
||||||
|
if (cutOff.EndLimit is double end)
|
||||||
|
yield return end;
|
||||||
|
foreach (var gap in cutOff.Gaps)
|
||||||
|
{
|
||||||
|
yield return gap.Start;
|
||||||
|
yield return gap.End;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Swaps which of two meeting cut-offs runs through: the one that stopped at the junction
|
||||||
|
/// extends past it (as <see cref="Extend"/> does), and the one that ran through is trimmed
|
||||||
|
/// there, losing the side away from the plate's origin and any gaps on it. Only the
|
||||||
|
/// origin-side sheet edge is certain, so the trimmed line keeps starting there. Refused when
|
||||||
|
/// a gap in either line meets the junction.
|
||||||
|
/// </summary>
|
||||||
|
public static CutOffEdit Swap(Plate plate, CutOff first, CutOff second, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
if (!plate.CutOffs.Contains(first) || !plate.CutOffs.Contains(second))
|
||||||
|
return CutOffEdit.Refused(NotOnPlate);
|
||||||
|
if (first != second && first.Axis != second.Axis
|
||||||
|
&& (TouchesGap(first, Line(second)) || TouchesGap(second, Line(first))))
|
||||||
|
return CutOffEdit.Refused(GapAtJunction);
|
||||||
|
if (Junction(plate, first, second) is not var (through, stopper, point))
|
||||||
|
return CutOffEdit.Refused(NotAJunction);
|
||||||
|
|
||||||
|
var (start, end) = NominalSpan(plate, through);
|
||||||
|
var farHigh = CutOff.FarEdgeIsHigh(through.Axis, plate.Quadrant);
|
||||||
|
var kept = through.Pieces(start, end)
|
||||||
|
.Select(p => farHigh ? (p.Start, System.Math.Min(p.End, point)) : (System.Math.Max(p.Start, point), p.End))
|
||||||
|
.Where(p => p.Item2 > p.Item1)
|
||||||
|
.ToList();
|
||||||
|
var adjacent = kept.FirstOrDefault(p => (farHigh ? p.Item2 : p.Item1) == point);
|
||||||
|
if (kept.Count == 0
|
||||||
|
|| adjacent.Item2 - adjacent.Item1 < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
|
||||||
|
return CutOffEdit.Refused(TooShort);
|
||||||
|
var throughChange = farHigh
|
||||||
|
? new CutOffLimitChange(through, through.StartLimit, point, GapsBetween(kept))
|
||||||
|
: new CutOffLimitChange(through, point, through.EndLimit, GapsBetween(kept));
|
||||||
|
|
||||||
|
var (stopStart, stopEnd) = NominalSpan(plate, stopper);
|
||||||
|
var stopperPoint = Line(through);
|
||||||
|
var high = System.Math.Abs(stopperPoint - stopEnd) <= System.Math.Abs(stopperPoint - stopStart);
|
||||||
|
if (!ExtendEnd(plate, stopper, high, out var limit))
|
||||||
|
return CutOffEdit.Refused(NotAJunction);
|
||||||
|
var stopperChange = high
|
||||||
|
? new CutOffLimitChange(stopper, stopper.StartLimit, limit)
|
||||||
|
: new CutOffLimitChange(stopper, limit, stopper.EndLimit);
|
||||||
|
|
||||||
|
var notices = new[] { Notice(plate, throughChange), Notice(plate, stopperChange) }
|
||||||
|
.Where(n => n != null).Distinct().ToList();
|
||||||
|
return CutOffEdit.Of(new[] { stopperChange, throughChange },
|
||||||
|
notices.Count == 0 ? null : string.Join(" ", notices));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The two cut-offs meeting within <paramref name="tolerance"/> of <paramref name="point"/>,
|
||||||
|
/// one running through the junction and the other stopping at it; null when there is none.
|
||||||
|
/// </summary>
|
||||||
|
public static (CutOff Through, CutOff Stopper)? JunctionAt(Plate plate, Vector point, double tolerance)
|
||||||
|
{
|
||||||
|
if (plate?.CutOffs == null)
|
||||||
|
return null;
|
||||||
|
(CutOff, CutOff)? best = null;
|
||||||
|
var bestDistance = double.MaxValue;
|
||||||
|
foreach (var vertical in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical))
|
||||||
|
foreach (var horizontal in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Horizontal))
|
||||||
|
{
|
||||||
|
if (Junction(plate, vertical, horizontal) is not var (through, stopper, _))
|
||||||
|
continue;
|
||||||
|
var distance = new Vector(vertical.Position.X, horizontal.Position.Y).DistanceTo(point);
|
||||||
|
if (distance <= tolerance && distance < bestDistance)
|
||||||
|
{
|
||||||
|
best = (through, stopper);
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where <paramref name="cutOff"/>'s line runs along its axis: its limits, or the sheet
|
||||||
|
/// edges where it has none. Overtravel is not part of the nominal line, and neither does it
|
||||||
|
/// take out the gaps (see <see cref="CutOff.Pieces(double, double)"/>).
|
||||||
|
/// </summary>
|
||||||
|
public static (double Start, double End) NominalSpan(Plate plate, CutOff cutOff)
|
||||||
|
{
|
||||||
|
var sheet = plate.BoundingBox(includeParts: false);
|
||||||
|
var vertical = cutOff.Axis == CutOffAxis.Vertical;
|
||||||
|
return (cutOff.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
|
||||||
|
cutOff.EndLimit ?? (vertical ? sheet.Top : sheet.Right));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The stretches of the nominal line a change removes and adds, along the axis, in ascending
|
||||||
|
/// order: what a preview marks.
|
||||||
|
/// </summary>
|
||||||
|
public static (IReadOnlyList<(double Start, double End)> Removed, IReadOnlyList<(double Start, double End)> Added)
|
||||||
|
Difference(Plate plate, CutOffLimitChange change)
|
||||||
|
{
|
||||||
|
var cutOff = change.CutOff;
|
||||||
|
var sheet = plate.BoundingBox(includeParts: false);
|
||||||
|
var vertical = cutOff.Axis == CutOffAxis.Vertical;
|
||||||
|
var before = cutOff.Pieces(NominalSpan(plate, cutOff).Start, NominalSpan(plate, cutOff).End);
|
||||||
|
var after = CutOff.Pieces(change.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
|
||||||
|
change.EndLimit ?? (vertical ? sheet.Top : sheet.Right), change.NewGaps);
|
||||||
|
return (Subtract(before, after), Subtract(after, before));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<(double Start, double End)> Subtract(
|
||||||
|
IReadOnlyList<(double Start, double End)> from, IReadOnlyList<(double Start, double End)> remove)
|
||||||
|
{
|
||||||
|
var result = new List<(double Start, double End)>();
|
||||||
|
foreach (var piece in from)
|
||||||
|
{
|
||||||
|
var current = piece.Start;
|
||||||
|
foreach (var (start, end) in remove.Where(r => r.End > piece.Start && r.Start < piece.End))
|
||||||
|
{
|
||||||
|
if (start > current + Tolerance.Epsilon)
|
||||||
|
result.Add((current, start));
|
||||||
|
current = System.Math.Max(current, end);
|
||||||
|
}
|
||||||
|
if (piece.End > current + Tolerance.Epsilon)
|
||||||
|
result.Add((current, piece.End));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (CutOff Through, CutOff Stopper, double Point)? Junction(Plate plate, CutOff first, CutOff second)
|
||||||
|
{
|
||||||
|
if (first == second || first.Axis == second.Axis)
|
||||||
|
return null;
|
||||||
|
var firstAt = Line(second);
|
||||||
|
var secondAt = Line(first);
|
||||||
|
if (TouchesGap(first, firstAt) || TouchesGap(second, secondAt))
|
||||||
|
return null;
|
||||||
|
var (firstStart, firstEnd) = NominalSpan(plate, first);
|
||||||
|
var (secondStart, secondEnd) = NominalSpan(plate, second);
|
||||||
|
if (!Within(firstAt, firstStart, firstEnd) || !Within(secondAt, secondStart, secondEnd))
|
||||||
|
return null;
|
||||||
|
var firstStops = AtEnd(firstAt, firstStart, firstEnd);
|
||||||
|
var secondStops = AtEnd(secondAt, secondStart, secondEnd);
|
||||||
|
if (firstStops == secondStops)
|
||||||
|
return null;
|
||||||
|
return firstStops ? (second, first, secondAt) : (first, second, firstAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether one of the cut-off's gaps contains or ends at the value along its axis.
|
||||||
|
private static bool TouchesGap(CutOff cutOff, double along) =>
|
||||||
|
cutOff.Gaps.Any(g => Within(along, g.Start, g.End));
|
||||||
|
|
||||||
|
// The limit an end takes when extended: the next crossing line beyond it, or null for the
|
||||||
|
// sheet edge. False when that end already runs to the sheet edge.
|
||||||
|
private static bool ExtendEnd(Plate plate, CutOff target, bool high, out double? limit)
|
||||||
|
{
|
||||||
|
limit = null;
|
||||||
|
if ((high ? target.EndLimit : target.StartLimit) == null)
|
||||||
|
return false;
|
||||||
|
var (start, end) = NominalSpan(plate, target);
|
||||||
|
var crossings = Crossings(plate, target);
|
||||||
|
if (high)
|
||||||
|
{
|
||||||
|
var next = crossings.Where(c => c > end + Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Min();
|
||||||
|
limit = double.IsNaN(next) ? null : next;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var next = crossings.Where(c => c < start - Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Max();
|
||||||
|
limit = double.IsNaN(next) ? null : next;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the perpendicular cut-offs whose cut pieces reach this one cross it, along its axis.
|
||||||
|
private static List<double> Crossings(Plate plate, CutOff target)
|
||||||
|
{
|
||||||
|
var line = Line(target);
|
||||||
|
return plate.CutOffs
|
||||||
|
.Where(other => other != target && other.Axis != target.Axis)
|
||||||
|
.Where(other =>
|
||||||
|
{
|
||||||
|
var (start, end) = NominalSpan(plate, other);
|
||||||
|
return other.Pieces(start, end).Any(p => Within(line, p.Start, p.End));
|
||||||
|
})
|
||||||
|
.Select(Line)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The change that leaves exactly the given pieces of the nominal line from start to end: a
|
||||||
|
// limit stays null while its end stays at the sheet edge.
|
||||||
|
private static CutOffLimitChange Change(CutOff target, double start, double end,
|
||||||
|
IReadOnlyList<(double Start, double End)> pieces)
|
||||||
|
{
|
||||||
|
var first = pieces[0].Start;
|
||||||
|
var last = pieces[^1].End;
|
||||||
|
return new CutOffLimitChange(target,
|
||||||
|
target.StartLimit == null && first == start ? null : first,
|
||||||
|
target.EndLimit == null && last == end ? null : last,
|
||||||
|
GapsBetween(pieces));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CutOffGap> GapsBetween(IReadOnlyList<(double Start, double End)> pieces)
|
||||||
|
{
|
||||||
|
var gaps = new List<CutOffGap>();
|
||||||
|
for (var i = 1; i < pieces.Count; i++)
|
||||||
|
if (pieces[i].Start > pieces[i - 1].End)
|
||||||
|
gaps.Add(new CutOffGap(pieces[i - 1].End, pieces[i].Start));
|
||||||
|
return gaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CutOffEdit Single(Plate plate, CutOffLimitChange change) =>
|
||||||
|
CutOffEdit.Of(new[] { change }, Notice(plate, change));
|
||||||
|
|
||||||
|
private static string Notice(Plate plate, CutOffLimitChange change)
|
||||||
|
{
|
||||||
|
var cutOff = change.CutOff;
|
||||||
|
if (!(cutOff.HeadLockOvercut > 0))
|
||||||
|
return null;
|
||||||
|
var high = CutOff.FarEdgeIsHigh(cutOff.Axis, plate.Quadrant);
|
||||||
|
var before = (high ? cutOff.EndLimit : cutOff.StartLimit) == null;
|
||||||
|
var after = (high ? change.EndLimit : change.StartLimit) == null;
|
||||||
|
return before == after ? null : after ? OvercutOn : OvercutOff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cut-off's own coordinate across its axis: X for a vertical line, Y for a horizontal one.
|
||||||
|
private static double Line(CutOff cutOff) =>
|
||||||
|
cutOff.Axis == CutOffAxis.Vertical ? cutOff.Position.X : cutOff.Position.Y;
|
||||||
|
|
||||||
|
private static double Along(CutOff cutOff, Vector point) =>
|
||||||
|
cutOff.Axis == CutOffAxis.Vertical ? point.Y : point.X;
|
||||||
|
|
||||||
|
private static double Distance(double value, (double Start, double End) piece) =>
|
||||||
|
value < piece.Start ? piece.Start - value : value > piece.End ? value - piece.End : 0;
|
||||||
|
|
||||||
|
private static bool Within(double value, double start, double end) =>
|
||||||
|
value >= start - Tolerance.Epsilon && value <= end + Tolerance.Epsilon;
|
||||||
|
|
||||||
|
private static bool AtEnd(double value, double start, double end) =>
|
||||||
|
System.Math.Abs(value - start) <= Tolerance.Epsilon || System.Math.Abs(value - end) <= Tolerance.Epsilon;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,15 +33,21 @@ public static class ClampCutDiagnostics
|
|||||||
var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
|
var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
|
||||||
var low = farHigh ? 0 : overcut;
|
var low = farHigh ? 0 : overcut;
|
||||||
var high = farHigh ? overcut : 0;
|
var high = farHigh ? overcut : 0;
|
||||||
var start = cut.Axis == CutOffAxis.Vertical
|
var vertical = cut.Axis == CutOffAxis.Vertical;
|
||||||
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom - low)
|
var from = cut.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left) - low;
|
||||||
: new Vector(cut.StartLimit ?? sheet.Left - low, cut.Position.Y);
|
var to = cut.EndLimit ?? (vertical ? sheet.Top : sheet.Right) + high;
|
||||||
var end = cut.Axis == CutOffAxis.Vertical
|
// A clamp wholly inside a gap meets no cut.
|
||||||
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top + high)
|
|
||||||
: new Vector(cut.EndLimit ?? sheet.Right + high, cut.Position.Y);
|
|
||||||
foreach (var zone in zones)
|
foreach (var zone in zones)
|
||||||
if (Intersects(start, end, zone.Bounds, out var location))
|
foreach (var (pieceStart, pieceEnd) in cut.Pieces(from, to))
|
||||||
findings.Add(new CutOffClampFinding(index, zone.Id, location));
|
{
|
||||||
|
var start = vertical ? new Vector(cut.Position.X, pieceStart) : new Vector(pieceStart, cut.Position.Y);
|
||||||
|
var end = vertical ? new Vector(cut.Position.X, pieceEnd) : new Vector(pieceEnd, cut.Position.Y);
|
||||||
|
if (Intersects(start, end, zone.Bounds, out var location))
|
||||||
|
{
|
||||||
|
findings.Add(new CutOffClampFinding(index, zone.Id, location));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return findings.AsReadOnly();
|
return findings.AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-post review of a plate's common-line groups (<see cref="Plate.CommonLines"/>): every member
|
||||||
|
/// still shares a span one kerf from another member, the group's kerf is still the plate's, and no
|
||||||
|
/// member's lead or shared cut runs into a partner one kerf away (a lead on a shared edge would cut
|
||||||
|
/// the partner). A plate that cuts its common lines once must cut each shared span exactly once, by
|
||||||
|
/// the member cut first (its own compensated edge, or a centerline cut in older programs); one set
|
||||||
|
/// to cut them twice must have no single-pass member programs.
|
||||||
|
/// Reports only; nothing is repaired, and an unreadable lead check is incomplete, never clear.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CommonLineReview
|
||||||
|
{
|
||||||
|
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
if (plate.CommonLines is not { Count: > 0 } groups)
|
||||||
|
return;
|
||||||
|
var plateKerf = plate.CuttingParameters?.Kerf is double k && double.IsFinite(k) && k > 0 ? k : (double?)null;
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var first = Number(plate, group.Members[0]);
|
||||||
|
if (plateKerf is { } kerf && System.Math.Abs(kerf - group.Kerf) > CommonLineGeometry.GapTolerance)
|
||||||
|
findings.Add(new(PostVerificationKind.CommonLine, plateNumber, first, null,
|
||||||
|
$"Common line was placed for a {group.Kerf:0.####} kerf, but the plate now cuts with a "
|
||||||
|
+ $"{kerf:0.####} kerf. Rejoin the parts at the current kerf before posting."));
|
||||||
|
var check = CommonLineGeometry.Check(group);
|
||||||
|
foreach (var problem in check.Problems)
|
||||||
|
findings.Add(new(PostVerificationKind.CommonLine, plateNumber, Number(plate, problem.Part), null,
|
||||||
|
$"'{problem.Part.BaseDrawing.Name}' {problem.Message}")
|
||||||
|
{ Location = problem.Part.BoundingBox?.Center });
|
||||||
|
Leads(plate, plateNumber, group, findings, token);
|
||||||
|
if (check.IsIntact)
|
||||||
|
SharedCuts(plate, plateNumber, group, check, findings, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shared cuts the members' programs make, against the spans the group derives now.
|
||||||
|
private static void SharedCuts(Plate plate, int plateNumber, CommonLineGroup group, CommonLineCheck check,
|
||||||
|
List<PostVerificationFinding> findings, CancellationToken token)
|
||||||
|
{
|
||||||
|
var cuts = new List<(Part Part, ExecutionMotion Motion)>();
|
||||||
|
Part singlePass = null;
|
||||||
|
foreach (var member in group.Members)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (singlePass == null && CommonLineCuts.IsSinglePass(member.Program))
|
||||||
|
singlePass = member;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cuts.AddRange(ExecutionMotionReader.Read(member.Program, member.Location, null, token).Motions
|
||||||
|
.Where(motion => !motion.Rapid && motion.Curve != null
|
||||||
|
&& motion.Layer is LayerType.CommonLine or LayerType.Cut or LayerType.Display)
|
||||||
|
.Select(motion => (member, motion)));
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
|
{
|
||||||
|
// The rapid check reports the unreadable program.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (plate.CutCommonLinesTwice)
|
||||||
|
{
|
||||||
|
if (singlePass != null)
|
||||||
|
findings.Add(new(PostVerificationKind.CommonLine, plateNumber, Number(plate, singlePass), null,
|
||||||
|
"The plate is set to cut its common lines twice, but this part's program cuts a shared line "
|
||||||
|
+ "once. Plan cutting again before posting.")
|
||||||
|
{ Location = singlePass.BoundingBox?.Center });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (var span in check.Spans)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var covering = cuts.Where(cut => Covers(cut.Motion, span, group.Kerf)).ToList();
|
||||||
|
var first = Number(plate, span.First);
|
||||||
|
var second = Number(plate, span.Second);
|
||||||
|
var middle = (span.Start + span.End) * 0.5;
|
||||||
|
if (covering.Count == 0)
|
||||||
|
{
|
||||||
|
findings.Add(new(PostVerificationKind.CommonLine, plateNumber, first, second,
|
||||||
|
"This common line is not cut. Plan cutting cuts each shared line once; "
|
||||||
|
+ "or set the plate to cut its common lines twice.")
|
||||||
|
{ Location = middle });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (covering.Count > 1)
|
||||||
|
{
|
||||||
|
findings.Add(new(PostVerificationKind.CommonLine, plateNumber, first, second,
|
||||||
|
"This common line is cut more than once (a plate not planned since the parts were joined cuts "
|
||||||
|
+ "it twice). Plan cutting again before posting, or set the plate to cut its common lines twice.")
|
||||||
|
{ Location = middle });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The partner's remainder ends in this kerf, so the part that cuts it must come first.
|
||||||
|
var owner = covering[0].Part;
|
||||||
|
var partner = ReferenceEquals(owner, span.First) ? span.Second : span.First;
|
||||||
|
if (Number(plate, owner) is int o && Number(plate, partner) is int p && o > p)
|
||||||
|
findings.Add(new(PostVerificationKind.CommonLine, plateNumber, o, p,
|
||||||
|
"The shared line is cut after the partner whose remainder ends in its kerf. Plan cutting "
|
||||||
|
+ "again, or move the part that cuts it ahead of its partner.")
|
||||||
|
{ Location = middle });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A straight cut covering the whole span: a member's own compensated edge half a kerf off its
|
||||||
|
// centerline, or a centerline cut made without compensation.
|
||||||
|
private static bool Covers(ExecutionMotion motion, CommonLineSpan span, double kerf)
|
||||||
|
{
|
||||||
|
var curve = motion.Curve;
|
||||||
|
if (curve.IsFiniteArc)
|
||||||
|
return false;
|
||||||
|
var length = span.Length;
|
||||||
|
var along = (span.End - span.Start) * (1 / length);
|
||||||
|
double Offset(Vector point) => (point.X - span.Start.X) * along.Y - (point.Y - span.Start.Y) * along.X;
|
||||||
|
double At(Vector point) => (point.X - span.Start.X) * along.X + (point.Y - span.Start.Y) * along.Y;
|
||||||
|
var tolerance = CommonLineGeometry.GapTolerance;
|
||||||
|
var offset = Offset(curve.Start);
|
||||||
|
if (System.Math.Abs(Offset(curve.End) - offset) > tolerance)
|
||||||
|
return false;
|
||||||
|
var expected = motion.Layer == LayerType.CommonLine ? 0 : kerf / 2;
|
||||||
|
if (System.Math.Abs(System.Math.Abs(offset) - expected) > tolerance)
|
||||||
|
return false;
|
||||||
|
var from = System.Math.Min(At(curve.Start), At(curve.End));
|
||||||
|
var to = System.Math.Max(At(curve.Start), At(curve.End));
|
||||||
|
return from <= tolerance && to >= length - tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Leads(Plate plate, int plateNumber, CommonLineGroup group,
|
||||||
|
List<PostVerificationFinding> findings, CancellationToken token)
|
||||||
|
{
|
||||||
|
var members = group.Members;
|
||||||
|
var materials = new LeadMaterialSnapshot[members.Count];
|
||||||
|
for (var i = 0; i < members.Count; i++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
materials[i] = LeadMaterialSnapshot.Capture(FixedLeadReview.Clean(members[i]), members[i].Location, token);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
|
{
|
||||||
|
materials[i] = null;
|
||||||
|
}
|
||||||
|
if (materials[i] is not { IsComplete: true })
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, Number(plate, members[i]), null,
|
||||||
|
"Common-line lead check: " + (materials[i]?.Reason ?? "the clean material could not be captured.")));
|
||||||
|
}
|
||||||
|
for (var i = 0; i < members.Count; i++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (materials[i] is not { IsComplete: true } own)
|
||||||
|
continue;
|
||||||
|
var partners = Enumerable.Range(0, members.Count)
|
||||||
|
.Where(j => j != i && materials[j] is { IsComplete: true }).ToArray();
|
||||||
|
OwnedExecution execution;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
execution = ExecutionMotionReader.Read(members[i].Program, members[i].Location, null, token);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
|
{
|
||||||
|
continue; // The rapid check reports the unreadable program.
|
||||||
|
}
|
||||||
|
var failures = new List<FixedLeadFailure>();
|
||||||
|
var result = LeadPathValidator.Check(execution, own, partners.Select(j => materials[j]).ToArray(),
|
||||||
|
_ => true, failures, token);
|
||||||
|
if (!result.IsComplete)
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, Number(plate, members[i]), null,
|
||||||
|
"Common-line lead check: " + result.Reason));
|
||||||
|
// Only partners matter here: a lead into the part's own material is the lead checks' to report.
|
||||||
|
foreach (var failure in failures.Where(f => f.Obstacle is int))
|
||||||
|
{
|
||||||
|
var partner = members[partners[failure.Obstacle!.Value]];
|
||||||
|
var move = execution.Motions[failure.Motion];
|
||||||
|
findings.Add(new(PostVerificationKind.CommonLine, plateNumber, Number(plate, members[i]),
|
||||||
|
Number(plate, partner), move.Layer == LayerType.CommonLine
|
||||||
|
? $"A shared cut of '{members[i].BaseDrawing.Name}' runs into '{partner.BaseDrawing.Name}', its "
|
||||||
|
+ "common-line partner. Rejoin the parts one kerf apart and plan cutting again."
|
||||||
|
: $"A lead of '{members[i].BaseDrawing.Name}' runs into '{partner.BaseDrawing.Name}', its "
|
||||||
|
+ "common-line partner one kerf away. Move the lead off the shared edge (Plan Cutting does).")
|
||||||
|
{ Location = move.Curve?.Midpoint ?? move.End });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? Number(Plate plate, Part part)
|
||||||
|
{
|
||||||
|
var index = plate.Parts.IndexOf(part);
|
||||||
|
return index < 0 ? null : index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInvalid(Exception exception) => exception is
|
||||||
|
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
|
|
||||||
namespace OpenNest.Diagnostics;
|
namespace OpenNest.Diagnostics;
|
||||||
@@ -12,20 +14,31 @@ public sealed class OverlapGeometryStamp
|
|||||||
private readonly Plate plate;
|
private readonly Plate plate;
|
||||||
private readonly Entry[] entries;
|
private readonly Entry[] entries;
|
||||||
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
|
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
|
||||||
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End, long Overcut)[] cutoffs;
|
// Gaps are replaced, never edited in place, so their reference stands for their content.
|
||||||
|
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End,
|
||||||
|
IReadOnlyList<CutOffGap> Gaps, long Overcut)[] cutoffs;
|
||||||
private readonly (long Width, long Length, int Quadrant) sheet;
|
private readonly (long Width, long Length, int Quadrant) sheet;
|
||||||
|
private readonly long partSpacing;
|
||||||
|
private readonly string commonLines;
|
||||||
|
// The label also checks the sheet edge, so its spacing and outline make a result stale too.
|
||||||
|
private readonly (long Left, long Bottom, long Right, long Top) edgeSpacing;
|
||||||
|
private readonly SheetOutline outline;
|
||||||
|
|
||||||
private OverlapGeometryStamp(Plate plate)
|
private OverlapGeometryStamp(Plate plate)
|
||||||
{
|
{
|
||||||
this.plate = plate;
|
this.plate = plate;
|
||||||
sheet = SheetState(plate);
|
sheet = SheetState(plate);
|
||||||
|
partSpacing = Bits(plate.PartSpacing);
|
||||||
|
commonLines = CommonLineState(plate);
|
||||||
|
edgeSpacing = EdgeState(plate);
|
||||||
|
outline = plate.Outline;
|
||||||
entries = new Entry[plate.Parts.Count];
|
entries = new Entry[plate.Parts.Count];
|
||||||
for (var i = 0; i < entries.Length; i++)
|
for (var i = 0; i < entries.Length; i++)
|
||||||
entries[i] = new Entry(plate.Parts[i]);
|
entries[i] = new Entry(plate.Parts[i]);
|
||||||
zones = new (string, long, long, long, long)[plate.ClampZones.Count];
|
zones = new (string, long, long, long, long)[plate.ClampZones.Count];
|
||||||
for (var i = 0; i < zones.Length; i++)
|
for (var i = 0; i < zones.Length; i++)
|
||||||
zones[i] = ZoneState(plate.ClampZones[i]);
|
zones[i] = ZoneState(plate.ClampZones[i]);
|
||||||
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?, long)[plate.CutOffs.Count];
|
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?, IReadOnlyList<CutOffGap>, long)[plate.CutOffs.Count];
|
||||||
for (var i = 0; i < cutoffs.Length; i++)
|
for (var i = 0; i < cutoffs.Length; i++)
|
||||||
cutoffs[i] = CutOffState(plate.CutOffs[i]);
|
cutoffs[i] = CutOffState(plate.CutOffs[i]);
|
||||||
}
|
}
|
||||||
@@ -36,18 +49,22 @@ public sealed class OverlapGeometryStamp
|
|||||||
zone?.Bounds is not { } box ? default
|
zone?.Bounds is not { } box ? default
|
||||||
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
|
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
|
||||||
|
|
||||||
private static (CutOff, CutOffAxis, long, long, long?, long?, long) CutOffState(CutOff cut) =>
|
private static (CutOff, CutOffAxis, long, long, long?, long?, IReadOnlyList<CutOffGap>, long) CutOffState(CutOff cut) =>
|
||||||
cut == null ? default
|
cut == null ? default
|
||||||
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
|
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
|
||||||
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
|
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
|
||||||
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null,
|
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null,
|
||||||
Bits(cut.HeadLockOvercut));
|
cut.Gaps, Bits(cut.HeadLockOvercut));
|
||||||
|
|
||||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||||
|
|
||||||
private static (long, long, int) SheetState(Plate plate) =>
|
private static (long, long, int) SheetState(Plate plate) =>
|
||||||
(Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant);
|
(Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant);
|
||||||
|
|
||||||
|
private static (long, long, long, long) EdgeState(Plate plate) =>
|
||||||
|
(Bits(plate.EdgeSpacing.Left), Bits(plate.EdgeSpacing.Bottom), Bits(plate.EdgeSpacing.Right),
|
||||||
|
Bits(plate.EdgeSpacing.Top));
|
||||||
|
|
||||||
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
|
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
|
||||||
|
|
||||||
internal bool SameZones(Plate current)
|
internal bool SameZones(Plate current)
|
||||||
@@ -74,9 +91,25 @@ public sealed class OverlapGeometryStamp
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal bool SameSpacing(Plate current) =>
|
||||||
|
ReferenceEquals(plate, current) && partSpacing == Bits(current.PartSpacing)
|
||||||
|
&& commonLines == CommonLineState(current);
|
||||||
|
|
||||||
|
/// <summary>The same sheet, edge spacing and outline: an unchanged part keeps its edge finding.</summary>
|
||||||
|
internal bool SameSheet(Plate current) =>
|
||||||
|
ReferenceEquals(plate, current) && sheet == SheetState(current) && edgeSpacing == EdgeState(current)
|
||||||
|
&& Equals(outline, current.Outline);
|
||||||
|
|
||||||
|
// Which plate slots share a common-line group, and its kerf: the spacing check reads both.
|
||||||
|
private static string CommonLineState(Plate plate) =>
|
||||||
|
string.Join(";", plate.CommonLines.Select(group => Bits(group.Kerf) + ":"
|
||||||
|
+ string.Join(",", group.Members.Select(member => plate.Parts.IndexOf(member)))));
|
||||||
|
|
||||||
public bool Matches(Plate current)
|
public bool Matches(Plate current)
|
||||||
{
|
{
|
||||||
if (current.Parts.Count != entries.Length || !SameZones(current) || !SameCutOffs(current))
|
if (current.Parts.Count != entries.Length || partSpacing != Bits(current.PartSpacing)
|
||||||
|
|| commonLines != CommonLineState(current) || edgeSpacing != EdgeState(current)
|
||||||
|
|| !Equals(outline, current.Outline) || !SameZones(current) || !SameCutOffs(current))
|
||||||
return false;
|
return false;
|
||||||
for (var i = 0; i < entries.Length; i++)
|
for (var i = 0; i < entries.Length; i++)
|
||||||
if (!entries[i].Matches(current.Parts[i]))
|
if (!entries[i].Matches(current.Parts[i]))
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ internal static class OverlapMarker
|
|||||||
point = default;
|
point = default;
|
||||||
var minY = outline.Min(vertex => vertex.Y);
|
var minY = outline.Min(vertex => vertex.Y);
|
||||||
var maxY = outline.Max(vertex => vertex.Y);
|
var maxY = outline.Max(vertex => vertex.Y);
|
||||||
var centre = (minY + maxY) / 2;
|
var center = (minY + maxY) / 2;
|
||||||
double below = minY, above = maxY;
|
double below = minY, above = maxY;
|
||||||
foreach (var vertex in outline.Concat(holes.SelectMany(hole => hole)))
|
foreach (var vertex in outline.Concat(holes.SelectMany(hole => hole)))
|
||||||
{
|
{
|
||||||
if (vertex.Y <= centre && vertex.Y > below)
|
if (vertex.Y <= center && vertex.Y > below)
|
||||||
below = vertex.Y;
|
below = vertex.Y;
|
||||||
else if (vertex.Y > centre && vertex.Y < above)
|
else if (vertex.Y > center && vertex.Y < above)
|
||||||
above = vertex.Y;
|
above = vertex.Y;
|
||||||
}
|
}
|
||||||
var y = (below + above) / 2;
|
var y = (below + above) / 2;
|
||||||
|
|||||||
@@ -31,11 +31,17 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
|||||||
}
|
}
|
||||||
// Sampling can hide a crossing or tangency between curves. Reject native
|
// Sampling can hide a crossing or tangency between curves. Reject native
|
||||||
// contour contact before asking the polygon approximation about containment.
|
// contour contact before asking the polygon approximation about containment.
|
||||||
|
// A contact lies within both entities' bounds, so shapes whose bounds are clear by more
|
||||||
|
// than any native query's allowance cannot touch: a part with thousands of holes does
|
||||||
|
// not query every pair.
|
||||||
|
var boxes = shapes.Select(shape => shape.BoundingBox).ToList();
|
||||||
for (var i = 0; i < shapes.Count; i++)
|
for (var i = 0; i < shapes.Count; i++)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
for (var j = 0; j < i; j++)
|
for (var j = 0; j < i; j++)
|
||||||
{
|
{
|
||||||
|
if (ClearOfNativeQueries(boxes[i], boxes[j]))
|
||||||
|
continue;
|
||||||
shapes[i].Intersects(shapes[j], out var intersections);
|
shapes[i].Intersects(shapes[j], out var intersections);
|
||||||
if (intersections.Count > 0)
|
if (intersections.Count > 0)
|
||||||
throw new ArgumentException("Native material contours cross or touch.");
|
throw new ArgumentException("Native material contours cross or touch.");
|
||||||
@@ -44,23 +50,70 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
|||||||
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
|
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
|
||||||
var outer = ordered[0];
|
var outer = ordered[0];
|
||||||
var holes = ordered.Skip(1).ToList();
|
var holes = ordered.Skip(1).ToList();
|
||||||
|
var outerBounds = BoundsOf(outer);
|
||||||
|
var holeBounds = holes.Select(BoundsOf).ToList();
|
||||||
for (var i = 0; i < holes.Count; i++)
|
for (var i = 0; i < holes.Count; i++)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
if (BoundariesTouch(outer, holes[i], cancellationToken)
|
if (BoundariesTouch(outer, outerBounds, holes[i], holeBounds[i], cancellationToken)
|
||||||
|| !Inside(outer, holes[i].Vertices[0]))
|
|| !Inside(outer, outerBounds, holes[i].Vertices[0]))
|
||||||
throw new ArgumentException("Contours must have one outer with strictly internal holes.");
|
throw new ArgumentException("Contours must have one outer with strictly internal holes.");
|
||||||
for (var j = 0; j < i; j++)
|
for (var j = 0; j < i; j++)
|
||||||
{
|
{
|
||||||
if (BoundariesTouch(holes[i], holes[j], cancellationToken)
|
// Exact polygon tests: holes whose vertex bounds are apart can neither touch
|
||||||
|| Inside(holes[i], holes[j].Vertices[0])
|
// nor contain each other's vertices.
|
||||||
|| Inside(holes[j], holes[i].Vertices[0]))
|
if (!Overlap(holeBounds[i], holeBounds[j]))
|
||||||
|
continue;
|
||||||
|
if (BoundariesTouch(holes[i], holeBounds[i], holes[j], holeBounds[j], cancellationToken)
|
||||||
|
|| Inside(holes[i], holeBounds[i], holes[j].Vertices[0])
|
||||||
|
|| Inside(holes[j], holeBounds[j], holes[i].Vertices[0]))
|
||||||
throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
|
throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new OverlapMaterial(outer, holes);
|
return new OverlapMaterial(outer, holes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The gap beyond which no native intersection query counts two entities as touching: ten
|
||||||
|
/// times the 0.00001 bounding-box allowance of native intersections, as the planner's
|
||||||
|
/// <c>PostVerificationGeometry.Extent.ClearMargin</c>.
|
||||||
|
/// </summary>
|
||||||
|
private const double NativeClearMargin = 1e-3;
|
||||||
|
|
||||||
|
// True only when both boxes are finite and apart by more than the native allowance on some axis.
|
||||||
|
private static bool ClearOfNativeQueries(Box a, Box b) =>
|
||||||
|
IsFinite(a) && IsFinite(b)
|
||||||
|
&& (a.Right + NativeClearMargin < b.Left || b.Right + NativeClearMargin < a.Left
|
||||||
|
|| a.Top + NativeClearMargin < b.Bottom || b.Top + NativeClearMargin < a.Bottom);
|
||||||
|
|
||||||
|
private static bool IsFinite(Box box) => double.IsFinite(box.Left) && double.IsFinite(box.Right)
|
||||||
|
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top);
|
||||||
|
|
||||||
|
/// <summary>Exact vertex bounds of a sampled polygon, for skipping segment and winding tests.</summary>
|
||||||
|
private readonly record struct Bounds(double MinX, double MinY, double MaxX, double MaxY)
|
||||||
|
{
|
||||||
|
internal bool Contains(Vector point) =>
|
||||||
|
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bounds BoundsOf(Polygon polygon)
|
||||||
|
{
|
||||||
|
double minX = double.PositiveInfinity, minY = double.PositiveInfinity,
|
||||||
|
maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
|
||||||
|
foreach (var vertex in polygon.Vertices)
|
||||||
|
{
|
||||||
|
minX = System.Math.Min(minX, vertex.X);
|
||||||
|
minY = System.Math.Min(minY, vertex.Y);
|
||||||
|
maxX = System.Math.Max(maxX, vertex.X);
|
||||||
|
maxY = System.Math.Max(maxY, vertex.Y);
|
||||||
|
}
|
||||||
|
return new(minX, minY, maxX, maxY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed-interval overlap: two segments that touch share a point inside both bounds.
|
||||||
|
private static bool Overlap(Bounds a, Bounds b) =>
|
||||||
|
a.MaxX >= b.MinX && b.MaxX >= a.MinX && a.MaxY >= b.MinY && b.MaxY >= a.MinY;
|
||||||
|
|
||||||
internal OverlapMaterial Transform(double rotation, Vector offset) =>
|
internal OverlapMaterial Transform(double rotation, Vector offset) =>
|
||||||
new(TransformPolygon(Outer, rotation, offset),
|
new(TransformPolygon(Outer, rotation, offset),
|
||||||
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
|
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
|
||||||
@@ -171,13 +224,23 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
|||||||
throw new ArgumentException("Material contour could not be completely triangulated.");
|
throw new ArgumentException("Material contour could not be completely triangulated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken)
|
// Exact: a segment of a whose bounds are clear of b's bounds touches no segment of b.
|
||||||
|
private static bool BoundariesTouch(Polygon a, Bounds aBounds, Polygon b, Bounds bBounds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (!Overlap(aBounds, bBounds))
|
||||||
|
return false;
|
||||||
for (var i = 0; i + 1 < a.Vertices.Count; i++)
|
for (var i = 0; i + 1 < a.Vertices.Count; i++)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var start = a.Vertices[i];
|
||||||
|
var end = a.Vertices[i + 1];
|
||||||
|
var segment = new Bounds(System.Math.Min(start.X, end.X), System.Math.Min(start.Y, end.Y),
|
||||||
|
System.Math.Max(start.X, end.X), System.Math.Max(start.Y, end.Y));
|
||||||
|
if (!Overlap(segment, bBounds))
|
||||||
|
continue;
|
||||||
for (var j = 0; j + 1 < b.Vertices.Count; j++)
|
for (var j = 0; j + 1 < b.Vertices.Count; j++)
|
||||||
if (SegmentsTouch(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1]))
|
if (SegmentsTouch(start, end, b.Vertices[j], b.Vertices[j + 1]))
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -200,6 +263,10 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
|||||||
&& point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y);
|
&& point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y);
|
||||||
|
|
||||||
// Boundary contact is rejected before this winding-number test is used for topology.
|
// Boundary contact is rejected before this winding-number test is used for topology.
|
||||||
|
// A point outside the polygon's vertex bounds has winding zero.
|
||||||
|
private static bool Inside(Polygon polygon, Bounds bounds, Vector point) =>
|
||||||
|
bounds.Contains(point) && Inside(polygon, point);
|
||||||
|
|
||||||
private static bool Inside(Polygon polygon, Vector point)
|
private static bool Inside(Polygon polygon, Vector point)
|
||||||
{
|
{
|
||||||
var winding = 0;
|
var winding = 0;
|
||||||
|
|||||||
@@ -26,6 +26,29 @@ public static class OverlapPairPresentation
|
|||||||
+ $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}";
|
+ $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string Label(PlateSpacingPair pair, IFormatProvider provider = null) =>
|
||||||
|
$"{pair.PartAId + 1}/{pair.PartBId + 1} gap {Number(pair.Gap, provider)}";
|
||||||
|
|
||||||
|
public static string Details(PlateSpacingPair pair, Units capturedUnits, IFormatProvider provider = null)
|
||||||
|
{
|
||||||
|
var units = UnitsHelper.GetShortString(capturedUnits);
|
||||||
|
return $"Part spacing {pair.PartAId + 1}/{pair.PartBId + 1}: {pair.PartAName} / {pair.PartBName}\n"
|
||||||
|
+ $"Gap ≈ {Number(pair.Gap, provider)} {units}\n"
|
||||||
|
+ $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A short canvas label: the part's sequence number and what is wrong.</summary>
|
||||||
|
public static string Label(PlateEdgeFinding finding) =>
|
||||||
|
$"{finding.PartId + 1} " + (finding.OffPlate ? "off plate" : "outside work area");
|
||||||
|
|
||||||
|
public static string Details(PlateEdgeFinding finding, Units capturedUnits, IFormatProvider provider = null)
|
||||||
|
{
|
||||||
|
var units = UnitsHelper.GetShortString(capturedUnits);
|
||||||
|
return (finding.OffPlate ? "Off the plate" : "Outside work area") + $": part {finding.PartId + 1}\n"
|
||||||
|
+ finding.Message + "\n"
|
||||||
|
+ $"Marker: ({Number(finding.Marker.X, provider)}, {Number(finding.Marker.Y, provider)}) {units}";
|
||||||
|
}
|
||||||
|
|
||||||
// Significant figures adapt to scale: a positive sliver must never read as zero.
|
// Significant figures adapt to scale: a positive sliver must never read as zero.
|
||||||
public static string Number(double value, IFormatProvider provider = null) =>
|
public static string Number(double value, IFormatProvider provider = null) =>
|
||||||
value.ToString("G6", provider ?? CultureInfo.CurrentCulture);
|
value.ToString("G6", provider ?? CultureInfo.CurrentCulture);
|
||||||
@@ -39,15 +62,27 @@ public static class OverlapPairPresentation
|
|||||||
/// Coincident or nearby markers return every matching pair in stable ID order.
|
/// Coincident or nearby markers return every matching pair in stable ID order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyList<PlateOverlapPair> HitTest(IEnumerable<PlateOverlapPair> pairs,
|
public static IReadOnlyList<PlateOverlapPair> HitTest(IEnumerable<PlateOverlapPair> pairs,
|
||||||
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi)
|
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi) =>
|
||||||
|
pairs.Where(pair => Hits(worldToScreen(pair.Marker), pointer, deviceDpi))
|
||||||
|
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
|
||||||
|
|
||||||
|
/// <inheritdoc cref="HitTest(IEnumerable{PlateOverlapPair}, Func{Vector, Vector}, Vector, int)"/>
|
||||||
|
public static IReadOnlyList<PlateSpacingPair> HitTest(IEnumerable<PlateSpacingPair> pairs,
|
||||||
|
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi) =>
|
||||||
|
pairs.Where(pair => Hits(worldToScreen(pair.Marker), pointer, deviceDpi))
|
||||||
|
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
|
||||||
|
|
||||||
|
/// <inheritdoc cref="HitTest(IEnumerable{PlateOverlapPair}, Func{Vector, Vector}, Vector, int)"/>
|
||||||
|
public static IReadOnlyList<PlateEdgeFinding> HitTest(IEnumerable<PlateEdgeFinding> findings,
|
||||||
|
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi) =>
|
||||||
|
findings.Where(finding => Hits(worldToScreen(finding.Marker), pointer, deviceDpi))
|
||||||
|
.OrderBy(finding => finding.PartId).ToArray();
|
||||||
|
|
||||||
|
private static bool Hits(Vector center, Vector pointer, int deviceDpi)
|
||||||
{
|
{
|
||||||
var radius = HitRadius(deviceDpi);
|
var radius = HitRadius(deviceDpi);
|
||||||
return pairs.Where(pair =>
|
var dx = pointer.X - center.X;
|
||||||
{
|
var dy = pointer.Y - center.Y;
|
||||||
var center = worldToScreen(pair.Marker);
|
return dx * dx + dy * dy <= radius * radius;
|
||||||
var dx = pointer.X - center.X;
|
|
||||||
var dy = pointer.Y - center.Y;
|
|
||||||
return dx * dx + dy * dy <= radius * radius;
|
|
||||||
}).OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,20 +23,35 @@ public sealed class OverlapReportState
|
|||||||
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
|
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
|
||||||
public IReadOnlyList<CutOffClampFinding> DisplayCutOffClampFindings { get; private set; } =
|
public IReadOnlyList<CutOffClampFinding> DisplayCutOffClampFindings { get; private set; } =
|
||||||
Array.Empty<CutOffClampFinding>();
|
Array.Empty<CutOffClampFinding>();
|
||||||
|
/// <summary>Known part-spacing pairs safe to draw, retained like <see cref="DisplayPairs"/>.</summary>
|
||||||
|
public IReadOnlyList<PlateSpacingPair> DisplaySpacingPairs { get; private set; } =
|
||||||
|
Array.Empty<PlateSpacingPair>();
|
||||||
|
/// <summary>Known plate-edge findings safe to draw, retained like <see cref="DisplayPairs"/>.</summary>
|
||||||
|
public IReadOnlyList<PlateEdgeFinding> DisplayEdgeFindings { get; private set; } =
|
||||||
|
Array.Empty<PlateEdgeFinding>();
|
||||||
public bool IsRunning => Status == OverlapCheckStatus.Checking;
|
public bool IsRunning => Status == OverlapCheckStatus.Checking;
|
||||||
|
/// <summary>A current, fully checked report with no finding of any kind.</summary>
|
||||||
|
public bool IsAllClear => Status == OverlapCheckStatus.Current && Report.Pairs.Count == 0
|
||||||
|
&& Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
|
||||||
|
&& Report.SpacingPairs.Count == 0 && Report.EdgeFindings.Count == 0;
|
||||||
|
/// <summary>
|
||||||
|
/// The last published result's message, kept through layout edits and rechecks so the canvas
|
||||||
|
/// stays quiet until the next result; never a claim about the current layout. Null when none.
|
||||||
|
/// </summary>
|
||||||
|
public string SettledMessage { get; private set; }
|
||||||
|
/// <summary>Whether that last published result was all clear.</summary>
|
||||||
|
public bool SettledClear { get; private set; }
|
||||||
|
|
||||||
public string Message => Status switch
|
public string Message => Status switch
|
||||||
{
|
{
|
||||||
OverlapCheckStatus.Checking => "Checking overlaps…",
|
OverlapCheckStatus.Checking => "Checking overlaps…",
|
||||||
OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
|
OverlapCheckStatus.Current => CurrentMessage(),
|
||||||
? (Report.Pairs.Count == 0 ? "No material overlaps detected"
|
|
||||||
: $"Overlaps: {Report.Pairs.Count} pairs")
|
|
||||||
: $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, "
|
|
||||||
+ $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts",
|
|
||||||
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
|
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
|
||||||
+ (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ")
|
+ (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ")
|
||||||
+ (Report.CutOffClampFindings.Count == 0 ? ""
|
+ (Report.CutOffClampFindings.Count == 0 ? ""
|
||||||
: $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ")
|
: $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ")
|
||||||
|
+ (Report.SpacingPairs.Count == 0 ? "" : $"{Report.SpacingPairs.Count} pairs under part spacing; ")
|
||||||
|
+ Edges("; ")
|
||||||
+ $"{uncheckedPartCount} parts could not be checked",
|
+ $"{uncheckedPartCount} parts could not be checked",
|
||||||
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
|
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
|
||||||
OverlapCheckStatus.Canceled => "Overlap check canceled",
|
OverlapCheckStatus.Canceled => "Overlap check canceled",
|
||||||
@@ -44,6 +59,66 @@ public sealed class OverlapReportState
|
|||||||
_ => "Overlaps: not checked"
|
_ => "Overlaps: not checked"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>A few steady words for the status bar; <see cref="Message"/> has the detail.</summary>
|
||||||
|
public string Summary => Status switch
|
||||||
|
{
|
||||||
|
OverlapCheckStatus.Checking => "Layout: checking…",
|
||||||
|
OverlapCheckStatus.Current => IsAllClear ? "Layout: OK" : "Layout: " + Findings(),
|
||||||
|
OverlapCheckStatus.Incomplete => "Layout: check incomplete",
|
||||||
|
OverlapCheckStatus.Failed => "Layout: check failed",
|
||||||
|
OverlapCheckStatus.Canceled => "Layout: check canceled",
|
||||||
|
OverlapCheckStatus.Stale => "Layout: out of date",
|
||||||
|
_ => "Layout: not checked"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>True when <see cref="Summary"/> reports findings or a check that did not finish.</summary>
|
||||||
|
public bool NeedsAttention => Status is OverlapCheckStatus.Incomplete or OverlapCheckStatus.Failed
|
||||||
|
|| Status == OverlapCheckStatus.Current && !IsAllClear;
|
||||||
|
|
||||||
|
private string Findings()
|
||||||
|
{
|
||||||
|
var overlaps = Report.Pairs.Count + Report.ClampFindings.Count + Report.CutOffClampFindings.Count;
|
||||||
|
var spacing = Report.SpacingPairs.Count;
|
||||||
|
var words = new List<string>();
|
||||||
|
if (overlaps != 0)
|
||||||
|
words.Add(overlaps == 1 ? "1 overlap" : $"{overlaps} overlaps");
|
||||||
|
if (spacing != 0)
|
||||||
|
words.Add($"{spacing} too close");
|
||||||
|
var off = Report.EdgeFindings.Count(finding => finding.OffPlate);
|
||||||
|
var near = Report.EdgeFindings.Count - off;
|
||||||
|
if (off != 0)
|
||||||
|
words.Add($"{off} off plate");
|
||||||
|
if (near != 0)
|
||||||
|
words.Add($"{near} outside work area");
|
||||||
|
return string.Join(", ", words);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plate-edge counts for the detailed message, each followed by the separator; empty when none.
|
||||||
|
private string Edges(string separator)
|
||||||
|
{
|
||||||
|
var off = Report.EdgeFindings.Count(finding => finding.OffPlate);
|
||||||
|
var near = Report.EdgeFindings.Count - off;
|
||||||
|
return (off == 0 ? "" : $"{off} parts off the plate{separator}")
|
||||||
|
+ (near == 0 ? "" : $"{near} parts outside the work area{separator}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a fully checked report that required a gap may clear spacing as well as overlaps.
|
||||||
|
private string CurrentMessage()
|
||||||
|
{
|
||||||
|
var spacing = (Report.SpacingPairs.Count == 0 ? ""
|
||||||
|
: $"; part spacing: {Report.SpacingPairs.Count} pairs too close")
|
||||||
|
+ (Report.EdgeFindings.Count == 0 ? "" : "; " + Edges("; ").TrimEnd(' ', ';'));
|
||||||
|
if (Report.ClampFindings.Count != 0 || Report.CutOffClampFindings.Count != 0)
|
||||||
|
return $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, "
|
||||||
|
+ $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts{spacing}";
|
||||||
|
if (Report.Pairs.Count != 0)
|
||||||
|
return $"Overlaps: {Report.Pairs.Count} pairs{spacing}";
|
||||||
|
if (spacing.Length != 0)
|
||||||
|
return "No material overlaps" + spacing;
|
||||||
|
return Report.SpacingChecked ? "No material overlaps or part spacing violations detected"
|
||||||
|
: "No material overlaps detected";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts a request. A manual check from Off shows Areas; an automatic recheck keeps
|
/// Starts a request. A manual check from Off shows Areas; an automatic recheck keeps
|
||||||
/// the user's display choice, including Off.
|
/// the user's display choice, including Off.
|
||||||
@@ -66,10 +141,14 @@ public sealed class OverlapReportState
|
|||||||
DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs
|
DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs
|
||||||
: report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly();
|
: report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly();
|
||||||
DisplayCutOffClampFindings = report.CutOffClampFindings;
|
DisplayCutOffClampFindings = report.CutOffClampFindings;
|
||||||
|
DisplaySpacingPairs = report.SpacingPairs;
|
||||||
|
DisplayEdgeFindings = report.EdgeFindings;
|
||||||
displayStamp = stamp;
|
displayStamp = stamp;
|
||||||
observedDisplayStamp = stamp;
|
observedDisplayStamp = stamp;
|
||||||
uncheckedPartCount = CountUncheckedParts(report.Issues);
|
uncheckedPartCount = CountUncheckedParts(report.Issues.Concat(report.SpacingIssues));
|
||||||
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
|
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
|
||||||
|
SettledMessage = Message;
|
||||||
|
SettledClear = IsAllClear;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +202,17 @@ public sealed class OverlapReportState
|
|||||||
&& (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList();
|
&& (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList();
|
||||||
if (retained.Count != DisplayPairs.Count)
|
if (retained.Count != DisplayPairs.Count)
|
||||||
DisplayPairs = retained.AsReadOnly();
|
DisplayPairs = retained.AsReadOnly();
|
||||||
if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0)
|
var sameSpacing = displayStamp.SameSpacing(plate);
|
||||||
|
var retainedSpacing = DisplaySpacingPairs.Where(pair => sameSpacing
|
||||||
|
&& unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList();
|
||||||
|
if (retainedSpacing.Count != DisplaySpacingPairs.Count)
|
||||||
|
DisplaySpacingPairs = retainedSpacing.AsReadOnly();
|
||||||
|
var sameSheet = displayStamp.SameSheet(plate);
|
||||||
|
var retainedEdges = DisplayEdgeFindings.Where(finding => sameSheet && unchanged[finding.PartId]).ToList();
|
||||||
|
if (retainedEdges.Count != DisplayEdgeFindings.Count)
|
||||||
|
DisplayEdgeFindings = retainedEdges.AsReadOnly();
|
||||||
|
if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0 && DisplaySpacingPairs.Count == 0
|
||||||
|
&& DisplayEdgeFindings.Count == 0)
|
||||||
ClearDisplayPairs();
|
ClearDisplayPairs();
|
||||||
else
|
else
|
||||||
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
|
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
|
||||||
@@ -133,6 +222,8 @@ public sealed class OverlapReportState
|
|||||||
{
|
{
|
||||||
DisplayPairs = Array.Empty<PlateOverlapPair>();
|
DisplayPairs = Array.Empty<PlateOverlapPair>();
|
||||||
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
||||||
|
DisplaySpacingPairs = Array.Empty<PlateSpacingPair>();
|
||||||
|
DisplayEdgeFindings = Array.Empty<PlateEdgeFinding>();
|
||||||
displayStamp = null;
|
displayStamp = null;
|
||||||
observedDisplayStamp = null;
|
observedDisplayStamp = null;
|
||||||
}
|
}
|
||||||
@@ -148,7 +239,11 @@ public sealed class OverlapReportState
|
|||||||
private void Clear(OverlapCheckStatus status, bool preserveDisplay = false)
|
private void Clear(OverlapCheckStatus status, bool preserveDisplay = false)
|
||||||
{
|
{
|
||||||
if (!preserveDisplay)
|
if (!preserveDisplay)
|
||||||
|
{
|
||||||
ClearDisplayPairs();
|
ClearDisplayPairs();
|
||||||
|
SettledMessage = null;
|
||||||
|
SettledClear = false;
|
||||||
|
}
|
||||||
Generation++;
|
Generation++;
|
||||||
Report = null;
|
Report = null;
|
||||||
uncheckedPartCount = 0;
|
uncheckedPartCount = 0;
|
||||||
|
|||||||
@@ -21,10 +21,21 @@ namespace OpenNest.Diagnostics;
|
|||||||
/// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays
|
/// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays
|
||||||
/// valid.
|
/// valid.
|
||||||
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
|
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
|
||||||
/// that check. The sheet edge and clamps are not spacing-checked here.
|
/// that check. Clamps are not spacing-checked here; <see cref="PlateEdgeReview"/> checks the sheet edge. The pre-post review and the
|
||||||
|
/// desktop overlap label share this scan, so they cannot disagree.
|
||||||
|
/// Two members of one common-line group (<see cref="Plate.CommonLines"/>) are held to the group's
|
||||||
|
/// kerf instead of the part spacing: they are placed one kerf apart on purpose.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class PartSpacingReview
|
internal static class PartSpacingReview
|
||||||
{
|
{
|
||||||
|
/// <summary>One part to scan: <see cref="Id"/> is its position in the overlap check's input.
|
||||||
|
/// The profile loads inside the scan's error boundary; null means no material.</summary>
|
||||||
|
internal sealed record SpacingInput(int Id, Func<ShapeProfile> Profile, Vector Location);
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="spacing"/> requires any gap at all.</summary>
|
||||||
|
internal static bool Enforces(double spacing) =>
|
||||||
|
double.IsFinite(spacing) && SpacingOutline.InflationFor(spacing) > Tolerance.Epsilon;
|
||||||
|
|
||||||
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
|
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
|
||||||
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
|
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -36,14 +47,61 @@ internal static class PartSpacingReview
|
|||||||
$"Part spacing check: plate part spacing {spacing} is not a finite, non-negative value."));
|
$"Part spacing check: plate part spacing {spacing} is not a finite, non-negative value."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var profiles = new Dictionary<(Program, long), ShapeProfile>();
|
||||||
|
var inputs = parts.Select((part, id) => new SpacingInput(id, () => Profile(part, profiles), part.Location))
|
||||||
|
.ToList();
|
||||||
|
Scan(inputs, spacing, CommonLinePairs(plate, parts), overlap.Pairs, overlap.Issues,
|
||||||
|
(a, b, message) => findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumbers[a],
|
||||||
|
b is { } other ? partNumbers[other] : null, "Part spacing check: " + message)),
|
||||||
|
(a, b, gap, marker) => findings.Add(new(PostVerificationKind.PartSpacing, plateNumber, partNumbers[a],
|
||||||
|
partNumbers[b],
|
||||||
|
CommonLineKerf(plate, parts[a], parts[b]) is { } kerf
|
||||||
|
? $"'{Name(parts[a])}' and '{Name(parts[b])}' are {gap:F4} apart, closer than the "
|
||||||
|
+ $"kerf ({kerf:F4}) of their common-line group."
|
||||||
|
: $"'{Name(parts[a])}' and '{Name(parts[b])}' are {gap:F3} apart, closer than the "
|
||||||
|
+ $"plate's part spacing ({spacing:F3}).")
|
||||||
|
{ Location = marker }),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The desktop label's check: the same scan over a snapshot's owned material, so it runs on
|
||||||
|
/// the overlap worker and never reads a live part. Pairs and issues keep input ids.
|
||||||
|
/// </summary>
|
||||||
|
internal static void Add(PlateOverlapSnapshot snapshot, IReadOnlyList<PlateOverlapPair> overlaps,
|
||||||
|
IReadOnlyList<PlateOverlapIssue> overlapIssues, List<PlateSpacingPair> pairs,
|
||||||
|
List<PlateOverlapIssue> issues, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var spacing = snapshot.PartSpacing;
|
||||||
|
if (!Enforces(spacing))
|
||||||
|
return;
|
||||||
|
var names = snapshot.Parts.ToDictionary(part => part.Id, part => part.Name);
|
||||||
|
var profiles = new Dictionary<(OverlapSource, long), ShapeProfile>();
|
||||||
|
var inputs = snapshot.Parts
|
||||||
|
.Select(part => new SpacingInput(part.Id, () => Profile(part, profiles), part.Location)).ToList();
|
||||||
|
Scan(inputs, spacing, snapshot.CommonLinePairs, overlaps, overlapIssues,
|
||||||
|
(a, b, message) => issues.Add(new PlateOverlapIssue(a, b, message)),
|
||||||
|
(a, b, gap, marker) => pairs.Add(new PlateSpacingPair(a, b, names[a], names[b], gap, marker)),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports each pair by input id, lower id first. <paramref name="incomplete"/> receives a
|
||||||
|
/// part or pair that could not be read; <paramref name="tooClose"/> a confirmed gap and its marker.
|
||||||
|
/// </summary>
|
||||||
|
private static void Scan(IReadOnlyList<SpacingInput> parts, double spacing,
|
||||||
|
IReadOnlyDictionary<(int, int), double> commonLines, IEnumerable<PlateOverlapPair> overlaps, IEnumerable<PlateOverlapIssue> overlapIssues,
|
||||||
|
Action<int, int?, string> incomplete, Action<int, int, double, Vector> tooClose,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
var inflateBy = SpacingOutline.InflationFor(spacing);
|
var inflateBy = SpacingOutline.InflationFor(spacing);
|
||||||
if (inflateBy <= Tolerance.Epsilon || parts.Count < 2)
|
if (inflateBy <= Tolerance.Epsilon || parts.Count < 2)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var unreadable = overlap.Issues.Where(issue => issue.PartBId == null)
|
var unreadable = overlapIssues.Where(issue => issue.PartBId == null)
|
||||||
.Select(issue => issue.PartAId).ToHashSet();
|
.Select(issue => issue.PartAId).ToHashSet();
|
||||||
var overlapping = overlap.Pairs.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
|
var overlapping = overlaps.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
|
||||||
var profiles = new Dictionary<(Program, long), ShapeProfile>();
|
var outlines = new Dictionary<ShapeProfile, (SpacingOutline Raw, SpacingOutline Inflated)>();
|
||||||
var shapes = new ShapeProfile[parts.Count];
|
var shapes = new ShapeProfile[parts.Count];
|
||||||
var fine = new NativeContours[parts.Count];
|
var fine = new NativeContours[parts.Count];
|
||||||
var raw = new SpacingOutline[parts.Count];
|
var raw = new SpacingOutline[parts.Count];
|
||||||
@@ -51,24 +109,30 @@ internal static class PartSpacingReview
|
|||||||
for (var i = 0; i < parts.Count; i++)
|
for (var i = 0; i < parts.Count; i++)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
if (unreadable.Contains(i))
|
if (unreadable.Contains(parts[i].Id))
|
||||||
continue;
|
continue;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var profile = Profile(parts[i], profiles);
|
var profile = parts[i].Profile();
|
||||||
if (profile == null)
|
if (profile == null)
|
||||||
continue;
|
continue;
|
||||||
shapes[i] = profile;
|
shapes[i] = profile;
|
||||||
raw[i] = SpacingOutline.Build(profile, parts[i].Location, 0);
|
// Repeated parts share a profile, so each shape is offset once.
|
||||||
inflated[i] = SpacingOutline.Build(profile, parts[i].Location, inflateBy);
|
if (!outlines.TryGetValue(profile, out var outline))
|
||||||
if (raw[i] == null || inflated[i] == null)
|
{
|
||||||
|
outline = (SpacingOutline.Build(profile, Vector.Zero, 0),
|
||||||
|
SpacingOutline.Build(profile, Vector.Zero, inflateBy));
|
||||||
|
outlines[profile] = outline;
|
||||||
|
}
|
||||||
|
if (outline.Raw == null || outline.Inflated == null)
|
||||||
throw new ArgumentException("Part material has no closed perimeter.");
|
throw new ArgumentException("Part material has no closed perimeter.");
|
||||||
|
raw[i] = outline.Raw.MovedTo(parts[i].Location);
|
||||||
|
inflated[i] = outline.Inflated.MovedTo(parts[i].Location);
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (IsInvalid(exception))
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
{
|
{
|
||||||
raw[i] = inflated[i] = null;
|
raw[i] = inflated[i] = null;
|
||||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumbers[i], null,
|
incomplete(parts[i].Id, null, exception.Message);
|
||||||
"Part spacing check: " + exception.Message));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +152,8 @@ internal static class PartSpacingReview
|
|||||||
// Sorted by Left, so nothing further along can reach part i either.
|
// Sorted by Left, so nothing further along can reach part i either.
|
||||||
if (other.Left > reach.Right + Tolerance.Epsilon)
|
if (other.Left > reach.Right + Tolerance.Epsilon)
|
||||||
break;
|
break;
|
||||||
if (!BoxesTouch(reach, other) || overlapping.Contains(Key(i, j))
|
var (first, second) = Key(parts[i].Id, parts[j].Id);
|
||||||
|
if (!BoxesTouch(reach, other) || overlapping.Contains((first, second))
|
||||||
|| !inflated[i].Overlaps(raw[j]))
|
|| !inflated[i].Overlaps(raw[j]))
|
||||||
continue;
|
continue;
|
||||||
double gap;
|
double gap;
|
||||||
@@ -101,27 +166,21 @@ internal static class PartSpacingReview
|
|||||||
}
|
}
|
||||||
catch (Exception exception) when (IsInvalid(exception))
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
{
|
{
|
||||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber,
|
incomplete(first, second, exception.Message);
|
||||||
System.Math.Min(partNumbers[i], partNumbers[j]),
|
|
||||||
System.Math.Max(partNumbers[i], partNumbers[j]),
|
|
||||||
"Part spacing check: " + exception.Message));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!(gap < spacing - SpacingOutline.SpacingSlack))
|
// Common-line partners are one kerf apart by design, never less.
|
||||||
continue;
|
var required = commonLines != null && commonLines.TryGetValue((first, second), out var kerf)
|
||||||
var (first, second) = partNumbers[i] < partNumbers[j] ? (i, j) : (j, i);
|
? System.Math.Min(kerf, spacing) : spacing;
|
||||||
findings.Add(new(PostVerificationKind.PartSpacing, plateNumber, partNumbers[first],
|
if (gap < required - SpacingOutline.SpacingSlack)
|
||||||
partNumbers[second],
|
tooClose(first, second, gap, marker);
|
||||||
$"'{Name(parts[first])}' and '{Name(parts[second])}' are {gap:F3} apart, closer than the "
|
|
||||||
+ $"plate's part spacing ({spacing:F3}).")
|
|
||||||
{ Location = marker });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Clean drawing material, rotated to the part's pose; null when the part has none.
|
/// <summary>Clean drawing material, rotated to the part's pose; null when the part has none.
|
||||||
/// Converted once per program and rotation.</summary>
|
/// Converted once per program and rotation.</summary>
|
||||||
private static ShapeProfile Profile(Part part, Dictionary<(Program, long), ShapeProfile> profiles)
|
internal static ShapeProfile Profile(Part part, Dictionary<(Program, long), ShapeProfile> profiles)
|
||||||
{
|
{
|
||||||
var program = part.BaseDrawing.Program;
|
var program = part.BaseDrawing.Program;
|
||||||
var rotation = part.Rotation - program.Rotation;
|
var rotation = part.Rotation - program.Rotation;
|
||||||
@@ -131,21 +190,65 @@ internal static class PartSpacingReview
|
|||||||
if (profiles.TryGetValue(key, out var cached))
|
if (profiles.TryGetValue(key, out var cached))
|
||||||
return cached;
|
return cached;
|
||||||
// Conversion creates fresh geometry, so rotating it never touches the live program.
|
// Conversion creates fresh geometry, so rotating it never touches the live program.
|
||||||
var entities = ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
|
var profile = Rotated(ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
|
||||||
.Where(entity => entity.Layer != SpecialLayers.Leadin && entity.Layer != SpecialLayers.Leadout)
|
.Where(entity => entity.Layer != SpecialLayers.Leadin && entity.Layer != SpecialLayers.Leadout),
|
||||||
.ForNesting();
|
rotation);
|
||||||
|
profiles[key] = profile;
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A snapshot source's material at a part's pose, from clones: sources are shared.</summary>
|
||||||
|
internal static ShapeProfile Profile(CapturedOverlapPart part,
|
||||||
|
Dictionary<(OverlapSource, long), ShapeProfile> profiles)
|
||||||
|
{
|
||||||
|
var key = (part.Source, BitConverter.DoubleToInt64Bits(part.Rotation));
|
||||||
|
if (profiles.TryGetValue(key, out var cached))
|
||||||
|
return cached;
|
||||||
|
var profile = Rotated(part.Source.Entities.Select(entity => entity.Clone()), part.Rotation);
|
||||||
|
profiles[key] = profile;
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShapeProfile Rotated(IEnumerable<Entity> owned, double rotation)
|
||||||
|
{
|
||||||
|
var entities = owned.ForNesting();
|
||||||
if (rotation != 0)
|
if (rotation != 0)
|
||||||
foreach (var entity in entities)
|
foreach (var entity in entities)
|
||||||
entity.Rotate(rotation);
|
entity.Rotate(rotation);
|
||||||
var profile = entities.Count == 0 ? null : new ShapeProfile(entities);
|
var profile = entities.Count == 0 ? null : new ShapeProfile(entities);
|
||||||
if (profile != null && profile.Perimeter == null)
|
if (profile != null && profile.Perimeter == null)
|
||||||
throw new ArgumentException("Part material has no closed perimeter.");
|
throw new ArgumentException("Part material has no closed perimeter.");
|
||||||
profiles[key] = profile;
|
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
|
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Each pair of <paramref name="parts"/> (by position, lower first) in one common-line group of
|
||||||
|
/// <paramref name="plate"/>, with the group's kerf.
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyDictionary<(int, int), double> CommonLinePairs(Plate plate, IReadOnlyList<Part> parts)
|
||||||
|
{
|
||||||
|
var pairs = new Dictionary<(int, int), double>();
|
||||||
|
if (plate?.CommonLines is not { Count: > 0 } groups)
|
||||||
|
return pairs;
|
||||||
|
var positions = new Dictionary<Part, int>(ReferenceEqualityComparer.Instance);
|
||||||
|
for (var i = 0; i < parts.Count; i++)
|
||||||
|
if (parts[i] != null)
|
||||||
|
positions.TryAdd(parts[i], i);
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var ids = group.Members.Where(positions.ContainsKey).Select(member => positions[member]).ToList();
|
||||||
|
for (var i = 0; i < ids.Count; i++)
|
||||||
|
for (var j = i + 1; j < ids.Count; j++)
|
||||||
|
pairs[Key(ids[i], ids[j])] = group.Kerf;
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? CommonLineKerf(Plate plate, Part a, Part b) =>
|
||||||
|
plate.CommonLineGroupOf(a) is { } group && group.Contains(b) ? group.Kerf : null;
|
||||||
|
|
||||||
private static string Name(Part part) => part.BaseDrawing.Name;
|
private static string Name(Part part) => part.BaseDrawing.Name;
|
||||||
|
|
||||||
private static bool BoxesTouch(Box a, Box b) =>
|
private static bool BoxesTouch(Box a, Box b) =>
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ namespace OpenNest.Diagnostics;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pre-post review of part support (docs/support-tabs.md). Every part whose cutting parameters
|
/// Pre-post review of part support (docs/support-tabs.md). Every part whose cutting parameters
|
||||||
/// ask for support tabs and that needs one (<see cref="SupportTabSettings.Analyze"/>: its plate's
|
/// ask for support tabs and that needs one (<see cref="SupportTabSettings.Analyze"/>: its plate's
|
||||||
/// support bed cannot hold it level or, without a bed, its width is in the no-bed range) must be
|
/// support bed cannot hold it level or, by size, its width is in the size range) must be
|
||||||
/// cut with a tab that keeps it level, however its program was made (planned, placed by hand or
|
/// cut with a tab that keeps it level, however its program was made (planned, placed by hand or
|
||||||
/// locked); without a bed any tab will do. The tab is the gap the outside cut leaves between its end and its start. Reports
|
/// locked); by size any tab will do. The tab is the gap the outside cut leaves between its end and its start. Reports
|
||||||
/// only; a part whose material or program cannot be read is reported as not checked.
|
/// only; a part whose material or program cannot be read is reported as not checked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class PartSupportReview
|
internal static class PartSupportReview
|
||||||
@@ -28,8 +28,7 @@ internal static class PartSupportReview
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var part = plate.Parts[index];
|
var part = plate.Parts[index];
|
||||||
var parameters = part?.CuttingParameters ?? plate.CuttingParameters;
|
var parameters = part?.CuttingParameters ?? plate.CuttingParameters;
|
||||||
if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff || parameters?.SupportTabs != true
|
if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff || !SupportTabSettings.CanTab(parameters, bed))
|
||||||
|| (bed == null && !parameters.HasNoBedTabRange()))
|
|
||||||
continue;
|
continue;
|
||||||
var partNumber = index + 1;
|
var partNumber = index + 1;
|
||||||
try
|
try
|
||||||
@@ -110,7 +109,7 @@ internal static class PartSupportReview
|
|||||||
? FormattableString.Invariant(
|
? FormattableString.Invariant(
|
||||||
$"The part is {support.Length:0.####} long, short enough for its tab alone to hold, ")
|
$"The part is {support.Length:0.####} long, short enough for its tab alone to hold, ")
|
||||||
: FormattableString.Invariant(
|
: FormattableString.Invariant(
|
||||||
$"The part is {support.Width:0.####} wide, in the width range tabbed without a support bed, "))
|
$"The part is {support.Width:0.####} wide, in the width range tabbed by size, "))
|
||||||
+ "and it has no tab; it may tip once cut free."
|
+ "and it has no tab; it may tip once cut free."
|
||||||
+ (support.BestTabPoint is { } suggested
|
+ (support.BestTabPoint is { } suggested
|
||||||
? FormattableString.Invariant($" Tab it near ({suggested.X:0.####}, {suggested.Y:0.####}).")
|
? FormattableString.Invariant($" Tab it near ({suggested.X:0.####}, {suggested.Y:0.####}).")
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using Clipper2Lib;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parts that leave the sheet or come closer to its edges than the plate's edge spacing.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Off the plate</b>: any cutting, lead or scribe motion of the placed program (its native
|
||||||
|
/// curve bounds) past the sheet's bounding box, or, on an outlined sheet, clean material outside
|
||||||
|
/// the outline or over one of its obstacles. A pierce or cut off the sheet cuts nothing, or the
|
||||||
|
/// table.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Outside the work area</b>: clean nesting material (leads excluded, as the part-spacing check reads it)
|
||||||
|
/// outside the plate's work area, or, on an outlined sheet, closer than the largest edge spacing to
|
||||||
|
/// the outline or an obstacle: Auto Nest's layout check judges bounds exactly this way, so a layout
|
||||||
|
/// it accepted stays clear. Shortfalls up to <see cref="SpacingOutline.SpacingSlack"/> pass. Leads
|
||||||
|
/// may run into the edge spacing; only off the sheet do they warn.
|
||||||
|
/// </para>
|
||||||
|
/// Cut-off parts are exempt: they run to the sheet edge by design.
|
||||||
|
/// </summary>
|
||||||
|
internal static class PlateEdgeReview
|
||||||
|
{
|
||||||
|
private const double Slack = SpacingOutline.SpacingSlack;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The sheet a plate's parts must stay on, owned and immutable, so the desktop label's worker
|
||||||
|
/// never reads the live plate. <see cref="Sheet"/> and <see cref="Work"/> are in the parts'
|
||||||
|
/// signed placement frame; <see cref="Inset"/> is the outline's clearance, the largest edge spacing.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record SheetFrame(Box Sheet, Box Work, SheetOutline Outline, Vector Origin, double Inset)
|
||||||
|
{
|
||||||
|
/// <summary>The plate's frame; throws <see cref="ArgumentException"/> for an unusable size or spacing.</summary>
|
||||||
|
internal static SheetFrame Of(Plate plate)
|
||||||
|
{
|
||||||
|
var size = plate.Size;
|
||||||
|
if (!(size.Length > 0) || !(size.Width > 0) || !double.IsFinite(size.Length) || !double.IsFinite(size.Width))
|
||||||
|
throw new ArgumentException($"plate size {size} is not a positive, finite size.");
|
||||||
|
var edges = plate.EdgeSpacing;
|
||||||
|
if (!new[] { edges.Left, edges.Bottom, edges.Right, edges.Top }.All(e => double.IsFinite(e) && e >= 0))
|
||||||
|
throw new ArgumentException("plate edge spacing is not finite and non-negative on every side.");
|
||||||
|
var sheet = plate.BoundingBox(includeParts: false);
|
||||||
|
return new SheetFrame(sheet, plate.WorkArea(), plate.Outline, new Vector(sheet.X, sheet.Y),
|
||||||
|
System.Math.Max(System.Math.Max(edges.Left, edges.Right), System.Math.Max(edges.Top, edges.Bottom)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What is wrong with one part; <see cref="Detail"/> follows its quoted name.</summary>
|
||||||
|
internal readonly record struct Verdict(bool OffPlate, string Detail, Vector Marker);
|
||||||
|
|
||||||
|
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (plate.Parts.Count == 0)
|
||||||
|
return;
|
||||||
|
SheetFrame frame;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
frame = SheetFrame.Of(plate);
|
||||||
|
}
|
||||||
|
catch (ArgumentException exception)
|
||||||
|
{
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||||
|
"Plate edge check: " + exception.Message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var profiles = new Dictionary<(Program, long), ShapeProfile>();
|
||||||
|
|
||||||
|
for (var index = 0; index < plate.Parts.Count; index++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var part = plate.Parts[index];
|
||||||
|
var partNumber = index + 1;
|
||||||
|
if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff)
|
||||||
|
continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// The placed program first: its leads and scribes may leave a sheet its material stays on.
|
||||||
|
var verdict = PastSheet(part, frame.Sheet, cancellationToken)
|
||||||
|
?? (PartSpacingReview.Profile(part, profiles) is { } profile
|
||||||
|
? Check(profile, part.Location, frame)
|
||||||
|
: null);
|
||||||
|
if (verdict is { } found)
|
||||||
|
findings.Add(new(found.OffPlate ? PostVerificationKind.OffPlate : PostVerificationKind.EdgeSpacing,
|
||||||
|
plateNumber, partNumber, null, $"'{part.BaseDrawing.Name}' {found.Detail}")
|
||||||
|
{ Location = found.Marker });
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
|
{
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null,
|
||||||
|
"Plate edge check: " + exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The desktop label's check: clean material only (snapshots hold no leads), run on the
|
||||||
|
/// overlap worker from owned geometry. Findings and issues keep input ids.
|
||||||
|
/// </summary>
|
||||||
|
internal static void Add(PlateOverlapSnapshot snapshot, List<PlateEdgeFinding> found,
|
||||||
|
List<PlateOverlapIssue> issues, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var frame = snapshot.Frame;
|
||||||
|
if (frame == null)
|
||||||
|
return;
|
||||||
|
var profiles = new Dictionary<(OverlapSource, long), ShapeProfile>();
|
||||||
|
var unreadable = snapshot.Issues.Where(issue => issue.PartBId == null)
|
||||||
|
.Select(issue => issue.PartAId).ToHashSet();
|
||||||
|
foreach (var part in snapshot.Parts)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (unreadable.Contains(part.Id))
|
||||||
|
continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (PartSpacingReview.Profile(part, profiles) is { } profile
|
||||||
|
&& Check(profile, part.Location, frame) is { } verdict)
|
||||||
|
found.Add(new PlateEdgeFinding(part.Id, part.Name, verdict.OffPlate,
|
||||||
|
$"'{part.Name}' {verdict.Detail}", verdict.Marker));
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
|
{
|
||||||
|
issues.Add(new PlateOverlapIssue(part.Id, null, exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clean material at <paramref name="location"/> against the sheet; null when it keeps the edge spacing.</summary>
|
||||||
|
internal static Verdict? Check(ShapeProfile profile, Vector location, SheetFrame frame)
|
||||||
|
{
|
||||||
|
var bounds = profile.Perimeter.BoundingBox.Translate(location);
|
||||||
|
if (Describe(Furthest(null, bounds, bounds.Center, frame.Sheet)) is { } past)
|
||||||
|
return past;
|
||||||
|
PathsD region = null;
|
||||||
|
if (frame.Outline != null)
|
||||||
|
{
|
||||||
|
var material = SpacingOutline.Build(profile, location, 0)
|
||||||
|
?? throw new ArgumentException("Part material has no closed perimeter.");
|
||||||
|
region = new PathsD { ClipperBridge.ToPath(material.Perimeter, positive: true) };
|
||||||
|
if (!frame.Outline.Holds(region, 0, frame.Origin, Slack))
|
||||||
|
return new Verdict(true, "lies outside the plate's outline or over one of its obstacles.",
|
||||||
|
bounds.Center);
|
||||||
|
}
|
||||||
|
if (Shortest(bounds, frame.Sheet, frame.Work) is { } close)
|
||||||
|
return new Verdict(false, $"is outside the work area: {close.Gap:F3} from the plate's {close.Edge} "
|
||||||
|
+ $"edge, inside its {close.Spacing:F3} edge spacing.", close.Marker);
|
||||||
|
if (region != null && frame.Inset > 0 && !frame.Outline.Holds(region, frame.Inset, frame.Origin, Slack))
|
||||||
|
return new Verdict(false, $"is outside the work area: inside the {frame.Inset:F3} edge spacing of the "
|
||||||
|
+ "plate's outline or one of its obstacles.", bounds.Center);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct Past(string Edge, double Distance, Vector Marker);
|
||||||
|
|
||||||
|
private readonly record struct Close(string Edge, double Gap, double Spacing, Vector Marker);
|
||||||
|
|
||||||
|
/// <summary>The furthest any non-rapid motion of the placed program runs past the sheet, or null.</summary>
|
||||||
|
private static Verdict? PastSheet(Part part, Box sheet, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Past? worst = null;
|
||||||
|
foreach (var move in ExecutionMotionReader.Read(part.Program, part.Location, null, cancellationToken).Motions)
|
||||||
|
{
|
||||||
|
if (move.Rapid || move.Curve == null)
|
||||||
|
continue;
|
||||||
|
worst = Furthest(worst, move.Curve.ToEntity().BoundingBox, move.Curve.Midpoint, sheet);
|
||||||
|
}
|
||||||
|
return Describe(worst);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Verdict? Describe(Past? past) => past is { } p
|
||||||
|
? new Verdict(true, $"runs {p.Distance:F3} past the plate's {p.Edge} edge.", p.Marker)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static Past? Furthest(Past? worst, Box box, Vector middle, Box sheet)
|
||||||
|
{
|
||||||
|
Consider("left", sheet.Left - box.Left, new Vector(sheet.Left, Clamp(middle.Y, sheet.Bottom, sheet.Top)));
|
||||||
|
Consider("bottom", sheet.Bottom - box.Bottom, new Vector(Clamp(middle.X, sheet.Left, sheet.Right), sheet.Bottom));
|
||||||
|
Consider("right", box.Right - sheet.Right, new Vector(sheet.Right, Clamp(middle.Y, sheet.Bottom, sheet.Top)));
|
||||||
|
Consider("top", box.Top - sheet.Top, new Vector(Clamp(middle.X, sheet.Left, sheet.Right), sheet.Top));
|
||||||
|
return worst;
|
||||||
|
|
||||||
|
void Consider(string edge, double distance, Vector marker)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(distance))
|
||||||
|
throw new ArgumentException("Part geometry must be finite.");
|
||||||
|
if (distance > Slack && (worst == null || distance > worst.Value.Distance))
|
||||||
|
worst = new Past(edge, distance, marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The side whose edge spacing the material falls furthest short of, or null.</summary>
|
||||||
|
private static Close? Shortest(Box bounds, Box sheet, Box work)
|
||||||
|
{
|
||||||
|
Close? worst = null;
|
||||||
|
var shortfall = 0.0;
|
||||||
|
var center = bounds.Center;
|
||||||
|
Consider("left", work.Left - bounds.Left, bounds.Left - sheet.Left, work.Left - sheet.Left,
|
||||||
|
new Vector((sheet.Left + bounds.Left) / 2, center.Y));
|
||||||
|
Consider("bottom", work.Bottom - bounds.Bottom, bounds.Bottom - sheet.Bottom, work.Bottom - sheet.Bottom,
|
||||||
|
new Vector(center.X, (sheet.Bottom + bounds.Bottom) / 2));
|
||||||
|
Consider("right", bounds.Right - work.Right, sheet.Right - bounds.Right, sheet.Right - work.Right,
|
||||||
|
new Vector((sheet.Right + bounds.Right) / 2, center.Y));
|
||||||
|
Consider("top", bounds.Top - work.Top, sheet.Top - bounds.Top, sheet.Top - work.Top,
|
||||||
|
new Vector(center.X, (sheet.Top + bounds.Top) / 2));
|
||||||
|
return worst;
|
||||||
|
|
||||||
|
void Consider(string edge, double by, double gap, double spacing, Vector marker)
|
||||||
|
{
|
||||||
|
if (by > Slack && by > shortfall)
|
||||||
|
{
|
||||||
|
shortfall = by;
|
||||||
|
worst = new Close(edge, System.Math.Max(0, gap), spacing, marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Clamp(double value, double low, double high) =>
|
||||||
|
double.IsFinite(value) ? System.Math.Clamp(value, low, high) : low;
|
||||||
|
|
||||||
|
private static bool IsInvalid(Exception exception) => exception is
|
||||||
|
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ public static class PlateOverlapAnalyzer
|
|||||||
public const double ChordTolerance = 0.001;
|
public const double ChordTolerance = 0.001;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Capture a whole plate including active clamps and nominal cut-off definitions.
|
/// Capture a whole plate including active clamps, nominal cut-off definitions and part spacing.
|
||||||
/// Use this entry for the desktop overlap check so manual cut-off contacts
|
/// Use this entry for the desktop overlap check so manual cut-off contacts
|
||||||
/// cannot be mistaken for a clear plate merely because cut-offs are not material.
|
/// cannot be mistaken for a clear plate merely because cut-offs are not material.
|
||||||
/// Clamp zones are mapped from the sheet's lower-left frame into the parts' signed
|
/// Clamp zones are mapped from the sheet's lower-left frame into the parts' signed
|
||||||
@@ -28,12 +28,28 @@ public static class PlateOverlapAnalyzer
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(plate);
|
ArgumentNullException.ThrowIfNull(plate);
|
||||||
var material = Capture(plate.Parts.ToArray(), ClampZoneFrame.ToPlacement(plate).ToArray(),
|
var parts = plate.Parts.ToArray();
|
||||||
|
var material = Capture(parts, ClampZoneFrame.ToPlacement(plate).ToArray(),
|
||||||
cache ?? new OverlapMaterialCache(), cancellationToken);
|
cache ?? new OverlapMaterialCache(), cancellationToken);
|
||||||
var cutoffs = ClampCutDiagnostics.Find(plate);
|
var cutoffs = ClampCutDiagnostics.Find(plate);
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(),
|
var issues = material.Issues.ToList();
|
||||||
material.Clamps.ToList(), cutoffs.ToList());
|
PlateEdgeReview.SheetFrame frame = null;
|
||||||
|
if (material.Parts.Count != 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
frame = PlateEdgeReview.SheetFrame.Of(plate);
|
||||||
|
}
|
||||||
|
catch (ArgumentException exception)
|
||||||
|
{
|
||||||
|
// Reported against the first part: the sheet itself cannot be checked.
|
||||||
|
issues.Add(new PlateOverlapIssue(material.Parts[0].Id, null, "Plate edge check: " + exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new PlateOverlapSnapshot(material.Parts.ToList(), issues,
|
||||||
|
material.Clamps.ToList(), cutoffs.ToList(), plate.PartSpacing,
|
||||||
|
PartSpacingReview.CommonLinePairs(plate, parts), frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -223,11 +239,18 @@ public static class PlateOverlapAnalyzer
|
|||||||
}
|
}
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken);
|
var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken);
|
||||||
|
var spacingPairs = new List<PlateSpacingPair>();
|
||||||
|
var spacingIssues = new List<PlateOverlapIssue>();
|
||||||
|
PartSpacingReview.Add(snapshot, pairs, issues, spacingPairs, spacingIssues, cancellationToken);
|
||||||
|
var edgeFindings = new List<PlateEdgeFinding>();
|
||||||
|
PlateEdgeReview.Add(snapshot, edgeFindings, spacingIssues, cancellationToken);
|
||||||
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
|
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
|
||||||
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
|
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
|
||||||
.ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings
|
.ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings
|
||||||
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(),
|
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(),
|
||||||
snapshot.CutOffClampFindings.ToList());
|
snapshot.CutOffClampFindings.ToList(), spacingPairs.OrderBy(pair => pair.PartAId)
|
||||||
|
.ThenBy(pair => pair.PartBId).ToList(), spacingIssues.OrderBy(issue => issue.PartAId)
|
||||||
|
.ThenBy(issue => issue.PartBId).ToList(), edgeFindings.OrderBy(finding => finding.PartId).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -348,10 +371,10 @@ public static class PlateOverlapAnalyzer
|
|||||||
new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top),
|
new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top),
|
||||||
new Vector(zone.Left, zone.Bottom), // Regions repeat their closing vertex exactly.
|
new Vector(zone.Left, zone.Bottom), // Regions repeat their closing vertex exactly.
|
||||||
};
|
};
|
||||||
var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
|
var center = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
|
||||||
return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
||||||
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, centre,
|
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, center,
|
||||||
centre);
|
center);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Polygon RectanglePolygon(Box box, Vector offset)
|
private static Polygon RectanglePolygon(Box box, Vector offset)
|
||||||
|
|||||||
@@ -9,13 +9,17 @@ public sealed class PlateOverlapReport
|
|||||||
{
|
{
|
||||||
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
|
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
|
||||||
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null,
|
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null,
|
||||||
List<CutOffClampFinding> cutOffClampFindings = null)
|
List<CutOffClampFinding> cutOffClampFindings = null, List<PlateSpacingPair> spacingPairs = null,
|
||||||
|
List<PlateOverlapIssue> spacingIssues = null, List<PlateEdgeFinding> edgeFindings = null)
|
||||||
{
|
{
|
||||||
Pairs = pairs.AsReadOnly();
|
Pairs = pairs.AsReadOnly();
|
||||||
Issues = issues.AsReadOnly();
|
Issues = issues.AsReadOnly();
|
||||||
Snapshot = snapshot;
|
Snapshot = snapshot;
|
||||||
ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly();
|
ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly();
|
||||||
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
||||||
|
SpacingPairs = (spacingPairs ?? new List<PlateSpacingPair>()).AsReadOnly();
|
||||||
|
SpacingIssues = (spacingIssues ?? new List<PlateOverlapIssue>()).AsReadOnly();
|
||||||
|
EdgeFindings = (edgeFindings ?? new List<PlateEdgeFinding>()).AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
|
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
|
||||||
@@ -33,7 +37,29 @@ public sealed class PlateOverlapReport
|
|||||||
/// <summary>Nominal cut-off/clamp contacts. Lines have no material area or part pair.</summary>
|
/// <summary>Nominal cut-off/clamp contacts. Lines have no material area or part pair.</summary>
|
||||||
public IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
public IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
||||||
|
|
||||||
public bool IsComplete => Issues.Count == 0;
|
/// <summary>
|
||||||
|
/// Pairs that share no material but sit closer than the plate's part spacing. Only a plate
|
||||||
|
/// capture carries the spacing; see <see cref="SpacingChecked"/>.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<PlateSpacingPair> SpacingPairs { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parts or pairs the spacing scan could not read. Kept apart from <see cref="Issues"/>,
|
||||||
|
/// which incremental rechecks reuse by overlap pair.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<PlateOverlapIssue> SpacingIssues { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parts off the sheet or inside its edge spacing, judged on clean material. Only a plate
|
||||||
|
/// capture carries the sheet; a bare group of parts has none. Unreadable parts join
|
||||||
|
/// <see cref="SpacingIssues"/>.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<PlateEdgeFinding> EdgeFindings { get; }
|
||||||
|
|
||||||
|
/// <summary>True when the input required a gap, so empty SpacingPairs means something.</summary>
|
||||||
|
public bool SpacingChecked => PartSpacingReview.Enforces(Snapshot.PartSpacing);
|
||||||
|
|
||||||
|
public bool IsComplete => Issues.Count == 0 && SpacingIssues.Count == 0;
|
||||||
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
|
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +121,7 @@ public sealed class PlateOverlapPair
|
|||||||
/// its pole of inaccessibility, or an interior scan-line point when a thin island defeats
|
/// its pole of inaccessibility, or an interior scan-line point when a thin island defeats
|
||||||
/// the bounded pole search. Markers, hit testing and inspection rings use this point.
|
/// the bounded pole search. Markers, hit testing and inspection rings use this point.
|
||||||
/// A tolerance-free tiny-clamp finding (a clamp narrower than the chord tolerance) keeps
|
/// A tolerance-free tiny-clamp finding (a clamp narrower than the chord tolerance) keeps
|
||||||
/// its conservative whole-clamp region, so its marker is the clamp's centre, within half
|
/// its conservative whole-clamp region, so its marker is the clamp's center, within half
|
||||||
/// the clamp's size of the material. Returned by value.
|
/// the clamp's size of the material. Returned by value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Vector Marker { get; }
|
public Vector Marker { get; }
|
||||||
@@ -103,6 +129,19 @@ public sealed class PlateOverlapPair
|
|||||||
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two parts closer than the plate's part spacing, ordered by zero-based input index.
|
||||||
|
/// <see cref="Marker"/> is the middle of the narrowest gap, in world coordinates.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PlateSpacingPair(int PartAId, int PartBId, string PartAName, string PartBName,
|
||||||
|
double Gap, Vector Marker);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A part off the sheet (<see cref="OffPlate"/>) or closer to its edge than the edge spacing.
|
||||||
|
/// <see cref="Marker"/> is where it leaves the sheet or the middle of its gap to the edge.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PlateEdgeFinding(int PartId, string PartName, bool OffPlate, string Message, Vector Marker);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One connected island of shared material in world coordinates, after hole subtraction:
|
/// One connected island of shared material in world coordinates, after hole subtraction:
|
||||||
/// a counterclockwise outline and its clockwise holes.
|
/// a counterclockwise outline and its clockwise holes.
|
||||||
@@ -144,12 +183,17 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message
|
|||||||
public sealed class PlateOverlapSnapshot
|
public sealed class PlateOverlapSnapshot
|
||||||
{
|
{
|
||||||
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues,
|
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues,
|
||||||
List<CapturedClamp> clamps = null, List<CutOffClampFinding> cutOffClampFindings = null)
|
List<CapturedClamp> clamps = null, List<CutOffClampFinding> cutOffClampFindings = null,
|
||||||
|
double partSpacing = 0, IReadOnlyDictionary<(int, int), double> commonLinePairs = null,
|
||||||
|
PlateEdgeReview.SheetFrame frame = null)
|
||||||
{
|
{
|
||||||
|
CommonLinePairs = commonLinePairs ?? new Dictionary<(int, int), double>();
|
||||||
Parts = parts.AsReadOnly();
|
Parts = parts.AsReadOnly();
|
||||||
Issues = issues.AsReadOnly();
|
Issues = issues.AsReadOnly();
|
||||||
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
|
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
|
||||||
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
||||||
|
PartSpacing = partSpacing;
|
||||||
|
Frame = frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
||||||
@@ -158,6 +202,16 @@ public sealed class PlateOverlapSnapshot
|
|||||||
/// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary>
|
/// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary>
|
||||||
internal IReadOnlyList<CapturedClamp> Clamps { get; }
|
internal IReadOnlyList<CapturedClamp> Clamps { get; }
|
||||||
internal IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
internal IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
||||||
|
|
||||||
|
/// <summary>The plate's part spacing; zero (no spacing check) for a bare group of parts.</summary>
|
||||||
|
internal double PartSpacing { get; }
|
||||||
|
|
||||||
|
/// <summary>Part ids (lower first) of each pair in one common-line group, with its kerf: held to
|
||||||
|
/// the kerf instead of <see cref="PartSpacing"/>.</summary>
|
||||||
|
internal IReadOnlyDictionary<(int, int), double> CommonLinePairs { get; }
|
||||||
|
|
||||||
|
/// <summary>The sheet the parts must stay on; null for a bare group of parts or an unusable sheet.</summary>
|
||||||
|
internal PlateEdgeReview.SheetFrame Frame { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
|
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
|
||||||
|
|||||||
@@ -19,12 +19,15 @@ public static class PostVerificationAnalyzer
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var report = Analyze(nest, cancellationToken);
|
var report = Analyze(nest, cancellationToken);
|
||||||
if (postProcessor is IPostVerificationSupport { PreservesPlacedProgramOrder: true })
|
var change = postProcessor is IPostVerificationSupport support
|
||||||
|
? support.PlacedProgramOrderChange(nest)
|
||||||
|
: "does not declare that it preserves placed part/contour order and pierce positions";
|
||||||
|
if (change == null)
|
||||||
return report;
|
return report;
|
||||||
var findings = report.Findings.ToList();
|
var findings = report.Findings.ToList();
|
||||||
findings.Add(new(PostVerificationKind.Incomplete, 0, null, null,
|
findings.Add(new(PostVerificationKind.Incomplete, 0, null, null,
|
||||||
$"Post '{postProcessor?.Name ?? "unknown"}' does not declare that it preserves placed part/contour order " +
|
$"Post '{postProcessor?.Name ?? "unknown"}' {change}. " +
|
||||||
"and pierce positions. Nest-level checks ran, but the final rapid sequence requires manual review."));
|
"Nest-level checks ran, but the final rapid sequence requires manual review."));
|
||||||
return new PostVerificationReport(findings);
|
return new PostVerificationReport(findings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +104,13 @@ public static class PostVerificationAnalyzer
|
|||||||
"Clamp/cut-off check: " + exception.Message));
|
"Clamp/cut-off check: " + exception.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var obstacles = new ReleasedContourState();
|
// A common-line member cut once ends in open remainder pieces that cut it free.
|
||||||
|
var members = (plate.CommonLines ?? [])
|
||||||
|
.SelectMany(group => group.Members)
|
||||||
|
.Select(member => plate.Parts.IndexOf(member) + 1)
|
||||||
|
.Where(number => number > 0)
|
||||||
|
.ToHashSet();
|
||||||
|
var obstacles = new ReleasedContourState(true, null, members.Count == 0 ? null : members.Contains);
|
||||||
Vector? position = startPoint;
|
Vector? position = startPoint;
|
||||||
for (var index = 0; index < plate.Parts.Count; index++)
|
for (var index = 0; index < plate.Parts.Count; index++)
|
||||||
{
|
{
|
||||||
@@ -144,8 +153,7 @@ public static class PostVerificationAnalyzer
|
|||||||
&& move.Layer is LayerType.Cut or LayerType.Display
|
&& move.Layer is LayerType.Cut or LayerType.Display
|
||||||
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
||||||
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
||||||
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken,
|
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
|
||||||
proveOpenCuts: true);
|
|
||||||
if (clamps is { Count: > 0 })
|
if (clamps is { Count: > 0 })
|
||||||
foreach (var move in moves)
|
foreach (var move in moves)
|
||||||
{
|
{
|
||||||
@@ -224,8 +232,12 @@ public static class PostVerificationAnalyzer
|
|||||||
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
|
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
|
||||||
// Parts that do not overlap may still be closer than the plate's part spacing.
|
// Parts that do not overlap may still be closer than the plate's part spacing.
|
||||||
PartSpacingReview.Add(plate, plateNumber, materialParts, indices, overlap, findings, cancellationToken);
|
PartSpacingReview.Add(plate, plateNumber, materialParts, indices, overlap, findings, cancellationToken);
|
||||||
|
// Parts must stay on the sheet and keep its edge spacing.
|
||||||
|
PlateEdgeReview.Add(plate, plateNumber, findings, cancellationToken);
|
||||||
// Parts the support bed cannot hold level need a tab that keeps them level.
|
// Parts the support bed cannot hold level need a tab that keeps them level.
|
||||||
PartSupportReview.Add(plate, plateNumber, findings, cancellationToken);
|
PartSupportReview.Add(plate, plateNumber, findings, cancellationToken);
|
||||||
|
// Common-line groups still share their spans at the kerf, and no lead cuts a partner.
|
||||||
|
CommonLineReview.Add(plate, plateNumber, findings, cancellationToken);
|
||||||
}
|
}
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
return new PostVerificationReport(findings);
|
return new PostVerificationReport(findings);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Diagnostics;
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ internal static class PostVerificationGeometry
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The relative rounding allowance of native line/circle intersections. Their discriminant
|
/// The relative rounding allowance of native line/circle intersections. Their discriminant
|
||||||
/// b^2 - 4ac cancels: its absolute error stays below about 30 units of roundoff times
|
/// b^2 - 4ac cancels: its absolute error stays below about 30 units of roundoff times
|
||||||
/// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's centre to the line's start), so a
|
/// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's center to the line's start), so a
|
||||||
/// spurious root can lie at most about 1e-7 times the span of both extents beyond the true
|
/// spurious root can lie at most about 1e-7 times the span of both extents beyond the true
|
||||||
/// distance between line and circle (about 2e-9 is seen in practice). A long lead beside a
|
/// distance between line and circle (about 2e-9 is seen in practice). A long lead beside a
|
||||||
/// small circle needs the full check; a short lead beside a nearby part does not.
|
/// small circle needs the full check; a short lead beside a nearby part does not.
|
||||||
@@ -68,6 +69,10 @@ internal static class PostVerificationGeometry
|
|||||||
return IsClearOf(other, ClearMargin + reach + SpanRounding * span);
|
return IsClearOf(other, ClearMargin + reach + SpanRounding * span);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="point"/> lies within the extent, its edges included.</summary>
|
||||||
|
internal bool Contains(Vector point) =>
|
||||||
|
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
|
||||||
|
|
||||||
/// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary>
|
/// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary>
|
||||||
internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit
|
internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit
|
||||||
&& (y > MaxY + ClearMargin || y < MinY - ClearMargin);
|
&& (y > MaxY + ClearMargin || y < MinY - ClearMargin);
|
||||||
@@ -239,8 +244,8 @@ internal static class PostVerificationGeometry
|
|||||||
System.Math.Max(Start.X, End.X), System.Math.Max(Start.Y, End.Y));
|
System.Math.Max(Start.X, End.X), System.Math.Max(Start.Y, End.Y));
|
||||||
|
|
||||||
internal Vector Midpoint => Center is { } center
|
internal Vector Midpoint => Center is { } center
|
||||||
? new Vector(center.X + Radius * System.Math.Cos(StartAngle + Sweep / 2),
|
? new Vector(center.X + Radius * PortableMath.Cos(StartAngle + Sweep / 2),
|
||||||
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
|
center.Y + Radius * PortableMath.Sin(StartAngle + Sweep / 2))
|
||||||
: (Start + End) * 0.5;
|
: (Start + End) * 0.5;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -283,7 +288,7 @@ internal static class PostVerificationGeometry
|
|||||||
|
|
||||||
internal double DistanceAlong(Vector point) => Center is { } center
|
internal double DistanceAlong(Vector point) => Center is { } center
|
||||||
? point.DistanceTo(Start) <= Epsilon ? 0
|
? point.DistanceTo(Start) <= Epsilon ? 0
|
||||||
: Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)) * Radius
|
: Travel(PortableMath.Atan2(point.Y - center.Y, point.X - center.X)) * Radius
|
||||||
: Dot(point - Start, (End - Start) * (1 / Length));
|
: Dot(point - Start, (End - Start) * (1 / Length));
|
||||||
|
|
||||||
/// <summary>Unit direction of travel at the curve's start or end.</summary>
|
/// <summary>Unit direction of travel at the curve's start or end.</summary>
|
||||||
@@ -310,8 +315,8 @@ internal static class PostVerificationGeometry
|
|||||||
if (distance <= 0) return Start;
|
if (distance <= 0) return Start;
|
||||||
if (distance >= Length) return End;
|
if (distance >= Length) return End;
|
||||||
return Center is { } center
|
return Center is { } center
|
||||||
? center + new Vector(System.Math.Cos(StartAngle + System.Math.Sign(Sweep) * distance / Radius),
|
? center + new Vector(PortableMath.Cos(StartAngle + System.Math.Sign(Sweep) * distance / Radius),
|
||||||
System.Math.Sin(StartAngle + System.Math.Sign(Sweep) * distance / Radius)) * Radius
|
PortableMath.Sin(StartAngle + System.Math.Sign(Sweep) * distance / Radius)) * Radius
|
||||||
: Start + (End - Start) * (distance / Length);
|
: Start + (End - Start) * (distance / Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +332,7 @@ internal static class PostVerificationGeometry
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal Curve Until(Vector point) => Center is { } center
|
internal Curve Until(Vector point) => Center is { } center
|
||||||
? new Curve(Start, point, center, Radius,
|
? new Curve(Start, point, center, Radius,
|
||||||
System.Math.Sign(Sweep) * Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)))
|
System.Math.Sign(Sweep) * Travel(PortableMath.Atan2(point.Y - center.Y, point.X - center.X)))
|
||||||
: new Curve(Start, point, null, 0, 0);
|
: new Curve(Start, point, null, 0, 0);
|
||||||
|
|
||||||
internal bool Contains(Vector point) => Contains(point, Epsilon);
|
internal bool Contains(Vector point) => Contains(point, Epsilon);
|
||||||
@@ -347,33 +352,53 @@ internal static class PostVerificationGeometry
|
|||||||
|
|
||||||
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
||||||
{
|
{
|
||||||
var entity = nativeEntity.Value;
|
var same = SameSupport(other);
|
||||||
var candidate = other.nativeEntity.Value;
|
// Two curves that meet end to end share that vertex, and the native query is
|
||||||
// Native arc filters discard NaN supporting-circle intersections. Inspect
|
// ill-conditioned there: circles meeting at a shallow angle, or a line meeting its own
|
||||||
// both unfiltered queries first: roundoff can differ by operand direction.
|
// fillet, put their root up to 1e-5 off the joint, which no joint tolerance can tell
|
||||||
// Coincident supports have separate overlap handling below.
|
// from a cut that touches beside it, and a fillet's circle tangent inside the arc it
|
||||||
if (Center is { } center && other.Center is { } otherCenter && !SameSupport(other))
|
// continues gives the supporting-circle query no root at all. Both shapes are known,
|
||||||
{
|
// so their other intersection, if there is one, follows from the shared vertex
|
||||||
var support = new Circle(center, Radius);
|
// exactly instead, and no native query runs for the pair.
|
||||||
var otherSupport = new Circle(otherCenter, other.Radius);
|
var joint = same ? null : SharedVertex(other);
|
||||||
support.Intersects(otherSupport, out var forward);
|
|
||||||
otherSupport.Intersects(support, out var reverse);
|
|
||||||
foreach (var point in forward)
|
|
||||||
Validate(point);
|
|
||||||
foreach (var point in reverse)
|
|
||||||
Validate(point);
|
|
||||||
}
|
|
||||||
List<Vector> points;
|
List<Vector> points;
|
||||||
bool intersects;
|
if (joint is { } vertex)
|
||||||
switch (candidate)
|
|
||||||
{
|
{
|
||||||
case Line line: intersects = entity.Intersects(line, out points); break;
|
points = [vertex];
|
||||||
case Arc arc: intersects = entity.Intersects(arc, out points); break;
|
if (SecondIntersection(other, vertex) is { } second && second.DistanceTo(vertex) > Epsilon
|
||||||
case Circle circle: intersects = entity.Intersects(circle, out points); break;
|
&& Contains(second) && other.Contains(second)
|
||||||
default: throw new NotSupportedException("Unsupported native boundary.");
|
&& !IsOutsideArcSpan(second) && !other.IsOutsideArcSpan(second))
|
||||||
|
points.Add(second);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var entity = nativeEntity.Value;
|
||||||
|
var candidate = other.nativeEntity.Value;
|
||||||
|
// Native arc filters discard NaN supporting-circle intersections. Inspect
|
||||||
|
// both unfiltered queries first: roundoff can differ by operand direction.
|
||||||
|
// Coincident supports have separate overlap handling below.
|
||||||
|
if (Center is { } center && other.Center is { } otherCenter && !same)
|
||||||
|
{
|
||||||
|
var support = new Circle(center, Radius);
|
||||||
|
var otherSupport = new Circle(otherCenter, other.Radius);
|
||||||
|
support.Intersects(otherSupport, out var forward);
|
||||||
|
otherSupport.Intersects(support, out var reverse);
|
||||||
|
foreach (var point in forward)
|
||||||
|
Validate(point);
|
||||||
|
foreach (var point in reverse)
|
||||||
|
Validate(point);
|
||||||
|
}
|
||||||
|
bool intersects;
|
||||||
|
switch (candidate)
|
||||||
|
{
|
||||||
|
case Line line: intersects = entity.Intersects(line, out points); break;
|
||||||
|
case Arc arc: intersects = entity.Intersects(arc, out points); break;
|
||||||
|
case Circle circle: intersects = entity.Intersects(circle, out points); break;
|
||||||
|
default: throw new NotSupportedException("Unsupported native boundary.");
|
||||||
|
}
|
||||||
|
if (!intersects)
|
||||||
|
points.Clear();
|
||||||
}
|
}
|
||||||
if (!intersects)
|
|
||||||
points.Clear();
|
|
||||||
// Coincident circles produce NaNs in the native discrete-contact query.
|
// Coincident circles produce NaNs in the native discrete-contact query.
|
||||||
// Their support overlap is handled separately, without inventing crossings.
|
// Their support overlap is handled separately, without inventing crossings.
|
||||||
// Supports that agree only within Epsilon (arcs of one circle whose centers were
|
// Supports that agree only within Epsilon (arcs of one circle whose centers were
|
||||||
@@ -382,24 +407,25 @@ internal static class PostVerificationGeometry
|
|||||||
// still refuse, while arcs that only meet at a joint or lie apart are not overlap.
|
// still refuse, while arcs that only meet at a joint or lie apart are not overlap.
|
||||||
var witness = Center is { } c && other.Center is { } oc
|
var witness = Center is { } c && other.Center is { } oc
|
||||||
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius) ? RaySeamTolerance : Epsilon;
|
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius) ? RaySeamTolerance : Epsilon;
|
||||||
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other, witness)
|
overlap = same && (InteriorWitness(Midpoint, other, witness)
|
||||||
|| InteriorWitness(other.Midpoint, this, witness)
|
|| InteriorWitness(other.Midpoint, this, witness)
|
||||||
|| InteriorWitness(Start, other, witness) || InteriorWitness(End, other, witness)
|
|| InteriorWitness(Start, other, witness) || InteriorWitness(End, other, witness)
|
||||||
|| InteriorWitness(other.Start, this, witness) || InteriorWitness(other.End, this, witness));
|
|| InteriorWitness(other.Start, this, witness) || InteriorWitness(other.End, this, witness));
|
||||||
if (SameSupport(other))
|
if (same)
|
||||||
points.Clear();
|
points.Clear();
|
||||||
foreach (var point in points)
|
foreach (var point in points)
|
||||||
Validate(point);
|
Validate(point);
|
||||||
var nativeContactCount = points.Count;
|
// Analytic contacts of adjacent curves are certain; the ray guard below is for native queries.
|
||||||
|
var nativeContactCount = joint.HasValue ? int.MaxValue : points.Count;
|
||||||
foreach (var point in new[] { Start, End, other.Start, other.End })
|
foreach (var point in new[] { Start, End, other.Start, other.End })
|
||||||
if (Contains(point) && other.Contains(point)
|
if (Contains(point) && other.Contains(point)
|
||||||
&& !points.Exists(p => p.DistanceTo(point) <= Epsilon))
|
&& !points.Exists(p => p.DistanceTo(point) <= Epsilon))
|
||||||
points.Add(point);
|
points.Add(point);
|
||||||
// Existing exact line/ray contact semantics guard native queries which
|
// Existing exact line/ray contact semantics guard native queries which
|
||||||
// suppress very short or nearly parallel intersections. Uncertainty refuses.
|
// suppress very short or nearly parallel intersections. Uncertainty refuses.
|
||||||
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other))
|
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other, points))
|
||||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||||
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this))
|
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this, points))
|
||||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||||
return points;
|
return points;
|
||||||
|
|
||||||
@@ -408,6 +434,49 @@ internal static class PostVerificationGeometry
|
|||||||
|| (point.DistanceTo(curve.Start) > tolerance && point.DistanceTo(curve.End) > tolerance));
|
|| (point.DistanceTo(curve.Start) > tolerance && point.DistanceTo(curve.End) > tolerance));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The end this curve and <paramref name="other"/> share, within <see cref="Epsilon"/>, as
|
||||||
|
/// this curve has it; null when they share none or either is a full circle.
|
||||||
|
/// </summary>
|
||||||
|
private Vector? SharedVertex(Curve other)
|
||||||
|
{
|
||||||
|
if (Center.HasValue && !IsFiniteArc || other.Center.HasValue && !other.IsFiniteArc)
|
||||||
|
return null;
|
||||||
|
foreach (var end in new[] { End, Start })
|
||||||
|
if (end.DistanceTo(other.Start) <= Epsilon || end.DistanceTo(other.End) <= Epsilon)
|
||||||
|
return end;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the supports of this curve and <paramref name="other"/> meet again, given that
|
||||||
|
/// they meet at <paramref name="vertex"/>: two circles meet again at the vertex's mirror
|
||||||
|
/// image across the line through their centers, and a line meets a circle again at the
|
||||||
|
/// far end of its chord through the vertex. Two lines meet only once. The result is on
|
||||||
|
/// both supports; it may be the vertex itself (tangency) or off either finite span.
|
||||||
|
/// </summary>
|
||||||
|
private Vector? SecondIntersection(Curve other, Vector vertex)
|
||||||
|
{
|
||||||
|
if (Center is { } center && other.Center is { } otherCenter)
|
||||||
|
{
|
||||||
|
var axis = otherCenter - center;
|
||||||
|
var distance = otherCenter.DistanceTo(center);
|
||||||
|
if (distance <= Epsilon)
|
||||||
|
return null;
|
||||||
|
var unit = axis * (1 / distance);
|
||||||
|
var foot = center + unit * Dot(vertex - center, unit);
|
||||||
|
return foot * 2 - vertex;
|
||||||
|
}
|
||||||
|
if (Center is null && other.Center is null)
|
||||||
|
return null;
|
||||||
|
var line = Center is null ? this : other;
|
||||||
|
var circle = Center is null ? other.Center!.Value : Center!.Value;
|
||||||
|
if (line.Length <= Epsilon)
|
||||||
|
return null;
|
||||||
|
var direction = (line.End - line.Start) * (1 / line.Length);
|
||||||
|
return vertex + direction * (2 * Dot(circle - vertex, direction));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether exact ray semantics find <paramref name="curve"/> touching <paramref name="line"/>
|
/// Whether exact ray semantics find <paramref name="curve"/> touching <paramref name="line"/>
|
||||||
/// anywhere except at a line endpoint the curve contains, which is already a recorded contact.
|
/// anywhere except at a line endpoint the curve contains, which is already a recorded contact.
|
||||||
@@ -415,16 +484,35 @@ internal static class PostVerificationGeometry
|
|||||||
/// endpoint (both stop half-way when both endpoints are contained): a line meeting a
|
/// endpoint (both stop half-way when both endpoints are contained): a line meeting a
|
||||||
/// tangent arc at their shared vertex is otherwise rediscovered there as a contact the
|
/// tangent arc at their shared vertex is otherwise rediscovered there as a contact the
|
||||||
/// native query missed, because rounding can drop the tangent root of its quadratic.
|
/// native query missed, because rounding can drop the tangent root of its quadratic.
|
||||||
|
/// For the same reason a ray hit within <see cref="RaySeamTolerance"/> of a
|
||||||
|
/// <paramref name="recorded"/> contact is that contact: an arc lead ending tangent on a
|
||||||
|
/// straight edge, mid-edge, has its end recorded and its double root dropped.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool ExactLineContact(Curve line, Curve curve)
|
private static bool ExactLineContact(Curve line, Curve curve, IReadOnlyList<Vector> recorded)
|
||||||
{
|
{
|
||||||
var direction = (line.End - line.Start) * (1 / line.Length);
|
var direction = (line.End - line.Start) * (1 / line.Length);
|
||||||
var startRecorded = curve.Contains(line.Start);
|
var startRecorded = curve.Contains(line.Start);
|
||||||
var endRecorded = curve.Contains(line.End);
|
var endRecorded = curve.Contains(line.End);
|
||||||
var forward = endRecorded ? startRecorded ? line.Length / 2 : 0 : line.Length;
|
var forward = endRecorded ? startRecorded ? line.Length / 2 : 0 : line.Length;
|
||||||
var backward = startRecorded ? endRecorded ? line.Length / 2 : 0 : line.Length;
|
var backward = startRecorded ? endRecorded ? line.Length / 2 : 0 : line.Length;
|
||||||
return (forward > 0 && curve.ContactAfterStart(line.Start, direction, forward))
|
return (forward > 0 && Unrecorded(line.Start, direction, forward))
|
||||||
|| (backward > 0 && curve.ContactAfterStart(line.End, direction * -1, backward));
|
|| (backward > 0 && Unrecorded(line.End, direction * -1, backward));
|
||||||
|
|
||||||
|
bool Unrecorded(Vector origin, Vector ray, double length)
|
||||||
|
{
|
||||||
|
var hits = new List<double>();
|
||||||
|
curve.ContactAfterStart(origin, ray, length, hits);
|
||||||
|
foreach (var distance in hits)
|
||||||
|
{
|
||||||
|
var hit = origin + ray * distance;
|
||||||
|
var known = false;
|
||||||
|
foreach (var point in recorded)
|
||||||
|
known |= point.DistanceTo(hit) <= RaySeamTolerance;
|
||||||
|
if (!known)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -437,24 +525,24 @@ internal static class PostVerificationGeometry
|
|||||||
if (radius <= Epsilon || !double.IsFinite(radius)
|
if (radius <= Epsilon || !double.IsFinite(radius)
|
||||||
|| System.Math.Abs(radius - end.DistanceTo(c)) > Epsilon * System.Math.Max(1, radius))
|
|| System.Math.Abs(radius - end.DistanceTo(c)) > Epsilon * System.Math.Max(1, radius))
|
||||||
throw new ArgumentException("Arc has zero or inconsistent radius.");
|
throw new ArgumentException("Arc has zero or inconsistent radius.");
|
||||||
var a = System.Math.Atan2(start.Y - c.Y, start.X - c.X);
|
var a = PortableMath.Atan2(start.Y - c.Y, start.X - c.X);
|
||||||
var b = System.Math.Atan2(end.Y - c.Y, end.X - c.X);
|
var b = PortableMath.Atan2(end.Y - c.Y, end.X - c.X);
|
||||||
var sweep = start.DistanceTo(end) <= Epsilon ? TwoPi
|
var sweep = start.DistanceTo(end) <= Epsilon ? TwoPi
|
||||||
: Normalize(clockwise ? a - b : b - a);
|
: Normalize(clockwise ? a - b : b - a);
|
||||||
return new Curve(start, end, c, radius, clockwise ? -sweep : sweep);
|
return new Curve(start, end, c, radius, clockwise ? -sweep : sweep);
|
||||||
}
|
}
|
||||||
|
|
||||||
private double StartAngle => System.Math.Atan2(Start.Y - Center.Value.Y, Start.X - Center.Value.X);
|
private double StartAngle => PortableMath.Atan2(Start.Y - Center.Value.Y, Start.X - Center.Value.X);
|
||||||
private double Travel(double angle) => Normalize(Sweep < 0 ? StartAngle - angle : angle - StartAngle);
|
private double Travel(double angle) => Normalize(Sweep < 0 ? StartAngle - angle : angle - StartAngle);
|
||||||
private bool OnArc(Vector point) => Travel(System.Math.Atan2(point.Y - Center.Value.Y,
|
private bool OnArc(Vector point) => Travel(PortableMath.Atan2(point.Y - Center.Value.Y,
|
||||||
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|
||||||
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
|
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
|
||||||
|
|
||||||
// How far past r^2 a squared distance from the centre still counts as touching the arc.
|
// How far past r^2 a squared distance from the center still counts as touching the arc.
|
||||||
// For small arcs this band reaches well beyond the radius (sqrt(1e-8) = 1e-4 as r -> 0).
|
// For small arcs this band reaches well beyond the radius (sqrt(1e-8) = 1e-4 as r -> 0).
|
||||||
private double ContactSlack => Epsilon * System.Math.Max(1, Radius * 2);
|
private double ContactSlack => Epsilon * System.Math.Max(1, Radius * 2);
|
||||||
|
|
||||||
// The largest distance from the centre that ContactAfterStart can count as contact.
|
// The largest distance from the center that ContactAfterStart can count as contact.
|
||||||
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
|
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
|
||||||
|
|
||||||
internal bool ContactAfterStart(Vector origin, Vector direction, double length,
|
internal bool ContactAfterStart(Vector origin, Vector direction, double length,
|
||||||
@@ -550,13 +638,13 @@ internal static class PostVerificationGeometry
|
|||||||
{
|
{
|
||||||
var a = StartAngle + System.Math.Sign(Sweep) * breaks[i - 1];
|
var a = StartAngle + System.Math.Sign(Sweep) * breaks[i - 1];
|
||||||
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
|
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
|
||||||
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
|
var ya = i == 1 ? Start.Y : center.Y + Radius * PortableMath.Sin(a);
|
||||||
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
|
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * PortableMath.Sin(b);
|
||||||
if ((ya > rayY) == (yb > rayY))
|
if ((ya > rayY) == (yb > rayY))
|
||||||
continue;
|
continue;
|
||||||
var dy = point.Y - center.Y;
|
var dy = point.Y - center.Y;
|
||||||
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
|
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
|
||||||
var x = center.X + (System.Math.Cos((a + b) / 2) >= 0 ? dx : -dx);
|
var x = center.X + (PortableMath.Cos((a + b) / 2) >= 0 ? dx : -dx);
|
||||||
if (x > point.X)
|
if (x > point.X)
|
||||||
inside = !inside;
|
inside = !inside;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,25 @@ public enum PostVerificationKind
|
|||||||
/// A part its plate's support bed cannot hold level has no tab that keeps it level, so it may
|
/// A part its plate's support bed cannot hold level has no tab that keeps it level, so it may
|
||||||
/// tip once cut free (see docs/support-tabs.md).
|
/// tip once cut free (see docs/support-tabs.md).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
PartSupport
|
PartSupport,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A part's cuts, leads or scribes run past the sheet, or its material lies outside an outlined
|
||||||
|
/// sheet or over one of its obstacles.
|
||||||
|
/// </summary>
|
||||||
|
OffPlate,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A part's clean material is closer to the sheet's edge than the plate's edge spacing, judged as
|
||||||
|
/// Auto Nest's layout check judges it.
|
||||||
|
/// </summary>
|
||||||
|
EdgeSpacing,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A common-line group (see docs/common-line-cutting.md) no longer shares its spans one kerf
|
||||||
|
/// apart, was placed for another kerf, or a member's lead runs into its partner.
|
||||||
|
/// </summary>
|
||||||
|
CommonLine
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
||||||
@@ -92,6 +110,12 @@ public sealed class PostVerificationReport
|
|||||||
Summary(PostVerificationKind.PartSpacing, "Part spacing");
|
Summary(PostVerificationKind.PartSpacing, "Part spacing");
|
||||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSupport))
|
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSupport))
|
||||||
Summary(PostVerificationKind.PartSupport, "Part support");
|
Summary(PostVerificationKind.PartSupport, "Part support");
|
||||||
|
if (Findings.Any(finding => finding.Kind == PostVerificationKind.OffPlate))
|
||||||
|
Summary(PostVerificationKind.OffPlate, "Off the plate");
|
||||||
|
if (Findings.Any(finding => finding.Kind == PostVerificationKind.EdgeSpacing))
|
||||||
|
Summary(PostVerificationKind.EdgeSpacing, "Outside work area");
|
||||||
|
if (Findings.Any(finding => finding.Kind == PostVerificationKind.CommonLine))
|
||||||
|
Summary(PostVerificationKind.CommonLine, "Common lines");
|
||||||
foreach (var finding in Findings)
|
foreach (var finding in Findings)
|
||||||
{
|
{
|
||||||
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
|
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Dimensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves dimensions against the entities being cut (visible, with roles, breakoffs and
|
||||||
|
/// tabbed cutouts applied), so a dimension measures the part as it will come off the table.
|
||||||
|
/// Radius and diameter dimensions find their circle or arc by ID among the visible entities.
|
||||||
|
/// </summary>
|
||||||
|
public static class DimensionBuilder
|
||||||
|
{
|
||||||
|
private const double DefaultOffsetFactor = 0.1;
|
||||||
|
private const double RadialOffsetFactor = 0.05;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The extents of the geometry that bounds the part, or null if there is none. Source marks
|
||||||
|
/// keep their own layer objects, so <see cref="SpecialLayers.IsMaterial"/> alone would count
|
||||||
|
/// them; break cuts are skipped because they stay inside the outline they join.
|
||||||
|
/// </summary>
|
||||||
|
public static Box Extents(IEnumerable<Entity> entities)
|
||||||
|
{
|
||||||
|
var measured = Measured(entities);
|
||||||
|
return measured.Count == 0 ? null : measured.GetBoundingBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Entity> Measured(IEnumerable<Entity> entities) =>
|
||||||
|
entities
|
||||||
|
.Where(e => SpecialLayers.IsMaterial(e.Layer) && !ScribeLayerRepair.IsMarkLayer(e.Layer))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public static DimensionLayout Layout(IEnumerable<Entity> entities, DrawingDimension dimension)
|
||||||
|
{
|
||||||
|
var kind = dimension.Kind;
|
||||||
|
if (dimension.Offset == 0 || !double.IsFinite(dimension.Offset))
|
||||||
|
return new DimensionLayout { Kind = kind, Error = "its offset is not usable" };
|
||||||
|
|
||||||
|
if (dimension.IsRadial)
|
||||||
|
return RadialLayout(entities, dimension);
|
||||||
|
|
||||||
|
var measured = Measured(entities);
|
||||||
|
if (measured.Count == 0)
|
||||||
|
return new DimensionLayout { Kind = kind, Error = "there is no cut geometry to measure" };
|
||||||
|
|
||||||
|
var box = measured.GetBoundingBox();
|
||||||
|
|
||||||
|
var offset = dimension.Offset;
|
||||||
|
if (kind == DimensionKind.OverallHorizontal)
|
||||||
|
{
|
||||||
|
if (box.Length <= 0)
|
||||||
|
return new DimensionLayout { Kind = kind, Error = "the part has no horizontal size" };
|
||||||
|
|
||||||
|
var edge = offset > 0 ? box.Top : box.Bottom;
|
||||||
|
return new DimensionLayout
|
||||||
|
{
|
||||||
|
Kind = kind,
|
||||||
|
Extents = box,
|
||||||
|
Profile = measured,
|
||||||
|
Value = box.Length,
|
||||||
|
ExtensionStart = new Vector(box.Left, edge),
|
||||||
|
ExtensionEnd = new Vector(box.Right, edge),
|
||||||
|
LineStart = new Vector(box.Left, edge + offset),
|
||||||
|
LineEnd = new Vector(box.Right, edge + offset),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (box.Width <= 0)
|
||||||
|
return new DimensionLayout { Kind = kind, Error = "the part has no vertical size" };
|
||||||
|
|
||||||
|
var side = offset > 0 ? box.Right : box.Left;
|
||||||
|
return new DimensionLayout
|
||||||
|
{
|
||||||
|
Kind = kind,
|
||||||
|
Extents = box,
|
||||||
|
Profile = measured,
|
||||||
|
Value = box.Width,
|
||||||
|
ExtensionStart = new Vector(side, box.Bottom),
|
||||||
|
ExtensionEnd = new Vector(side, box.Top),
|
||||||
|
LineStart = new Vector(side + offset, box.Bottom),
|
||||||
|
LineEnd = new Vector(side + offset, box.Top),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The overall dimensions <paramref name="existing"/> does not have yet, below and to the
|
||||||
|
/// right of the part and a tenth of its larger size away. Empty when there is nothing to
|
||||||
|
/// measure.
|
||||||
|
/// </summary>
|
||||||
|
public static List<DrawingDimension> MissingOverall(IEnumerable<Entity> entities,
|
||||||
|
IEnumerable<DrawingDimension> existing)
|
||||||
|
{
|
||||||
|
var added = new List<DrawingDimension>();
|
||||||
|
var box = Extents(entities);
|
||||||
|
if (box == null)
|
||||||
|
return added;
|
||||||
|
|
||||||
|
var offset = System.Math.Max(box.Length, box.Width) * DefaultOffsetFactor;
|
||||||
|
if (offset <= 0)
|
||||||
|
return added;
|
||||||
|
|
||||||
|
var kinds = existing.Select(d => d.Kind).ToHashSet();
|
||||||
|
if (!kinds.Contains(DimensionKind.OverallHorizontal))
|
||||||
|
added.Add(new DrawingDimension { Kind = DimensionKind.OverallHorizontal, Offset = -offset });
|
||||||
|
if (!kinds.Contains(DimensionKind.OverallVertical))
|
||||||
|
added.Add(new DrawingDimension { Kind = DimensionKind.OverallVertical, Offset = offset });
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <paramref name="kind"/> dimension for each circle or arc in <paramref name="curves"/>
|
||||||
|
/// that does not have one yet. On a circle the leader points up and right for a radius and
|
||||||
|
/// up and left for a diameter, so the two do not overlap; on an arc it points through the
|
||||||
|
/// middle. The label sits outside the curve by half its radius, or a twentieth of
|
||||||
|
/// the part's larger size when that is more.
|
||||||
|
/// </summary>
|
||||||
|
public static List<DrawingDimension> MissingRadial(IEnumerable<Entity> curves, DimensionKind kind,
|
||||||
|
IEnumerable<Entity> part, IEnumerable<DrawingDimension> existing)
|
||||||
|
{
|
||||||
|
var added = new List<DrawingDimension>();
|
||||||
|
if (!DrawingDimension.IsRadialKind(kind))
|
||||||
|
return added;
|
||||||
|
|
||||||
|
var box = Extents(part);
|
||||||
|
var partSize = box == null ? 0 : System.Math.Max(box.Length, box.Width);
|
||||||
|
var taken = existing.Where(d => d.Kind == kind).Select(d => d.EntityId).ToHashSet();
|
||||||
|
foreach (var curve in curves)
|
||||||
|
{
|
||||||
|
if (curve.Id == Guid.Empty || !taken.Add(curve.Id) || !TryCurve(curve, out _, out var radius, out var arc))
|
||||||
|
continue;
|
||||||
|
added.Add(new DrawingDimension
|
||||||
|
{
|
||||||
|
Kind = kind,
|
||||||
|
EntityId = curve.Id,
|
||||||
|
Angle = arc != null ? MidAngle(arc)
|
||||||
|
: kind == DimensionKind.Radius ? Angle.HalfPI / 2 : Angle.HalfPI * 1.5,
|
||||||
|
Offset = System.Math.Max(radius / 2, partSize * RadialOffsetFactor),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether <paramref name="entity"/> is a circle or arc a radius or diameter can measure.</summary>
|
||||||
|
public static bool IsRadialTarget(Entity entity) => TryCurve(entity, out _, out _, out _);
|
||||||
|
|
||||||
|
private static bool TryCurve(Entity entity, out Vector center, out double radius, out Arc arc)
|
||||||
|
{
|
||||||
|
arc = null;
|
||||||
|
switch (entity)
|
||||||
|
{
|
||||||
|
case Circle circle:
|
||||||
|
center = circle.Center;
|
||||||
|
radius = circle.Radius;
|
||||||
|
break;
|
||||||
|
case Arc a:
|
||||||
|
center = a.Center;
|
||||||
|
radius = a.Radius;
|
||||||
|
arc = a.IsFullCircle() ? null : a;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
center = default;
|
||||||
|
radius = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return radius > 0 && double.IsFinite(radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DimensionLayout RadialLayout(IEnumerable<Entity> entities, DrawingDimension dimension)
|
||||||
|
{
|
||||||
|
var kind = dimension.Kind;
|
||||||
|
if (!double.IsFinite(dimension.Angle))
|
||||||
|
return new DimensionLayout { Kind = kind, Error = "its leader angle is not usable" };
|
||||||
|
|
||||||
|
var entity = dimension.EntityId == Guid.Empty
|
||||||
|
? null
|
||||||
|
: entities.FirstOrDefault(e => e.Id == dimension.EntityId);
|
||||||
|
if (entity == null)
|
||||||
|
return new DimensionLayout { Kind = kind, Error = "its circle or arc is not cut" };
|
||||||
|
if (!TryCurve(entity, out var center, out var radius, out var arc))
|
||||||
|
return new DimensionLayout { Kind = kind, Error = "it no longer measures a circle or arc" };
|
||||||
|
|
||||||
|
var reach = radius + dimension.Offset;
|
||||||
|
if (reach <= 0)
|
||||||
|
return new DimensionLayout { Kind = kind, Error = "its offset is not usable" };
|
||||||
|
|
||||||
|
var angle = arc != null && kind == DimensionKind.Radius
|
||||||
|
? ClampToSweep(arc, dimension.Angle)
|
||||||
|
: dimension.Angle;
|
||||||
|
var direction = new Vector(PortableMath.Cos(angle), PortableMath.Sin(angle));
|
||||||
|
var arrow = center + direction * radius;
|
||||||
|
var far = center - direction * radius;
|
||||||
|
return new DimensionLayout
|
||||||
|
{
|
||||||
|
Kind = kind,
|
||||||
|
Value = kind == DimensionKind.Diameter ? radius * 2 : radius,
|
||||||
|
Center = center,
|
||||||
|
Radius = radius,
|
||||||
|
Angle = angle,
|
||||||
|
Arc = arc,
|
||||||
|
ArrowPoint = arrow,
|
||||||
|
FarArrowPoint = far,
|
||||||
|
LineStart = kind == DimensionKind.Diameter ? far : center,
|
||||||
|
LineEnd = center + direction * reach,
|
||||||
|
Extents = new Box(center.X - radius, center.Y - radius, radius * 2, radius * 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><paramref name="angle"/> if it is on the arc's sweep, else the nearer end's angle.</summary>
|
||||||
|
public static double ClampToSweep(Arc arc, double angle)
|
||||||
|
{
|
||||||
|
if (Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||||
|
return angle;
|
||||||
|
var toStart = AngularDistance(angle, arc.StartAngle);
|
||||||
|
var toEnd = AngularDistance(angle, arc.EndAngle);
|
||||||
|
return toStart <= toEnd ? arc.StartAngle : arc.EndAngle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double AngularDistance(double a, double b)
|
||||||
|
{
|
||||||
|
var d = Angle.NormalizeRad(a - b);
|
||||||
|
return System.Math.Min(d, Angle.TwoPI - d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double MidAngle(Arc arc)
|
||||||
|
{
|
||||||
|
// SweepAngle runs counterclockwise from the start, or from the end on a reversed arc.
|
||||||
|
var from = arc.IsReversed ? arc.EndAngle : arc.StartAngle;
|
||||||
|
return Angle.NormalizeRad(from + arc.SweepAngle() / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Dimensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A dimension resolved against a part's geometry. Extension lines run along the extents, on
|
||||||
|
/// the lines through <see cref="ExtensionStart"/> and <see cref="LineStart"/> and through
|
||||||
|
/// <see cref="ExtensionEnd"/> and <see cref="LineEnd"/>, out from the part to
|
||||||
|
/// <see cref="ExtensionTip"/>; the dimension line joins the last two.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DimensionLayout
|
||||||
|
{
|
||||||
|
private const int MaxExtensionSteps = 64;
|
||||||
|
private const double ExtensionPrecision = 0.05;
|
||||||
|
|
||||||
|
public DimensionKind Kind { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Why the dimension could not be resolved; null when it was.</summary>
|
||||||
|
public string Error { get; init; }
|
||||||
|
|
||||||
|
public bool IsValid => Error == null;
|
||||||
|
|
||||||
|
public double Value { get; init; }
|
||||||
|
public Vector ExtensionStart { get; init; }
|
||||||
|
public Vector ExtensionEnd { get; init; }
|
||||||
|
public Vector LineStart { get; init; }
|
||||||
|
public Vector LineEnd { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The part's extents the dimension was measured from.</summary>
|
||||||
|
public Box Extents { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The geometry behind <see cref="Extents"/>.</summary>
|
||||||
|
public IReadOnlyList<Entity> Profile { get; init; } = Array.Empty<Entity>();
|
||||||
|
|
||||||
|
public string ValueText => Value.ToString("0.000", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
/// <summary>The value as drawn: radius and diameter carry their drafting prefix.</summary>
|
||||||
|
public string DisplayText => Kind switch
|
||||||
|
{
|
||||||
|
DimensionKind.Radius => "R" + ValueText,
|
||||||
|
DimensionKind.Diameter => "\u00D8" + ValueText,
|
||||||
|
_ => ValueText,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Vector MidPoint => new((LineStart.X + LineEnd.X) / 2, (LineStart.Y + LineEnd.Y) / 2);
|
||||||
|
|
||||||
|
public bool IsRadial => DrawingDimension.IsRadialKind(Kind);
|
||||||
|
|
||||||
|
// Radius and diameter: the leader runs from LineStart (the center for a radius, the far
|
||||||
|
// side of the curve for a diameter) through ArrowPoint on the curve to LineEnd, the label.
|
||||||
|
|
||||||
|
/// <summary>Radius and diameter: the measured curve's center.</summary>
|
||||||
|
public Vector Center { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Radius and diameter: the measured curve's radius.</summary>
|
||||||
|
public double Radius { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Radius and diameter: the leader's direction from the center, in radians.</summary>
|
||||||
|
public double Angle { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Radius and diameter: where the leader meets the curve on the label's side.</summary>
|
||||||
|
public Vector ArrowPoint { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Diameter: where the leader meets the curve opposite <see cref="ArrowPoint"/>.</summary>
|
||||||
|
public Vector FarArrowPoint { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Radius and diameter on an arc: the measured arc; null for a circle.</summary>
|
||||||
|
public Arc Arc { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Radius and diameter: the leader angle and offset that put the label at
|
||||||
|
/// <paramref name="point"/>, with the label at least <paramref name="minimum"/> from both the
|
||||||
|
/// curve and the center. A radius on an arc stays on the arc's sweep.
|
||||||
|
/// </summary>
|
||||||
|
public (double Offset, double Angle) RadialPlacementAt(Vector point, double minimum)
|
||||||
|
{
|
||||||
|
var dx = point.X - Center.X;
|
||||||
|
var dy = point.Y - Center.Y;
|
||||||
|
var distance = System.Math.Sqrt(dx * dx + dy * dy);
|
||||||
|
var angle = distance > 0 ? PortableMath.Atan2(dy, dx) : Angle;
|
||||||
|
if (Arc != null && Kind == DimensionKind.Radius)
|
||||||
|
angle = DimensionBuilder.ClampToSweep(Arc, angle);
|
||||||
|
|
||||||
|
var offset = distance - Radius;
|
||||||
|
if (offset > -minimum && offset < minimum)
|
||||||
|
offset = offset >= 0 ? minimum : -minimum;
|
||||||
|
if (offset < minimum - Radius)
|
||||||
|
offset = minimum - Radius <= -minimum ? minimum - Radius : minimum;
|
||||||
|
return (offset, Math.Angle.NormalizeRad(angle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where an extension line leaving <paramref name="foot"/> on the dimension line first
|
||||||
|
/// meets the profile offset by <paramref name="clearance"/>, walking toward the part by the
|
||||||
|
/// distance known to be free at each step. <paramref name="foot"/> itself when it is
|
||||||
|
/// already that close.
|
||||||
|
/// </summary>
|
||||||
|
public Vector ExtensionTip(Vector foot, double clearance)
|
||||||
|
{
|
||||||
|
var horizontal = Kind == DimensionKind.OverallHorizontal;
|
||||||
|
var position = horizontal ? foot.Y : foot.X;
|
||||||
|
var center = horizontal
|
||||||
|
? (Extents.Bottom + Extents.Top) / 2
|
||||||
|
: (Extents.Left + Extents.Right) / 2;
|
||||||
|
var sign = System.Math.Sign(center - position);
|
||||||
|
var direction = horizontal ? new Vector(0, sign) : new Vector(sign, 0);
|
||||||
|
|
||||||
|
// The walk ends at the far side of the part at the latest: the line touches the part.
|
||||||
|
var limit = System.Math.Abs(center - position) + (horizontal ? Extents.Width : Extents.Length) / 2;
|
||||||
|
var travelled = 0.0;
|
||||||
|
for (var i = 0; i < MaxExtensionSteps && travelled < limit; i++)
|
||||||
|
{
|
||||||
|
var free = DistanceToProfile(foot + direction * travelled) - clearance;
|
||||||
|
if (free <= clearance * ExtensionPrecision)
|
||||||
|
break;
|
||||||
|
travelled += free;
|
||||||
|
}
|
||||||
|
return foot + direction * System.Math.Min(travelled, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double DistanceToProfile(Vector point)
|
||||||
|
{
|
||||||
|
var nearest = double.MaxValue;
|
||||||
|
foreach (var entity in Profile)
|
||||||
|
nearest = System.Math.Min(nearest, point.DistanceTo(entity.ClosestPointTo(point)));
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The offset that puts the dimension line through <paramref name="point"/>, on the side of
|
||||||
|
/// the part's center the point is on and at least <paramref name="minimum"/> clear of it.
|
||||||
|
/// </summary>
|
||||||
|
public double OffsetAt(Vector point, double minimum)
|
||||||
|
{
|
||||||
|
var horizontal = Kind == DimensionKind.OverallHorizontal;
|
||||||
|
var position = horizontal ? point.Y : point.X;
|
||||||
|
var low = horizontal ? Extents.Bottom : Extents.Left;
|
||||||
|
var high = horizontal ? Extents.Top : Extents.Right;
|
||||||
|
|
||||||
|
return position >= (low + high) / 2
|
||||||
|
? System.Math.Max(minimum, position - high)
|
||||||
|
: System.Math.Min(-minimum, position - low);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace OpenNest.Dimensions
|
||||||
|
{
|
||||||
|
public enum DimensionKind
|
||||||
|
{
|
||||||
|
/// <summary>The part's overall size along X, drawn above or below it.</summary>
|
||||||
|
OverallHorizontal,
|
||||||
|
|
||||||
|
/// <summary>The part's overall size along Y, drawn right or left of it.</summary>
|
||||||
|
OverallVertical,
|
||||||
|
|
||||||
|
/// <summary>A circle's or arc's radius, on a leader from its center.</summary>
|
||||||
|
Radius,
|
||||||
|
|
||||||
|
/// <summary>A circle's or arc's diameter, on a leader across it.</summary>
|
||||||
|
Diameter,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An annotation on a drawing. Only its placement is stored: the value is measured from the
|
||||||
|
/// geometry each time it is resolved, so it cannot go stale. It never affects the program.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DrawingDimension
|
||||||
|
{
|
||||||
|
public DimensionKind Kind { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Never zero. Overall dimensions: distance from the part's extents to the dimension line,
|
||||||
|
/// positive above a horizontal dimension's part or right of a vertical one's. Radius and
|
||||||
|
/// diameter: distance from the curve to the label along the leader, positive outside.
|
||||||
|
/// </summary>
|
||||||
|
public double Offset { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The circle or arc a radius or diameter measures; empty for overall dimensions.</summary>
|
||||||
|
public Guid EntityId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Radius and diameter: the leader's direction from the center, in radians.</summary>
|
||||||
|
public double Angle { get; set; }
|
||||||
|
|
||||||
|
public bool IsRadial => IsRadialKind(Kind);
|
||||||
|
|
||||||
|
public static bool IsRadialKind(DimensionKind kind) =>
|
||||||
|
kind is DimensionKind.Radius or DimensionKind.Diameter;
|
||||||
|
|
||||||
|
public DrawingDimension Clone() => new() { Kind = Kind, Offset = Offset, EntityId = EntityId, Angle = Angle };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Dimensions
|
||||||
|
{
|
||||||
|
/// <summary>One hole diameter in a <see cref="HoleTable"/> and every hole cut at that size.</summary>
|
||||||
|
public sealed class HoleSize
|
||||||
|
{
|
||||||
|
public HoleSize(double diameter, bool isKeyed, IReadOnlyList<Vector> centers, IReadOnlyList<Guid> entityIds)
|
||||||
|
{
|
||||||
|
Diameter = diameter;
|
||||||
|
IsKeyed = isKeyed;
|
||||||
|
Centers = centers;
|
||||||
|
EntityIds = entityIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The diameter, rounded to <see cref="HoleTable.Decimals"/> places.</summary>
|
||||||
|
public double Diameter { get; }
|
||||||
|
|
||||||
|
/// <summary>True when the holes are round except for a keyway, flat or other interruption.</summary>
|
||||||
|
public bool IsKeyed { get; }
|
||||||
|
|
||||||
|
public int Quantity => Centers.Count;
|
||||||
|
|
||||||
|
public IReadOnlyList<Vector> Centers { get; }
|
||||||
|
|
||||||
|
/// <summary>The IDs of every entity that makes up a hole of this size.</summary>
|
||||||
|
public IReadOnlyList<Guid> EntityIds { get; }
|
||||||
|
|
||||||
|
public string DiameterText => "\u00D8" + Diameter.ToString("0.0000", System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
+ (IsKeyed ? " keyed" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts the round and keyed holes in a drawing by diameter. A round hole is a cut circle, or a
|
||||||
|
/// closed contour made only of arcs on one center and radius; a keyed hole is a closed contour
|
||||||
|
/// around its center with at least <see cref="MinKeyedSweep"/> of arc on one center and radius,
|
||||||
|
/// and anything else for the rest. Either must lie inside another cut contour, so the outline of
|
||||||
|
/// a round part is not a hole. Marks (scribe, etch, rapids, break cuts) are not holes. Pass the
|
||||||
|
/// entities as they will be cut (visible, with roles applied).
|
||||||
|
/// </summary>
|
||||||
|
public static class HoleTable
|
||||||
|
{
|
||||||
|
/// <summary>Holes whose diameters agree to this many decimal places share a row.</summary>
|
||||||
|
public const int Decimals = 4;
|
||||||
|
|
||||||
|
/// <summary>The least arc, in radians, that makes a contour a keyed hole: 270 degrees.</summary>
|
||||||
|
public const double MinKeyedSweep = 1.5 * System.Math.PI;
|
||||||
|
|
||||||
|
private const double SweepTolerance = 1e-6;
|
||||||
|
|
||||||
|
public static IReadOnlyList<HoleSize> Build(IEnumerable<Entity> entities)
|
||||||
|
{
|
||||||
|
var cut = entities
|
||||||
|
.Where(e => SpecialLayers.IsMaterial(e.Layer) && !ScribeLayerRepair.IsMarkLayer(e.Layer))
|
||||||
|
.ToList();
|
||||||
|
if (cut.Count == 0)
|
||||||
|
return Array.Empty<HoleSize>();
|
||||||
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(cut);
|
||||||
|
var holes = new List<(double Diameter, bool Keyed, Vector Center, List<Guid> Ids)>();
|
||||||
|
foreach (var shape in shapes)
|
||||||
|
{
|
||||||
|
if (!TryRound(shape, out var center, out var radius, out var keyed))
|
||||||
|
continue;
|
||||||
|
var box = shape.BoundingBox;
|
||||||
|
var area = box.Area();
|
||||||
|
if (!shapes.Any(other => !ReferenceEquals(other, shape)
|
||||||
|
&& other.BoundingBox.Area() > area + Tolerance.Epsilon
|
||||||
|
&& other.BoundingBox.Contains(box)))
|
||||||
|
continue;
|
||||||
|
holes.Add((System.Math.Round(radius * 2, Decimals), keyed, center, shape.Entities.Select(e => e.Id).ToList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return holes
|
||||||
|
.GroupBy(h => (h.Diameter, h.Keyed))
|
||||||
|
.OrderBy(g => g.Key.Diameter)
|
||||||
|
.ThenBy(g => g.Key.Keyed)
|
||||||
|
.Select(g => new HoleSize(
|
||||||
|
g.Key.Diameter,
|
||||||
|
g.Key.Keyed,
|
||||||
|
g.Select(h => h.Center).ToList(),
|
||||||
|
g.SelectMany(h => h.Ids).ToList()))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryRound(Shape shape, out Vector center, out double radius, out bool keyed)
|
||||||
|
{
|
||||||
|
center = default;
|
||||||
|
radius = 0;
|
||||||
|
keyed = false;
|
||||||
|
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
|
||||||
|
{
|
||||||
|
center = circle.Center;
|
||||||
|
radius = circle.Radius;
|
||||||
|
return radius > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shape.IsClosed())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var arcs = shape.Entities.OfType<Arc>().ToList();
|
||||||
|
var tolerance = Tolerance.ChainTolerance;
|
||||||
|
var sweep = 0.0;
|
||||||
|
foreach (var arc in arcs)
|
||||||
|
{
|
||||||
|
var total = arcs
|
||||||
|
.Where(a => a.Center.DistanceTo(arc.Center) <= tolerance
|
||||||
|
&& System.Math.Abs(a.Radius - arc.Radius) <= tolerance)
|
||||||
|
.Sum(a => a.SweepAngle());
|
||||||
|
if (total <= sweep)
|
||||||
|
continue;
|
||||||
|
sweep = total;
|
||||||
|
center = arc.Center;
|
||||||
|
radius = arc.Radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (radius <= 0 || sweep > Angle.TwoPI + SweepTolerance)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (System.Math.Abs(sweep - Angle.TwoPI) <= SweepTolerance && arcs.Count == shape.Entities.Count)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// The center test turns away a curved slot, whose long arcs wrap around solid material.
|
||||||
|
keyed = true;
|
||||||
|
return sweep >= MinKeyedSweep - SweepTolerance && shape.ToPolygon().ContainsPoint(center);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ namespace OpenNest
|
|||||||
|
|
||||||
public string Customer { get; set; }
|
public string Customer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Nesting order: higher numbers nest first. Zero is the default.</summary>
|
||||||
public int Priority { get; set; }
|
public int Priority { get; set; }
|
||||||
|
|
||||||
public DwgQty Quantity;
|
public DwgQty Quantity;
|
||||||
@@ -119,6 +120,14 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool CutFirst { get; set; }
|
public bool CutFirst { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lets automatic nesting place this drawing's parts one kerf apart so they share a cut
|
||||||
|
/// (see docs/common-line-cutting.md), when the nest's common-line usage allows it. Off by
|
||||||
|
/// default: a shared edge is cut once from the centerline, which some customers'
|
||||||
|
/// edge-quality requirements do not accept.
|
||||||
|
/// </summary>
|
||||||
|
public bool AllowCommonLine { get; set; }
|
||||||
|
|
||||||
public NestConstraints Constraints { get; set; }
|
public NestConstraints Constraints { get; set; }
|
||||||
|
|
||||||
public SourceInfo Source { get; set; }
|
public SourceInfo Source { get; set; }
|
||||||
@@ -151,6 +160,12 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public List<TabbedCutouts.TabbedCutout> TabbedCutouts { get; set; } = new List<TabbedCutouts.TabbedCutout>();
|
public List<TabbedCutouts.TabbedCutout> TabbedCutouts { get; set; } = new List<TabbedCutouts.TabbedCutout>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Annotations shown in the CAD Converter. They store placement only, are measured from the
|
||||||
|
/// geometry when shown and never affect <see cref="Program"/>.
|
||||||
|
/// </summary>
|
||||||
|
public List<Dimensions.DrawingDimension> Dimensions { get; set; } = new List<Dimensions.DrawingDimension>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Complete set of source entities with stable GUIDs.
|
/// Complete set of source entities with stable GUIDs.
|
||||||
/// Null when the drawing was created from G-code or an older nest file.
|
/// Null when the drawing was created from G-code or an older nest file.
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ namespace OpenNest.Geometry
|
|||||||
public Vector StartPoint()
|
public Vector StartPoint()
|
||||||
{
|
{
|
||||||
return new Vector(
|
return new Vector(
|
||||||
Center.X + Radius * System.Math.Cos(StartAngle),
|
Center.X + Radius * PortableMath.Cos(StartAngle),
|
||||||
Center.Y + Radius * System.Math.Sin(StartAngle)
|
Center.Y + Radius * PortableMath.Sin(StartAngle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +153,8 @@ namespace OpenNest.Geometry
|
|||||||
public Vector EndPoint()
|
public Vector EndPoint()
|
||||||
{
|
{
|
||||||
return new Vector(
|
return new Vector(
|
||||||
Center.X + Radius * System.Math.Cos(EndAngle),
|
Center.X + Radius * PortableMath.Cos(EndAngle),
|
||||||
Center.Y + Radius * System.Math.Sin(EndAngle)
|
Center.Y + Radius * PortableMath.Sin(EndAngle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +165,8 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
var midAngle = StartAngle + (IsReversed ? -SweepAngle() / 2 : SweepAngle() / 2);
|
var midAngle = StartAngle + (IsReversed ? -SweepAngle() / 2 : SweepAngle() / 2);
|
||||||
return new Vector(
|
return new Vector(
|
||||||
Center.X + Radius * System.Math.Cos(midAngle),
|
Center.X + Radius * PortableMath.Cos(midAngle),
|
||||||
Center.Y + Radius * System.Math.Sin(midAngle)
|
Center.Y + Radius * PortableMath.Sin(midAngle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ namespace OpenNest.Geometry
|
|||||||
if (tolerance >= Radius)
|
if (tolerance >= Radius)
|
||||||
return 1;
|
return 1;
|
||||||
|
|
||||||
var maxAngle = 2.0 * System.Math.Acos(1.0 - tolerance / Radius);
|
var maxAngle = 2.0 * PortableMath.Acos(1.0 - tolerance / Radius);
|
||||||
return System.Math.Max(
|
return System.Math.Max(
|
||||||
1,
|
1,
|
||||||
(int)System.Math.Ceiling(System.Math.Abs(SweepAngle()) / maxAngle)
|
(int)System.Math.Ceiling(System.Math.Abs(SweepAngle()) / maxAngle)
|
||||||
@@ -250,7 +250,7 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
var r =
|
var r =
|
||||||
circumscribe && segments > 0
|
circumscribe && segments > 0
|
||||||
? Radius / System.Math.Cos(System.Math.Abs(stepAngle) / 2.0)
|
? Radius / PortableMath.Cos(System.Math.Abs(stepAngle) / 2.0)
|
||||||
: Radius;
|
: Radius;
|
||||||
|
|
||||||
for (int i = 0; i <= segments; ++i)
|
for (int i = 0; i <= segments; ++i)
|
||||||
@@ -259,8 +259,8 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
points.Add(
|
points.Add(
|
||||||
new Vector(
|
new Vector(
|
||||||
System.Math.Cos(angle) * r + Center.X,
|
PortableMath.Cos(angle) * r + Center.X,
|
||||||
System.Math.Sin(angle) * r + Center.Y
|
PortableMath.Sin(angle) * r + Center.Y
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -479,8 +479,8 @@ namespace OpenNest.Geometry
|
|||||||
if (Angle.IsBetweenRad(angle, StartAngle, EndAngle, IsReversed))
|
if (Angle.IsBetweenRad(angle, StartAngle, EndAngle, IsReversed))
|
||||||
{
|
{
|
||||||
return new Vector(
|
return new Vector(
|
||||||
System.Math.Cos(angle) * Radius + Center.X,
|
PortableMath.Cos(angle) * Radius + Center.X,
|
||||||
System.Math.Sin(angle) * Radius + Center.Y
|
PortableMath.Sin(angle) * Radius + Center.Y
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ namespace OpenNest.Geometry
|
|||||||
return (Vector.Invalid, 0, double.MaxValue);
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
var halfChord = chordLen / 2;
|
var halfChord = chordLen / 2;
|
||||||
var radius = halfChord / System.Math.Abs(System.Math.Sin(theta));
|
var radius = halfChord / System.Math.Abs(PortableMath.Sin(theta));
|
||||||
var d = -halfChord / System.Math.Tan(theta);
|
var d = -halfChord / PortableMath.Tan(theta);
|
||||||
|
|
||||||
var cx = (p1.X + pn.X) / 2 + d * -uy;
|
var cx = (p1.X + pn.X) / 2 + d * -uy;
|
||||||
var cy = (p1.Y + pn.Y) / 2 + d * ux;
|
var cy = (p1.Y + pn.Y) / 2 + d * ux;
|
||||||
@@ -116,7 +116,7 @@ namespace OpenNest.Geometry
|
|||||||
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
|
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
|
||||||
if (len < 1e-10)
|
if (len < 1e-10)
|
||||||
return 0;
|
return 0;
|
||||||
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
|
return PortableMath.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -128,8 +128,8 @@ namespace OpenNest.Geometry
|
|||||||
var total = 0.0;
|
var total = 0.0;
|
||||||
for (var i = 0; i < points.Count - 1; i++)
|
for (var i = 0; i < points.Count - 1; i++)
|
||||||
{
|
{
|
||||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
var a1 = PortableMath.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
var a2 = PortableMath.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||||
var da = a2 - a1;
|
var da = a2 - a1;
|
||||||
while (da > System.Math.PI)
|
while (da > System.Math.PI)
|
||||||
da -= Angle.TwoPI;
|
da -= Angle.TwoPI;
|
||||||
|
|||||||
Loaded 100 of 1039 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user