mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-03 14:41:37 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
19be5a5ece | ||
|
|
242a0d2a1b | ||
|
|
f26fc0d596 | ||
|
|
ac886d05b8 | ||
|
|
3e1df45c7f | ||
|
|
cc16590881 | ||
|
|
8ad32e4864 | ||
|
|
7198f7a4f7 | ||
|
|
ee842c822f | ||
|
|
512b861739 | ||
|
|
3e767ca3b6 | ||
|
|
e18304066d | ||
|
|
dcb0a5c519 | ||
|
|
1592d4bfd9 | ||
|
|
857a560dce | ||
|
|
69a83b3ad9 | ||
|
|
a3a75d589d | ||
|
|
a3b7c715d5 | ||
|
|
16afb41c26 | ||
|
|
2e72469d6c | ||
|
|
3dd1acd3cf | ||
|
|
9a62a5102d | ||
|
|
223bfea47c | ||
|
|
d1270f5208 | ||
|
|
fe31a3f8ec | ||
|
|
a88c056f4b | ||
|
|
81ec6cc738 | ||
|
|
11b04cc272 | ||
|
|
6a9b81326e | ||
|
|
37cdc52f0d | ||
|
|
d3d7bc0299 | ||
|
|
3884d058ec | ||
|
|
27610c3b1d | ||
|
|
1b99a7c866 | ||
|
|
6e40c2e6ed | ||
|
|
0ba5539fab | ||
|
|
2e17dd7bc4 | ||
|
|
86b0370a8f | ||
|
|
e5e2c268b8 | ||
|
|
7f1c5a3854 | ||
|
|
96d8a6874f | ||
|
|
fecfcc337e | ||
|
|
b0697f92b3 | ||
|
|
98f8e98230 | ||
|
|
5611d9a663 | ||
|
|
5761eefd37 | ||
|
|
9f335ea111 | ||
|
|
0c7ad5aefb | ||
|
|
73a7c8d6a2 | ||
|
|
479883e7ac | ||
|
|
07499fe1cd | ||
|
|
1b1f86085d | ||
|
|
36c8de825e | ||
|
|
ccd361dfa9 | ||
|
|
7c4276a457 | ||
|
|
14ba2e653b | ||
|
|
18612e698d | ||
|
|
90e2fd2e6e | ||
|
|
9f3c22dc4f | ||
|
|
84e6adbcf2 | ||
|
|
b35490addf | ||
|
|
1fbe57a841 | ||
|
|
1bb58117af | ||
|
|
70e8666335 | ||
|
|
2d8d877482 | ||
|
|
610b9dafcb | ||
|
|
079dfaf9ea | ||
|
|
c27188c722 | ||
|
|
548522775f | ||
|
|
be01124a0f | ||
|
|
f847b216f2 | ||
|
|
23f093fcc1 | ||
|
|
15750949dd | ||
|
|
ea1decc097 | ||
|
|
42f1762c56 | ||
|
|
455c543501 | ||
|
|
4b982e8eeb | ||
|
|
0d369c9a51 | ||
|
|
cbf3b728aa | ||
|
|
fa70713bfc | ||
|
|
ba55d5bff0 | ||
|
|
f600c5e58b | ||
|
|
27796851f3 | ||
|
|
d4269c1f06 | ||
|
|
8cd9b2cbe3 | ||
|
|
b341848e07 | ||
|
|
33f60d3dcb | ||
|
|
ad3443f60d | ||
|
|
ebecd42433 | ||
|
|
3f5d402489 | ||
|
|
a6056a6065 | ||
|
|
dfa6ccb954 | ||
|
|
87672543cd | ||
|
|
a9198e5c25 | ||
|
|
6a556b75ab | ||
|
|
b376d217dd | ||
|
|
49bb7389f7 | ||
|
|
ee208b2766 | ||
|
|
0d1dade063 | ||
|
|
48863d51c8 | ||
|
|
cd1961e26b | ||
|
|
414479fc4e | ||
|
|
bc21382c5d | ||
|
|
ba2102c793 | ||
|
|
4b312a552d | ||
|
|
a3caff6e40 | ||
|
|
6b63d41030 | ||
|
|
9c78b4ebec | ||
|
|
993555dba6 | ||
|
|
13352b8acd | ||
|
|
7ce5e73a82 | ||
|
|
a00e2c3233 | ||
|
|
0785bb84c9 | ||
|
|
45592a811c | ||
|
|
39f28c7fa9 | ||
|
|
07e7d7edcd | ||
|
|
bbadd6aa75 | ||
|
|
c188014a30 | ||
|
|
069efc2368 | ||
|
|
ed92c37845 | ||
|
|
00cb639c8e | ||
|
|
9e4361fd11 | ||
|
|
3c3bab0a9b | ||
|
|
6201065034 | ||
|
|
684207039a | ||
|
|
8f568482bc | ||
|
|
73e54fbd67 | ||
|
|
b45e9df593 | ||
|
|
6e3705eb89 | ||
|
|
c325a1745a | ||
|
|
dc4fc1d593 | ||
|
|
d4903a648c | ||
|
|
9d69c89b21 | ||
|
|
939d7bfff2 | ||
|
|
8fd1fabc54 | ||
|
|
54b15cff19 | ||
|
|
f657292801 | ||
|
|
8f27eac2ca | ||
|
|
ff46de69df | ||
|
|
1fc36e9e54 | ||
|
|
099a020424 | ||
|
|
c58ae6f9e1 | ||
|
|
63771b0029 | ||
|
|
09d58ce064 | ||
|
|
64eca9775f | ||
|
|
d1c77b8ec7 | ||
|
|
35359906fe | ||
|
|
5ecdc98581 | ||
|
|
3ca7d6185f | ||
|
|
f6781d27c3 | ||
|
|
74203483c5 | ||
|
|
4b604d3874 | ||
|
|
28db6ae807 | ||
|
|
19809c61d0 | ||
|
|
382abf702b | ||
|
|
2963764215 | ||
|
|
64e24fc6c8 | ||
|
|
0eb6d5886a | ||
|
|
9ab6fbbeaf | ||
|
|
8c7dc41ce8 | ||
|
|
c3881c5534 | ||
|
|
7bf753ce17 | ||
|
|
11b6032459 | ||
|
|
0ca4e64189 | ||
|
|
f901840ed0 | ||
|
|
059a873d32 | ||
|
|
0922e41401 | ||
|
|
ed9c1f9df6 | ||
|
|
50f966913d | ||
|
|
96f02c665c | ||
|
|
aeb90d8a86 | ||
|
|
f51e5aefdd | ||
|
|
a7c0747e11 | ||
|
|
b65876c031 | ||
|
|
cc731ff3d6 | ||
|
|
1ccba9b910 | ||
|
|
af6975b9f1 | ||
|
|
44666b289d | ||
|
|
be057bf337 | ||
|
|
28ee04a480 | ||
|
|
1b323e2380 | ||
|
|
2d74d09023 | ||
|
|
cc2b3d1b81 | ||
|
|
63233db108 | ||
|
|
abfc1a714a | ||
|
|
efa4844049 |
No files matched your search
@@ -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:
|
||||||
@@ -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'
|
||||||
@@ -114,8 +122,7 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
if: >-
|
if: >-
|
||||||
github.repository == 'ajisaacs/OpenNest' &&
|
github.repository == 'ajisaacs/OpenNest' &&
|
||||||
((github.event_name == 'release' && github.event.release.prerelease == false) ||
|
github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'
|
||||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'))
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -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__/
|
||||||
@@ -7,7 +7,7 @@ OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform librari
|
|||||||
|
|
||||||
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
|
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
|
||||||
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
|
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
|
||||||
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`.
|
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`. User guides and their screenshots live in the separate `ajisaacs/opennest-help` repo: update or recapture them when a UI change alters a documented screen, and never rename a `HelpTopics` ID.
|
||||||
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
|
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
|
||||||
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
|
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
|
||||||
|
|
||||||
@@ -22,13 +22,17 @@ 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).
|
||||||
|
|
||||||
|
Only Plan Cutting changes need the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); never run it for nesting engines, fills, UI, posts or file formats. Limit it to the areas the change touches with `OPENNEST_PROBLEM_NESTS_CHECKS` (table in that doc); `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.
|
||||||
|
|
||||||
@@ -37,33 +41,47 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
|
|||||||
- `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.
|
||||||
- `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`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory.
|
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). 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
|
||||||
|
|
||||||
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
|
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
|
||||||
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check and the pre-post part-spacing review share Core's `SpacingOutline` for candidates; the review then confirms each one on finely flattened native contours, so it never warns where Auto Nest's check would pass. Do not loosen tolerances to hide failures.
|
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check, the runner's candidate validation and the pre-post part-spacing review all find spacing candidates with Core's `SpacingOutline` and confirm them on `NativeContours` (the outline circumscribes arcs by more than the slack), so they agree; `NestLayoutCheck.Clears` skips the confirmation and stays stricter for engine certification. Do not loosen tolerances to hide failures.
|
||||||
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
|
- `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.
|
||||||
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
|
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
|
||||||
|
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. Every post must raise before a marked rapid or refuse the nest.
|
||||||
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
|
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
|
||||||
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
|
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
|
||||||
|
|
||||||
Read the relevant contract before changing its behavior:
|
Read the relevant contract before changing its behavior:
|
||||||
|
|
||||||
- [Nest file format](docs/nest-file-format.md)
|
- [Nest file format](docs/nest-file-format.md)
|
||||||
|
- [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) 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
|
||||||
|
- [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
|
||||||
|
- [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.
|
||||||
|
- [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;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="OpenNest.Avalonia.App"
|
||||||
|
RequestedThemeVariant="Light">
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace OpenNest.Avalonia
|
||||||
|
{
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var window = new MainWindow();
|
||||||
|
desktop.MainWindow = window;
|
||||||
|
var args = desktop.Args ?? System.Array.Empty<string>();
|
||||||
|
if (args.Length > 0)
|
||||||
|
window.OpenNest(args[0]);
|
||||||
|
// --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");
|
||||||
|
if (snapshot >= 0 && snapshot + 1 < args.Length)
|
||||||
|
window.SaveSnapshotAndClose(args[snapshot + 1],
|
||||||
|
snapshot + 2 < args.Length && int.TryParse(args[snapshot + 2], out var plate) ? plate : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Platform;
|
||||||
|
using Avalonia.Rendering.SceneGraph;
|
||||||
|
using Avalonia.Skia;
|
||||||
|
using OpenNest.Avalonia.Rendering;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using SkiaSharp;
|
||||||
|
using Control = Avalonia.Controls.Control;
|
||||||
|
using Vector = OpenNest.Geometry.Vector;
|
||||||
|
|
||||||
|
namespace OpenNest.Avalonia.Controls
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Draws one plate with Skia: pans with a left or middle drag, zooms at the cursor with the
|
||||||
|
/// wheel, fits on a double click, and highlights the part under the cursor.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlateCanvas : Control
|
||||||
|
{
|
||||||
|
private static readonly SKColor BackgroundColor = new(169, 169, 169);
|
||||||
|
private static readonly SKColor PlateFillColor = new(245, 245, 245);
|
||||||
|
private static readonly SKColor PlateOutlineColor = new(128, 128, 128);
|
||||||
|
private static readonly SKColor EdgeSpacingColor = new(180, 180, 180);
|
||||||
|
private static readonly SKColor LeadColor = new(255, 0, 0);
|
||||||
|
private static readonly SKColor EtchColor = new(0, 150, 60);
|
||||||
|
private static readonly SKColor SelectedColor = new(150, 200, 255, 90 * 255 / 100);
|
||||||
|
|
||||||
|
// Paths are built once per program and only read afterwards, so the render thread may draw
|
||||||
|
// them while the UI thread builds others. Keyed by reference: a rotation replaces the program.
|
||||||
|
private readonly Dictionary<CNC.Program, ProgramPaths> paths = new();
|
||||||
|
|
||||||
|
private Plate plate;
|
||||||
|
private double scale = 1;
|
||||||
|
private Point origin;
|
||||||
|
private Point? dragStart;
|
||||||
|
private bool fitPending = true;
|
||||||
|
|
||||||
|
public Plate Plate
|
||||||
|
{
|
||||||
|
get => plate;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
plate = value;
|
||||||
|
HoveredPart = null;
|
||||||
|
SelectedPart = null;
|
||||||
|
fitPending = true;
|
||||||
|
InvalidateVisual();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Part HoveredPart { get; private set; }
|
||||||
|
|
||||||
|
public Part SelectedPart { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The cursor in plate coordinates, or null when it is outside the view.</summary>
|
||||||
|
public Vector? CursorWorld { get; private set; }
|
||||||
|
|
||||||
|
public event EventHandler ViewChanged;
|
||||||
|
|
||||||
|
public PlateCanvas()
|
||||||
|
{
|
||||||
|
ClipToBounds = true;
|
||||||
|
Focusable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearCache() => paths.Clear();
|
||||||
|
|
||||||
|
public void ZoomToFit()
|
||||||
|
{
|
||||||
|
if (!Fit())
|
||||||
|
return;
|
||||||
|
InvalidateVisual();
|
||||||
|
ViewChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fits the view to the plate and its parts without requesting a redraw.</summary>
|
||||||
|
private bool Fit()
|
||||||
|
{
|
||||||
|
if (plate == null || Bounds.Width <= 0 || Bounds.Height <= 0)
|
||||||
|
return false;
|
||||||
|
var extents = PlateRect(plate);
|
||||||
|
foreach (var part in plate.Parts)
|
||||||
|
extents = Union(extents, part.BoundingBox);
|
||||||
|
const double margin = 20;
|
||||||
|
var sx = (Bounds.Width - 2 * margin) / System.Math.Max(extents.Length, 1e-6);
|
||||||
|
var sy = (Bounds.Height - 2 * margin) / System.Math.Max(extents.Width, 1e-6);
|
||||||
|
scale = System.Math.Min(sx, sy);
|
||||||
|
origin = new Point(
|
||||||
|
Bounds.Width / 2 - (extents.Left + extents.Length / 2) * scale,
|
||||||
|
Bounds.Height / 2 + (extents.Bottom + extents.Width / 2) * scale);
|
||||||
|
fitPending = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Zoom => scale;
|
||||||
|
|
||||||
|
private Vector ToWorld(Point p) => new((p.X - origin.X) / scale, (origin.Y - p.Y) / scale);
|
||||||
|
|
||||||
|
private SKMatrix WorldMatrix =>
|
||||||
|
new((float)scale, 0, (float)origin.X, 0, (float)-scale, (float)origin.Y, 0, 0, 1);
|
||||||
|
|
||||||
|
private static Box PlateRect(Plate plate)
|
||||||
|
{
|
||||||
|
var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
|
||||||
|
var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
|
||||||
|
return new Box(left, bottom, plate.Size.Length, plate.Size.Width);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Box Union(Box a, Box b)
|
||||||
|
{
|
||||||
|
var left = System.Math.Min(a.Left, b.Left);
|
||||||
|
var bottom = System.Math.Min(a.Bottom, b.Bottom);
|
||||||
|
return new Box(left, bottom,
|
||||||
|
System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProgramPaths PathsFor(Part part)
|
||||||
|
{
|
||||||
|
if (!paths.TryGetValue(part.Program, out var result))
|
||||||
|
paths[part.Program] = result = ProgramPaths.Build(part.Program);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Render(DrawingContext context)
|
||||||
|
{
|
||||||
|
// A redraw may not be requested during a render, so tell listeners afterwards.
|
||||||
|
if (fitPending && Fit())
|
||||||
|
global::Avalonia.Threading.Dispatcher.UIThread.Post(() => ViewChanged?.Invoke(this, EventArgs.Empty));
|
||||||
|
|
||||||
|
var items = new List<PartItem>();
|
||||||
|
if (plate != null)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < plate.Parts.Count; i++)
|
||||||
|
{
|
||||||
|
var part = plate.Parts[i];
|
||||||
|
items.Add(new PartItem(PathsFor(part), part.Location, part.BoundingBox,
|
||||||
|
ToSkColor(part.BaseDrawing.Color), (i + 1).ToString(),
|
||||||
|
ReferenceEquals(part, SelectedPart), ReferenceEquals(part, HoveredPart),
|
||||||
|
part.BaseDrawing.IsCutOff, part.HasManualLeadIns));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Custom(new PlateDrawOperation(new Rect(Bounds.Size), plate == null ? null : PlateRect(plate),
|
||||||
|
plate?.EdgeSpacing, WorldMatrix, items));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SKColor ToSkColor(System.Drawing.Color color) =>
|
||||||
|
color.IsEmpty ? new SKColor(130, 204, 130) : new SKColor(color.R, color.G, color.B, color.A);
|
||||||
|
|
||||||
|
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
|
||||||
|
{
|
||||||
|
var at = e.GetPosition(this);
|
||||||
|
var factor = System.Math.Pow(1.2, e.Delta.Y);
|
||||||
|
// Keep the world point under the cursor fixed.
|
||||||
|
origin = new Point(at.X - (at.X - origin.X) * factor, at.Y - (at.Y - origin.Y) * factor);
|
||||||
|
scale *= factor;
|
||||||
|
InvalidateVisual();
|
||||||
|
ViewChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
Focus();
|
||||||
|
var point = e.GetCurrentPoint(this);
|
||||||
|
if (e.ClickCount == 2)
|
||||||
|
{
|
||||||
|
ZoomToFit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (point.Properties.IsLeftButtonPressed)
|
||||||
|
{
|
||||||
|
SelectedPart = HitTest(ToWorld(point.Position));
|
||||||
|
InvalidateVisual();
|
||||||
|
ViewChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
if (point.Properties.IsLeftButtonPressed || point.Properties.IsMiddleButtonPressed)
|
||||||
|
{
|
||||||
|
dragStart = point.Position;
|
||||||
|
e.Pointer.Capture(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerMoved(PointerEventArgs e)
|
||||||
|
{
|
||||||
|
var at = e.GetPosition(this);
|
||||||
|
if (dragStart is { } start)
|
||||||
|
{
|
||||||
|
origin += at - start;
|
||||||
|
dragStart = at;
|
||||||
|
InvalidateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
CursorWorld = ToWorld(at);
|
||||||
|
var hovered = dragStart == null ? HitTest(CursorWorld.Value) : HoveredPart;
|
||||||
|
if (!ReferenceEquals(hovered, HoveredPart))
|
||||||
|
{
|
||||||
|
HoveredPart = hovered;
|
||||||
|
InvalidateVisual();
|
||||||
|
}
|
||||||
|
ViewChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||||
|
{
|
||||||
|
dragStart = null;
|
||||||
|
e.Pointer.Capture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerExited(PointerEventArgs e)
|
||||||
|
{
|
||||||
|
CursorWorld = null;
|
||||||
|
if (HoveredPart != null)
|
||||||
|
{
|
||||||
|
HoveredPart = null;
|
||||||
|
InvalidateVisual();
|
||||||
|
}
|
||||||
|
ViewChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnKeyDown(KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.F)
|
||||||
|
{
|
||||||
|
ZoomToFit();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnSizeChanged(global::Avalonia.Controls.SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnSizeChanged(e);
|
||||||
|
if (fitPending)
|
||||||
|
InvalidateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The topmost part whose material contains <paramref name="world"/>.</summary>
|
||||||
|
private Part HitTest(Vector world)
|
||||||
|
{
|
||||||
|
if (plate == null)
|
||||||
|
return null;
|
||||||
|
for (var i = plate.Parts.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var part = plate.Parts[i];
|
||||||
|
var box = part.BoundingBox;
|
||||||
|
if (world.X < box.Left || world.X > box.Right || world.Y < box.Bottom || world.Y > box.Top)
|
||||||
|
continue;
|
||||||
|
var local = world - part.Location;
|
||||||
|
if (PathsFor(part).Fill.Contains((float)local.X, (float)local.Y))
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PartItem(ProgramPaths Paths, Vector Location, Box Bounds, SKColor Color,
|
||||||
|
string Label, bool Selected, bool Hovered, bool IsCutOff, bool ShowLeads);
|
||||||
|
|
||||||
|
private sealed class PlateDrawOperation : ICustomDrawOperation
|
||||||
|
{
|
||||||
|
private readonly Box plateRect;
|
||||||
|
private readonly Spacing? edgeSpacing;
|
||||||
|
private readonly SKMatrix matrix;
|
||||||
|
private readonly List<PartItem> items;
|
||||||
|
|
||||||
|
public PlateDrawOperation(Rect bounds, Box plateRect, Spacing? edgeSpacing, SKMatrix matrix,
|
||||||
|
List<PartItem> items)
|
||||||
|
{
|
||||||
|
Bounds = bounds;
|
||||||
|
this.plateRect = plateRect;
|
||||||
|
this.edgeSpacing = edgeSpacing;
|
||||||
|
this.matrix = matrix;
|
||||||
|
this.items = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Rect Bounds { get; }
|
||||||
|
|
||||||
|
public bool HitTest(Point p) => Bounds.Contains(p);
|
||||||
|
|
||||||
|
public bool Equals(ICustomDrawOperation other) => false;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Render(ImmediateDrawingContext context)
|
||||||
|
{
|
||||||
|
var lease = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
|
||||||
|
if (lease == null)
|
||||||
|
return;
|
||||||
|
using var api = lease.Lease();
|
||||||
|
var canvas = api.SkCanvas;
|
||||||
|
canvas.Save();
|
||||||
|
canvas.ClipRect(new SKRect(0, 0, (float)Bounds.Width, (float)Bounds.Height));
|
||||||
|
canvas.Clear(BackgroundColor);
|
||||||
|
|
||||||
|
if (plateRect != null)
|
||||||
|
Draw(canvas);
|
||||||
|
|
||||||
|
canvas.Restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Draw(SKCanvas canvas)
|
||||||
|
{
|
||||||
|
// Hairline strokes (width 0) stay one pixel wide at any zoom.
|
||||||
|
using var fill = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||||
|
using var stroke = new SKPaint { Style = SKPaintStyle.Stroke, StrokeWidth = 0, IsAntialias = true };
|
||||||
|
|
||||||
|
canvas.Save();
|
||||||
|
var world = matrix;
|
||||||
|
canvas.Concat(ref world);
|
||||||
|
|
||||||
|
var plate = new SKRect((float)plateRect.Left, (float)plateRect.Bottom,
|
||||||
|
(float)plateRect.Right, (float)plateRect.Top);
|
||||||
|
fill.Color = PlateFillColor;
|
||||||
|
canvas.DrawRect(plate, fill);
|
||||||
|
if (edgeSpacing is { } spacing)
|
||||||
|
{
|
||||||
|
stroke.Color = EdgeSpacingColor;
|
||||||
|
canvas.DrawRect(new SKRect(plate.Left + (float)spacing.Left,
|
||||||
|
plate.Top + (float)spacing.Bottom, plate.Right - (float)spacing.Right,
|
||||||
|
plate.Bottom - (float)spacing.Top), stroke);
|
||||||
|
}
|
||||||
|
stroke.Color = PlateOutlineColor;
|
||||||
|
canvas.DrawRect(plate, stroke);
|
||||||
|
|
||||||
|
var view = canvas.LocalClipBounds;
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var b = item.Bounds;
|
||||||
|
if (b.Right < view.Left || b.Left > view.Right || b.Top < view.Top || b.Bottom > view.Bottom)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
canvas.Save();
|
||||||
|
canvas.Translate((float)item.Location.X, (float)item.Location.Y);
|
||||||
|
|
||||||
|
fill.Color = item.Selected ? SelectedColor
|
||||||
|
: item.Hovered ? Lighten(item.Color) : item.Color;
|
||||||
|
canvas.DrawPath(item.Paths.Fill, fill);
|
||||||
|
|
||||||
|
stroke.Color = EtchColor;
|
||||||
|
canvas.DrawPath(item.Paths.Etch, stroke);
|
||||||
|
stroke.Color = item.Selected ? new SKColor(30, 90, 200) : Darken(item.Color);
|
||||||
|
canvas.DrawPath(item.Paths.Cut, stroke);
|
||||||
|
// As on the desktop, only manually placed lead-ins are shown.
|
||||||
|
if (item.ShowLeads)
|
||||||
|
{
|
||||||
|
stroke.Color = LeadColor;
|
||||||
|
canvas.DrawPath(item.Paths.Lead, stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.Restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.Restore();
|
||||||
|
DrawLabels(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ids are drawn in screen space and only where the part is big enough on screen to hold one.
|
||||||
|
private void DrawLabels(SKCanvas canvas)
|
||||||
|
{
|
||||||
|
using var text = new SKPaint { Color = SKColors.Black, IsAntialias = true, TextSize = 12 };
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (item.IsCutOff)
|
||||||
|
continue;
|
||||||
|
var center = matrix.MapPoint((float)item.Bounds.Center.X, (float)item.Bounds.Center.Y);
|
||||||
|
var width = item.Bounds.Length * matrix.ScaleX;
|
||||||
|
var height = item.Bounds.Width * -matrix.ScaleY;
|
||||||
|
var measured = text.MeasureText(item.Label);
|
||||||
|
if (measured + 4 > width || text.TextSize + 4 > height)
|
||||||
|
continue;
|
||||||
|
canvas.DrawText(item.Label, center.X - measured / 2, center.Y + text.TextSize / 3, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SKColor Darken(SKColor c) =>
|
||||||
|
new((byte)(c.Red * 0.55), (byte)(c.Green * 0.55), (byte)(c.Blue * 0.55), 255);
|
||||||
|
|
||||||
|
private static SKColor Lighten(SKColor c) =>
|
||||||
|
new((byte)(c.Red + (255 - c.Red) / 2), (byte)(c.Green + (255 - c.Green) / 2),
|
||||||
|
(byte)(c.Blue + (255 - c.Blue) / 2), c.Alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:c="using:OpenNest.Avalonia.Controls"
|
||||||
|
xmlns:local="using:OpenNest.Avalonia"
|
||||||
|
x:Class="OpenNest.Avalonia.MainWindow"
|
||||||
|
Title="OpenNest (Avalonia prototype)"
|
||||||
|
Width="1400" Height="860">
|
||||||
|
<DockPanel>
|
||||||
|
<Menu DockPanel.Dock="Top">
|
||||||
|
<MenuItem Header="_File">
|
||||||
|
<MenuItem x:Name="OpenMenuItem" Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="E_xit" Click="OnExit" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_Nest">
|
||||||
|
<MenuItem x:Name="AutoNestMenuItem" Header="_Auto Nest" Click="OnAutoNest" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_View">
|
||||||
|
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
|
||||||
|
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
|
||||||
|
<MenuItem Header="_Next Plate" InputGesture="PageDown" Click="OnNextPlate" />
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
|
<Button x:Name="OpenButton" Content="Open" Click="OnOpen" />
|
||||||
|
<Button Content="Fit" Click="OnFit" />
|
||||||
|
<Button Content="Previous" Click="OnPreviousPlate" />
|
||||||
|
<Button Content="Next" Click="OnNextPlate" />
|
||||||
|
<Button x:Name="AutoNestButton" Content="Auto Nest" Click="OnAutoNest" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border DockPanel.Dock="Bottom" Padding="6,3" Background="#F0F0F0">
|
||||||
|
<TextBlock x:Name="Status" Text="Open a .nest file to begin." />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="260,4,*,4,320">
|
||||||
|
<DockPanel Grid.Column="0">
|
||||||
|
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Plates" />
|
||||||
|
<ListBox x:Name="PlateList" SelectionChanged="OnPlateSelected" />
|
||||||
|
</DockPanel>
|
||||||
|
<GridSplitter Grid.Column="1" ResizeDirection="Columns" />
|
||||||
|
<c:PlateCanvas Grid.Column="2" x:Name="Canvas" />
|
||||||
|
<GridSplitter Grid.Column="3" ResizeDirection="Columns" />
|
||||||
|
<DockPanel Grid.Column="4">
|
||||||
|
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Drawings on plate" />
|
||||||
|
<DataGrid x:Name="DrawingGrid" x:DataType="local:DrawingRow" IsReadOnly="True"
|
||||||
|
AutoGenerateColumns="False" GridLinesVisibility="Horizontal" CanUserSortColumns="True">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Drawing" Binding="{Binding Name}" Width="*" />
|
||||||
|
<DataGridTextColumn Header="Qty" Binding="{Binding Count}" />
|
||||||
|
<DataGridTextColumn Header="Size" Binding="{Binding Size}" />
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using OpenNest.Desktop;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Forms;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Avalonia
|
||||||
|
{
|
||||||
|
public sealed record DrawingRow(string Name, int Count, string Size);
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private Document document;
|
||||||
|
private string fileName;
|
||||||
|
private string notice;
|
||||||
|
private CancellationTokenSource nesting;
|
||||||
|
private Task autoNest = Task.CompletedTask;
|
||||||
|
|
||||||
|
private Nest nest => document?.Nest;
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
// 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;
|
||||||
|
Canvas.ViewChanged += (_, _) => UpdateStatus();
|
||||||
|
KeyBindings.Add(new KeyBinding
|
||||||
|
{
|
||||||
|
Gesture = new KeyGesture(Key.O, KeyModifiers.Control),
|
||||||
|
Command = new Command(() => OnOpen(null, null)),
|
||||||
|
});
|
||||||
|
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageUp), Command = new Command(() => StepPlate(-1)) });
|
||||||
|
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageDown), Command = new Command(() => StepPlate(1)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OpenNest(string path)
|
||||||
|
{
|
||||||
|
if (nesting != null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
document = new Document { Nest = new NestReader(path).Read() };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Status.Text = $"Could not open {Path.GetFileName(path)}: {ex.Message}";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName = Path.GetFileName(path);
|
||||||
|
Title = $"{fileName} - OpenNest (Avalonia prototype)";
|
||||||
|
notice = null;
|
||||||
|
Canvas.ClearCache();
|
||||||
|
RefreshPlateList(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshPlateList(int selectIndex)
|
||||||
|
{
|
||||||
|
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 ? System.Math.Clamp(selectIndex, 0, nest.Plates.Count - 1) : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
Opened += async (_, _) =>
|
||||||
|
{
|
||||||
|
if (nest?.Plates.Count > 0)
|
||||||
|
PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1);
|
||||||
|
await autoNest;
|
||||||
|
await System.Threading.Tasks.Task.Delay(500);
|
||||||
|
var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height);
|
||||||
|
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
|
||||||
|
bitmap.Render(this);
|
||||||
|
bitmap.Save(path);
|
||||||
|
// The status line beside the image, so headless checks can read it.
|
||||||
|
File.WriteAllText(path + ".txt", Status.Text);
|
||||||
|
Close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnOpen(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (nesting != null)
|
||||||
|
return;
|
||||||
|
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Open Nest",
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter = new[] { new FilePickerFileType("Nest files") { Patterns = new[] { "*.nest" } } },
|
||||||
|
});
|
||||||
|
var path = files.FirstOrDefault()?.TryGetLocalPath();
|
||||||
|
if (path != null)
|
||||||
|
OpenNest(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnExit(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
private void 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 on sheets like the shown plate through the
|
||||||
|
/// shared pipeline, off the UI thread. Open is refused until it finishes, so the nest
|
||||||
|
/// and its drawings stay stable from the request to the commit.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RunAutoNestAsync()
|
||||||
|
{
|
||||||
|
if (nest == null || nesting != null)
|
||||||
|
return;
|
||||||
|
var engineName = EngineSelection.EngineName;
|
||||||
|
var template = Canvas.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();
|
||||||
|
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)
|
||||||
|
RefreshPlateList(firstNew);
|
||||||
|
ShowNotice(kept.Nested ? $"{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;
|
||||||
|
SetNesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetNesting(bool running)
|
||||||
|
{
|
||||||
|
AutoNestButton.Content = running ? "Stop" : "Auto Nest";
|
||||||
|
AutoNestMenuItem.Header = running ? "_Stop Auto Nest" : "_Auto Nest";
|
||||||
|
OpenButton.IsEnabled = !running;
|
||||||
|
OpenMenuItem.IsEnabled = !running;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowNotice(string text)
|
||||||
|
{
|
||||||
|
notice = text;
|
||||||
|
UpdateStatus();
|
||||||
|
if (Canvas.Plate == null)
|
||||||
|
Status.Text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFit(object sender, RoutedEventArgs e) => Canvas.ZoomToFit();
|
||||||
|
|
||||||
|
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
|
||||||
|
|
||||||
|
private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
|
||||||
|
|
||||||
|
private void StepPlate(int step)
|
||||||
|
{
|
||||||
|
if (nest == null || nest.Plates.Count == 0)
|
||||||
|
return;
|
||||||
|
PlateList.SelectedIndex = System.Math.Clamp(PlateList.SelectedIndex + step, 0, nest.Plates.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlateSelected(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
var index = PlateList.SelectedIndex;
|
||||||
|
var plate = nest != null && index >= 0 ? nest.Plates[index] : null;
|
||||||
|
Canvas.Plate = plate;
|
||||||
|
DrawingGrid.ItemsSource = plate?.Parts
|
||||||
|
.Where(part => !part.BaseDrawing.IsCutOff)
|
||||||
|
.GroupBy(part => part.BaseDrawing)
|
||||||
|
.Select(group => new DrawingRow(group.Key.Name, group.Count(), SizeOf(group.First())))
|
||||||
|
.OrderByDescending(row => row.Count)
|
||||||
|
.ToList();
|
||||||
|
UpdateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SizeOf(Part part) =>
|
||||||
|
$"{part.BoundingBox.Length:0.###} x {part.BoundingBox.Width:0.###}";
|
||||||
|
|
||||||
|
private void UpdateStatus()
|
||||||
|
{
|
||||||
|
if (Canvas.Plate == null)
|
||||||
|
return;
|
||||||
|
var text = $"{fileName} Plate {PlateList.SelectedIndex + 1} of {nest.Plates.Count} Zoom {Canvas.Zoom:0.##} px/unit";
|
||||||
|
if (Canvas.CursorWorld is { } at)
|
||||||
|
text += $" X {at.X:0.000} Y {at.Y:0.000}";
|
||||||
|
var part = Canvas.HoveredPart ?? Canvas.SelectedPart;
|
||||||
|
if (part != null)
|
||||||
|
text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}";
|
||||||
|
if (notice != null)
|
||||||
|
text += $" | {notice}";
|
||||||
|
Status.Text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Command : System.Windows.Input.ICommand
|
||||||
|
{
|
||||||
|
private readonly Action action;
|
||||||
|
|
||||||
|
public Command(Action action) => this.action = action;
|
||||||
|
|
||||||
|
public event EventHandler CanExecuteChanged { add { } remove { } }
|
||||||
|
|
||||||
|
public bool CanExecute(object parameter) => true;
|
||||||
|
|
||||||
|
public void Execute(object parameter) => action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RootNamespace>OpenNest.Avalonia</RootNamespace>
|
||||||
|
<AssemblyName>OpenNest.Avalonia</AssemblyName>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||||
|
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.csproj" />
|
||||||
|
<PackageReference Include="Avalonia" Version="11.3.0" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.0" />
|
||||||
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace OpenNest.Avalonia
|
||||||
|
{
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args) =>
|
||||||
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
|
||||||
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
|
AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using System;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
namespace OpenNest.Avalonia.Rendering
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A program's display geometry as Skia paths in the program frame, before placement.
|
||||||
|
/// A Skia port of the desktop's GraphicsHelper.GetDisplayPaths, without tab bridging.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ProgramPaths : IDisposable
|
||||||
|
{
|
||||||
|
public SKPath Cut { get; } = new() { FillType = SKPathFillType.EvenOdd };
|
||||||
|
|
||||||
|
public SKPath Lead { get; } = new();
|
||||||
|
|
||||||
|
public SKPath Etch { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>The material outline to fill: cuts and tabs, without open break cuts.</summary>
|
||||||
|
public SKPath Fill { get; } = new() { FillType = SKPathFillType.EvenOdd };
|
||||||
|
|
||||||
|
public SKRect Bounds { get; private set; }
|
||||||
|
|
||||||
|
public static ProgramPaths Build(CNC.Program program)
|
||||||
|
{
|
||||||
|
var paths = new ProgramPaths();
|
||||||
|
var position = Vector.Zero;
|
||||||
|
paths.AddProgram(program, ref position);
|
||||||
|
var bounds = paths.Cut.Bounds;
|
||||||
|
if (!paths.Etch.IsEmpty)
|
||||||
|
bounds = paths.Cut.IsEmpty ? paths.Etch.Bounds : SKRect.Union(bounds, paths.Etch.Bounds);
|
||||||
|
paths.Bounds = bounds;
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SKPath TargetFor(LayerType layer) => layer switch
|
||||||
|
{
|
||||||
|
LayerType.Leadin or LayerType.Leadout => Lead,
|
||||||
|
LayerType.Scribe => Etch,
|
||||||
|
LayerType.Display or LayerType.Tab => null,
|
||||||
|
_ => Cut,
|
||||||
|
};
|
||||||
|
|
||||||
|
private void AddProgram(CNC.Program program, ref Vector position)
|
||||||
|
{
|
||||||
|
// Sub-program offsets are relative to the frame origin at entry, not the tool position.
|
||||||
|
var frameOrigin = position;
|
||||||
|
var mode = program.Mode;
|
||||||
|
SKPath open = null;
|
||||||
|
|
||||||
|
for (var i = 0; i < program.Length; ++i)
|
||||||
|
{
|
||||||
|
var code = program[i];
|
||||||
|
switch (code)
|
||||||
|
{
|
||||||
|
case LinearMove line:
|
||||||
|
{
|
||||||
|
var end = mode == Mode.Incremental ? line.EndPoint + position : line.EndPoint;
|
||||||
|
var target = line.Suppressed ? null : TargetFor(line.Layer);
|
||||||
|
if (target != null)
|
||||||
|
{
|
||||||
|
Continue(ref open, target, position);
|
||||||
|
target.LineTo((float)end.X, (float)end.Y);
|
||||||
|
}
|
||||||
|
if (!line.Suppressed && line.Layer != LayerType.Break)
|
||||||
|
AddFillLine(line.Layer, position, end);
|
||||||
|
position = end;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case ArcMove arc:
|
||||||
|
{
|
||||||
|
var end = arc.EndPoint;
|
||||||
|
var center = arc.CenterPoint;
|
||||||
|
if (mode == Mode.Incremental)
|
||||||
|
{
|
||||||
|
end += position;
|
||||||
|
center += position;
|
||||||
|
}
|
||||||
|
var target = arc.Suppressed ? null : TargetFor(arc.Layer);
|
||||||
|
if (target != null)
|
||||||
|
{
|
||||||
|
Continue(ref open, target, position);
|
||||||
|
AddArc(target, position, end, center, arc.Rotation);
|
||||||
|
}
|
||||||
|
if (!arc.Suppressed && arc.Layer != LayerType.Break)
|
||||||
|
AddFillArc(arc.Layer, position, end, center, arc.Rotation);
|
||||||
|
position = end;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case RapidMove rapid:
|
||||||
|
{
|
||||||
|
var end = mode == Mode.Incremental ? rapid.EndPoint + position : rapid.EndPoint;
|
||||||
|
if (end.DistanceTo(position) > 0.001)
|
||||||
|
open = null;
|
||||||
|
position = end;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SubProgramCall call when call.Program != null:
|
||||||
|
open = null;
|
||||||
|
position = new Vector(frameOrigin.X + call.Offset.X, frameOrigin.Y + call.Offset.Y);
|
||||||
|
AddProgram(call.Program, ref position);
|
||||||
|
open = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts a new contour unless the move continues the one already open on the same path.
|
||||||
|
private SKPath openFill;
|
||||||
|
|
||||||
|
private static void Continue(ref SKPath open, SKPath target, Vector from)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(open, target) || target.LastPoint != new SKPoint((float)from.X, (float)from.Y))
|
||||||
|
target.MoveTo((float)from.X, (float)from.Y);
|
||||||
|
open = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsMaterial(LayerType layer) =>
|
||||||
|
layer is not (LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Display);
|
||||||
|
|
||||||
|
private void AddFillLine(LayerType layer, Vector from, Vector to)
|
||||||
|
{
|
||||||
|
if (!IsMaterial(layer))
|
||||||
|
return;
|
||||||
|
Continue(ref openFill, Fill, from);
|
||||||
|
Fill.LineTo((float)to.X, (float)to.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddFillArc(LayerType layer, Vector from, Vector to, Vector center, RotationType rotation)
|
||||||
|
{
|
||||||
|
if (!IsMaterial(layer))
|
||||||
|
return;
|
||||||
|
Continue(ref openFill, Fill, from);
|
||||||
|
AddArc(Fill, from, to, center, rotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddArc(SKPath path, Vector from, Vector to, Vector center, RotationType rotation)
|
||||||
|
{
|
||||||
|
var startAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(from.Y - center.Y, from.X - center.X)));
|
||||||
|
var endAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(to.Y - center.Y, to.X - center.X)));
|
||||||
|
var radius = (float)center.DistanceTo(to);
|
||||||
|
var oval = new SKRect((float)center.X - radius, (float)center.Y - radius,
|
||||||
|
(float)center.X + radius, (float)center.Y + radius);
|
||||||
|
|
||||||
|
if (startAngle.IsEqualTo(endAngle))
|
||||||
|
{
|
||||||
|
// A full circle: two half sweeps keep the contour starting at the current point.
|
||||||
|
var sweep = rotation == RotationType.CCW ? 180f : -180f;
|
||||||
|
path.ArcTo(oval, (float)startAngle, sweep, false);
|
||||||
|
path.ArcTo(oval, (float)startAngle + sweep, sweep, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rotation == RotationType.CCW && endAngle < startAngle)
|
||||||
|
endAngle += 360.0;
|
||||||
|
else if (rotation == RotationType.CW && startAngle < endAngle)
|
||||||
|
startAngle += 360.0;
|
||||||
|
|
||||||
|
path.ArcTo(oval, (float)startAngle, (float)(endAngle - startAngle), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Cut.Dispose();
|
||||||
|
Lead.Dispose();
|
||||||
|
Etch.Dispose();
|
||||||
|
Fill.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -37,9 +38,19 @@ namespace OpenNest.Benchmark
|
|||||||
public double PartSpacing { get; init; }
|
public double PartSpacing { get; init; }
|
||||||
public int Quadrant { get; init; }
|
public int Quadrant { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Source plate's machine clamp rail, resolved for each candidate size as Auto Nest does; null when none.</summary>
|
||||||
|
public ClampRail ClampRail { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Source plate's explicit clamp zones when it has no rail, offered unchanged on every size.</summary>
|
||||||
|
public IReadOnlyList<ClampZone> ClampZones { get; init; } = Array.Empty<ClampZone>();
|
||||||
|
|
||||||
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
|
/// <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; }
|
||||||
@@ -60,7 +71,8 @@ namespace OpenNest.Benchmark
|
|||||||
/// (unlimited quantity - the engine under test decides how many of each
|
/// (unlimited quantity - the engine under test decides how many of each
|
||||||
/// size it actually uses, and how demand splits across plates). The
|
/// size it actually uses, and how demand splits across plates). The
|
||||||
/// engine owns its own multi-plate/size strategy; this harness no
|
/// engine owns its own multi-plate/size strategy; this harness no
|
||||||
/// longer picks plate sizes on the engine's behalf.
|
/// longer picks plate sizes on the engine's behalf. Stock comes from
|
||||||
|
/// the same template builder as desktop Auto Nest, so clamps match it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public NestJob BuildNestJob(
|
public NestJob BuildNestJob(
|
||||||
int maxPlates,
|
int maxPlates,
|
||||||
@@ -71,24 +83,41 @@ namespace OpenNest.Benchmark
|
|||||||
var parts = Requests.Select(r =>
|
var parts = Requests.Select(r =>
|
||||||
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
|
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
|
||||||
);
|
);
|
||||||
var stock = CandidateSizes.Select(size => new NestPlateStock(
|
// No options would make the builder offer the template itself; an empty pool stays empty.
|
||||||
size.ToString(1),
|
var stock = CandidateSizes.Count == 0
|
||||||
size,
|
? Array.Empty<NestPlateStock>()
|
||||||
null,
|
: NestStockBuilder.FromTemplate(
|
||||||
PartSpacing,
|
StockTemplate(),
|
||||||
EdgeSpacing,
|
CandidateSizes.Select(size => new PlateOption { Width = size.Width, Length = size.Length }).ToList()
|
||||||
Quadrant
|
);
|
||||||
));
|
|
||||||
return new NestJob(
|
return new NestJob(
|
||||||
parts,
|
parts,
|
||||||
stock,
|
stock,
|
||||||
new NestJobOptions(
|
new NestJobOptions(
|
||||||
"Fill",
|
"Lattice",
|
||||||
maxPlates,
|
maxPlates,
|
||||||
salvageRate ?? SalvageRate,
|
salvageRate ?? SalvageRate,
|
||||||
minimumSalvageDimension ?? 0
|
minimumSalvageDimension ?? 0,
|
||||||
|
offcutCut: OffcutCut
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A plate carrying this job's spacing, quadrant and clamps, sized to the largest candidate.</summary>
|
||||||
|
private Plate StockTemplate()
|
||||||
|
{
|
||||||
|
var template = new Plate(CandidateSizes.MaxBy(size => size.Width * size.Length))
|
||||||
|
{
|
||||||
|
PartSpacing = PartSpacing,
|
||||||
|
EdgeSpacing = EdgeSpacing,
|
||||||
|
Quadrant = Quadrant,
|
||||||
|
};
|
||||||
|
if (ClampRail != null)
|
||||||
|
template.SetClampRail(ClampRail);
|
||||||
|
else
|
||||||
|
foreach (var zone in ClampZones)
|
||||||
|
template.ClampZones.Add(zone);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,8 @@ 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
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
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 +82,8 @@ namespace OpenNest.Benchmark
|
|||||||
salvageRate,
|
salvageRate,
|
||||||
minimumSalvageDimension,
|
minimumSalvageDimension,
|
||||||
outputDirectory,
|
outputDirectory,
|
||||||
progressLog
|
progressLog,
|
||||||
|
timeLimit
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -126,14 +138,7 @@ namespace OpenNest.Benchmark
|
|||||||
.BaselinePlateRuns.Select(
|
.BaselinePlateRuns.Select(
|
||||||
(run, index) =>
|
(run, index) =>
|
||||||
{
|
{
|
||||||
var stock = new NestPlateStock(
|
var stock = DrawingJobMapper.FromPlate($"baseline-{index}", run.Plate, 1);
|
||||||
$"baseline-{index}",
|
|
||||||
run.Plate.Size,
|
|
||||||
1,
|
|
||||||
run.Plate.PartSpacing,
|
|
||||||
run.Plate.EdgeSpacing,
|
|
||||||
run.Plate.Quadrant
|
|
||||||
);
|
|
||||||
var placements = run
|
var placements = run
|
||||||
.Parts.Select(part =>
|
.Parts.Select(part =>
|
||||||
{
|
{
|
||||||
@@ -221,13 +226,21 @@ 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
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var requested = job.TotalRequestedQuantity;
|
var requested = job.TotalRequestedQuantity;
|
||||||
@@ -241,7 +254,9 @@ 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)
|
||||||
|
engine = new DeadlineNestingEngine(engine, limit);
|
||||||
|
using var cts = new CancellationTokenSource(HarnessTimeout(timeLimit));
|
||||||
var jobResult = engine.Solve(nestJob, log, cts.Token);
|
var jobResult = engine.Solve(nestJob, log, cts.Token);
|
||||||
log?.Finished(jobResult, sw.ElapsedMilliseconds);
|
log?.Finished(jobResult, sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
@@ -363,7 +378,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);
|
||||||
@@ -375,7 +390,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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,8 @@ namespace OpenNest.Benchmark
|
|||||||
EdgeSpacing = template.EdgeSpacing,
|
EdgeSpacing = template.EdgeSpacing,
|
||||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||||
Quadrant = template.Quadrant,
|
Quadrant = template.Quadrant,
|
||||||
|
ClampRail = template.ClampRail,
|
||||||
|
ClampZones = template.ClampZones,
|
||||||
SalvageRate = nest.SalvageRate,
|
SalvageRate = nest.SalvageRate,
|
||||||
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
|
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
|
||||||
Requests = requests,
|
Requests = requests,
|
||||||
@@ -182,23 +184,45 @@ namespace OpenNest.Benchmark
|
|||||||
plate.EdgeSpacing.Top
|
plate.EdgeSpacing.Top
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
// Clamps are part of the sheet the layout was made on.
|
||||||
|
if (plate.ClampRail != null)
|
||||||
|
validationPlate.SetClampRail(plate.ClampRail, plate.RemovedClamps);
|
||||||
|
else
|
||||||
|
foreach (var zone in plate.ClampZones)
|
||||||
|
validationPlate.ClampZones.Add(zone);
|
||||||
for (var copy = 0; copy < plate.Quantity; copy++)
|
for (var copy = 0; copy < plate.Quantity; copy++)
|
||||||
runs.Add((validationPlate, parts));
|
runs.Add((validationPlate, parts));
|
||||||
}
|
}
|
||||||
return runs.Count > 0 ? runs : null;
|
return runs.Count > 0 ? runs : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
|
private static (
|
||||||
Nest nest
|
Spacing EdgeSpacing,
|
||||||
)
|
double PartSpacing,
|
||||||
|
int Quadrant,
|
||||||
|
ClampRail ClampRail,
|
||||||
|
IReadOnlyList<ClampZone> ClampZones
|
||||||
|
) ResolvePlateTemplate(Nest nest)
|
||||||
{
|
{
|
||||||
var source = nest.Plates?.FirstOrDefault();
|
var source = nest.Plates?.FirstOrDefault();
|
||||||
|
|
||||||
if (source != null)
|
if (source != null)
|
||||||
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
|
return (
|
||||||
|
source.EdgeSpacing,
|
||||||
|
source.PartSpacing,
|
||||||
|
source.Quadrant,
|
||||||
|
source.ClampRail,
|
||||||
|
source.ClampRail == null ? source.ClampZones.ToList() : Array.Empty<ClampZone>()
|
||||||
|
);
|
||||||
|
|
||||||
var defaults = nest.PlateDefaults;
|
var defaults = nest.PlateDefaults;
|
||||||
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
|
return (
|
||||||
|
defaults.EdgeSpacing,
|
||||||
|
defaults.PartSpacing,
|
||||||
|
defaults.Quadrant,
|
||||||
|
defaults.ClampRail,
|
||||||
|
Array.Empty<ClampZone>()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Size> ResolveSheetSizes(Nest nest)
|
private static List<Size> ResolveSheetSizes(Nest nest)
|
||||||
|
|||||||
@@ -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,8 @@ 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
|
||||||
);
|
);
|
||||||
|
|
||||||
Report.PrintDetailed(results);
|
Report.PrintDetailed(results);
|
||||||
@@ -197,6 +201,18 @@ 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;
|
||||||
@@ -235,6 +251,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 +362,12 @@ 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(" --help Show this message");
|
Console.Error.WriteLine(" --help Show this message");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,5 +383,7 @@ 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 bool Invalid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+104
-21
@@ -35,7 +35,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)
|
||||||
@@ -115,7 +115,7 @@ static class NestConsole
|
|||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
engine.IsJobEngine
|
engine.IsJobEngine
|
||||||
? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
||||||
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as StockLadder require --autonest)"
|
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as Irregular require --autonest)"
|
||||||
);
|
);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -132,14 +132,26 @@ static class NestConsole
|
|||||||
|
|
||||||
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|
||||||
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|
||||||
|| options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
|
|| options.OutlineFile != null || options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size or --template."
|
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size, --outline or --template."
|
||||||
);
|
);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.OutlineFile != null && !options.AutoNest)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Error: --outline needs --autonest; single-plate fill strategies fill rectangles only.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.OutlineFile != null && !File.Exists(options.OutlineFile))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Error: Outline file not found: {options.OutlineFile}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.InputFiles.Count == 0)
|
if (options.InputFiles.Count == 0)
|
||||||
{
|
{
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
@@ -197,6 +209,12 @@ static class NestConsole
|
|||||||
ApplyTemplate(plate, options);
|
ApplyTemplate(plate, options);
|
||||||
ApplyOverrides(plate, options);
|
ApplyOverrides(plate, options);
|
||||||
|
|
||||||
|
if (!options.AutoNest && PlateFillService.Refusal(plate) is { } refusal)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Error: {refusal} Use --autonest.");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
var drawing = ResolveDrawing(nest, options);
|
var drawing = ResolveDrawing(nest, options);
|
||||||
|
|
||||||
if (drawing == null)
|
if (drawing == null)
|
||||||
@@ -257,6 +275,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 +297,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 +408,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 +457,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 +504,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 +555,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 +634,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 +678,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)
|
||||||
{
|
{
|
||||||
@@ -824,6 +897,7 @@ static class NestConsole
|
|||||||
{
|
{
|
||||||
CuttingPlanPhase.Reordering => "finding a part order...",
|
CuttingPlanPhase.Reordering => "finding a part order...",
|
||||||
CuttingPlanPhase.KeepingOrder => "keeping the current order...",
|
CuttingPlanPhase.KeepingOrder => "keeping the current order...",
|
||||||
|
CuttingPlanPhase.SafestRoute => "finding the safest route...",
|
||||||
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
|
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
|
||||||
_ => value.Phase + "...",
|
_ => value.Phase + "...",
|
||||||
});
|
});
|
||||||
@@ -980,7 +1054,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)
|
||||||
@@ -989,12 +1063,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
|
||||||
@@ -1036,6 +1117,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;
|
||||||
@@ -1058,5 +1140,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,14 @@ public sealed class AutomaticCutOffOptions
|
|||||||
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
|
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double? MinimumStripLength { get; set; }
|
public double? MinimumStripLength { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to propose a full-length horizontal separator that keeps the unused band beyond the
|
||||||
|
/// parts along Y (away from the origin) when that band is a larger piece than the X tail.
|
||||||
|
/// The band must be at least MinimumTailLength wide. False preserves vertical-only proposals.
|
||||||
|
/// An existing full-length horizontal cut-off clear of the parts is honored either way.
|
||||||
|
/// </summary>
|
||||||
|
public bool KeepHorizontalRemnant { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AutomaticCutOffDiagnosticCode
|
public enum AutomaticCutOffDiagnosticCode
|
||||||
@@ -39,6 +47,8 @@ public enum AutomaticCutOffDiagnosticCode
|
|||||||
NoSafeTailSeparator,
|
NoSafeTailSeparator,
|
||||||
TailBelowMinimum,
|
TailBelowMinimum,
|
||||||
ShiftedCut,
|
ShiftedCut,
|
||||||
|
HorizontalRemnant,
|
||||||
|
NoSafeHorizontalSeparator,
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record AutomaticCutOffDiagnostic(
|
public sealed record AutomaticCutOffDiagnostic(
|
||||||
@@ -77,6 +87,15 @@ public sealed class AutomaticCutOffPlan
|
|||||||
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
|
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
|
||||||
public bool HasSeparatedTail => TailSeparatorX.HasValue;
|
public bool HasSeparatedTail => TailSeparatorX.HasValue;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signed Y coordinate of the new or existing full-length horizontal separator the vertical
|
||||||
|
/// lines stop at, keeping the band beyond it; null when the lines cross the full sheet width.
|
||||||
|
/// </summary>
|
||||||
|
public double? HorizontalSeparatorY { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>Width along Y of the band kept beyond HorizontalSeparatorY, otherwise zero.</summary>
|
||||||
|
public double HorizontalRemnantWidth { get; internal set; }
|
||||||
|
|
||||||
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
|
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
|
||||||
public double StripCutLength { get; internal set; }
|
public double StripCutLength { get; internal set; }
|
||||||
|
|
||||||
@@ -90,6 +109,15 @@ public sealed class AutomaticCutOffPlan
|
|||||||
public static class AutomaticCutOffPlanner
|
public static class AutomaticCutOffPlanner
|
||||||
{
|
{
|
||||||
public const double GeometryTolerance = Tolerance.Epsilon;
|
public const double GeometryTolerance = Tolerance.Epsilon;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How far a display perimeter (a supplied blank's never-cut outline) may extend past the
|
||||||
|
/// physical sheet, in model units. Imported blanks equal their sheet but carry the source CAM's
|
||||||
|
/// rounding (PEP: a 0.000006 outline skew plus placements rounded to 0.001), a few hundred-
|
||||||
|
/// thousandths of an inch. Nothing cuts that outline, so it is accepted; anything cut, or a
|
||||||
|
/// larger overhang that signals a real placement error, is still refused.
|
||||||
|
/// </summary>
|
||||||
|
public const double DisplayPerimeterSheetAllowance = 1e-4;
|
||||||
public const double MinimumSpacing = 2 * GeometryTolerance;
|
public const double MinimumSpacing = 2 * GeometryTolerance;
|
||||||
public const int MaximumCandidateCount = 10000;
|
public const int MaximumCandidateCount = 10000;
|
||||||
public const int MaximumShiftCandidateCount = 20000;
|
public const int MaximumShiftCandidateCount = 20000;
|
||||||
@@ -115,7 +143,9 @@ public static class AutomaticCutOffPlanner
|
|||||||
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
|
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
|
||||||
|
|
||||||
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
|
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
|
||||||
|
var ySign = plate.Quadrant is 3 or 4 ? -1 : 1;
|
||||||
var occupied = 0.0;
|
var occupied = 0.0;
|
||||||
|
var occupiedY = 0.0;
|
||||||
var hasParts = false;
|
var hasParts = false;
|
||||||
foreach (var part in plate.Parts)
|
foreach (var part in plate.Parts)
|
||||||
{
|
{
|
||||||
@@ -123,7 +153,9 @@ public static class AutomaticCutOffPlanner
|
|||||||
if (part.BaseDrawing.IsCutOff)
|
if (part.BaseDrawing.IsCutOff)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign));
|
var (partX, partY) = ValidatePart(part, bounds, sign, ySign);
|
||||||
|
occupied = System.Math.Max(occupied, partX);
|
||||||
|
occupiedY = System.Math.Max(occupiedY, partY);
|
||||||
hasParts = true;
|
hasParts = true;
|
||||||
}
|
}
|
||||||
if (!hasParts)
|
if (!hasParts)
|
||||||
@@ -131,16 +163,62 @@ public static class AutomaticCutOffPlanner
|
|||||||
|
|
||||||
// Use distances from the coordinate origin, not the sheet's lower-left corner.
|
// Use distances from the coordinate origin, not the sheet's lower-left corner.
|
||||||
var length = plate.Size.Length;
|
var length = plate.Size.Length;
|
||||||
|
var width = plate.Size.Width;
|
||||||
occupied = System.Math.Min(occupied, length);
|
occupied = System.Math.Min(occupied, length);
|
||||||
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
|
occupiedY = System.Math.Min(occupiedY, width);
|
||||||
+ GeometryTolerance;
|
var margin = System.Math.Max(plate.PartSpacing, settings.PartClearance) + GeometryTolerance;
|
||||||
|
var separator = occupied + margin;
|
||||||
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
|
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
|
||||||
|
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
||||||
|
|
||||||
|
// A band beyond the parts along Y is kept by a full-length horizontal separator; the
|
||||||
|
// vertical lines then stop at it instead of slicing the band. A user's line clear of the
|
||||||
|
// parts is honored; a new one is proposed only when its band beats the X tail.
|
||||||
|
var horizontalDistance = plate.CutOffs
|
||||||
|
.Where(c => c?.Axis == CutOffAxis.Horizontal && Finite(c.Position) && FullLengthLimits(c, bounds, settings))
|
||||||
|
.Select(c => ySign * c.Position.Y)
|
||||||
|
.Where(distance => distance > occupiedY + GeometryTolerance && distance < width - GeometryTolerance
|
||||||
|
&& width - distance >= options.MinimumTailLength)
|
||||||
|
.Select(distance => (double?)distance)
|
||||||
|
.Min();
|
||||||
|
var horizontalExists = horizontalDistance.HasValue;
|
||||||
|
if (!horizontalExists && options.KeepHorizontalRemnant)
|
||||||
|
{
|
||||||
|
var proposed = occupiedY + margin;
|
||||||
|
var tailArea = hasTailCandidate && length - separator >= options.MinimumTailLength
|
||||||
|
? (length - separator) * width : 0;
|
||||||
|
if (double.IsFinite(proposed) && proposed < width - GeometryTolerance
|
||||||
|
&& width - proposed >= options.MinimumTailLength && (width - proposed) * length > tailArea)
|
||||||
|
horizontalDistance = proposed;
|
||||||
|
}
|
||||||
|
if (horizontalDistance.HasValue && !horizontalExists)
|
||||||
|
{
|
||||||
|
var y = ySign * horizontalDistance.Value;
|
||||||
|
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
|
||||||
|
var clamp = clamps.FirstOrDefault(zone =>
|
||||||
|
y >= zone.Bounds.Bottom - plate.ClampClearance && y <= zone.Bounds.Top + plate.ClampClearance);
|
||||||
|
if (limited || clamp != null)
|
||||||
|
{
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
|
||||||
|
(clamp != null ? $"A horizontal separator at Y = {y:G8} would cross clamp '{clamp.Id}'"
|
||||||
|
: $"A same-line manual cut at Y = {y:G8} has different limits")
|
||||||
|
+ "; vertical lines cross the full sheet width instead."));
|
||||||
|
horizontalDistance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A tail too short to keep stays attached to the skeleton, so the strip grid must
|
// A tail too short to keep stays attached to the skeleton, so the strip grid must
|
||||||
// continue to the sheet end; stopping at the skipped separator would leave a final
|
// continue to the sheet end; stopping at the skipped separator would leave a final
|
||||||
// strip longer than the spacing.
|
// strip longer than the spacing. Beside a kept horizontal band the tail is a corner
|
||||||
var keepsTail = hasTailCandidate && length - separator >= options.MinimumTailLength;
|
// piece, which must also be wide enough to keep.
|
||||||
|
bool KeepsTail(double? band) => hasTailCandidate && length - separator >= options.MinimumTailLength
|
||||||
|
&& (band == null || band.Value >= options.MinimumTailLength);
|
||||||
|
var keepsTail = KeepsTail(horizontalDistance);
|
||||||
var usedSpan = keepsTail ? separator : length;
|
var usedSpan = keepsTail ? separator : length;
|
||||||
var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
|
// Bound the count for either outcome: the horizontal separator is verified only below.
|
||||||
|
var candidateCount = System.Math.Ceiling(System.Math.Max(usedSpan, KeepsTail(null) ? separator : length)
|
||||||
|
/ options.Spacing);
|
||||||
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
|
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
|
||||||
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
|
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
|
||||||
|
|
||||||
@@ -148,7 +226,31 @@ public static class AutomaticCutOffPlanner
|
|||||||
ValidateExisting(plate, bounds, settings);
|
ValidateExisting(plate, bounds, settings);
|
||||||
var cache = Plate.BuildPerimeterCache(plate);
|
var cache = Plate.BuildPerimeterCache(plate);
|
||||||
var definitions = new List<CutOff>();
|
var definitions = new List<CutOff>();
|
||||||
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
CutOff horizontalSeparator = null;
|
||||||
|
if (horizontalDistance.HasValue && !horizontalExists)
|
||||||
|
{
|
||||||
|
horizontalSeparator = new CutOff(new Vector(0, ySign * horizontalDistance.Value), CutOffAxis.Horizontal);
|
||||||
|
horizontalSeparator.Regenerate(plate, settings, cache);
|
||||||
|
if (!HasUsableSegments(horizontalSeparator.Drawing.Program, false) ||
|
||||||
|
!IsFullSpanProgram(horizontalSeparator.Drawing.Program, false, bounds.Left, bounds.Right))
|
||||||
|
{
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
|
||||||
|
"No full-length horizontal separator survives the current settings; "
|
||||||
|
+ "vertical lines cross the full sheet width instead."));
|
||||||
|
horizontalSeparator = null;
|
||||||
|
horizontalDistance = null;
|
||||||
|
keepsTail = KeepsTail(null);
|
||||||
|
usedSpan = keepsTail ? separator : length;
|
||||||
|
candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Vertical lines run from the origin-side edge up to a kept band's separator.
|
||||||
|
var horizontalY = horizontalDistance.HasValue ? ySign * horizontalDistance.Value : (double?)null;
|
||||||
|
var startLimit = ySign < 0 ? horizontalY : null;
|
||||||
|
var endLimit = ySign > 0 ? horizontalY : null;
|
||||||
|
var spanLow = startLimit ?? bounds.Bottom;
|
||||||
|
var spanHigh = endLimit ?? bounds.Top;
|
||||||
var separated = false;
|
var separated = false;
|
||||||
var separatorX = (double?)null;
|
var separatorX = (double?)null;
|
||||||
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
|
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
|
||||||
@@ -162,7 +264,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
// Full-span definitions fix the strip boundaries, even when their emitted
|
// Full-span definitions fix the strip boundaries, even when their emitted
|
||||||
// programs are segmented. Partial cuts remain obstacles, not boundaries.
|
// programs are segmented. Partial cuts remain obstacles, not boundaries.
|
||||||
var boundaries = plate.CutOffs
|
var boundaries = plate.CutOffs
|
||||||
.Where(c => c.Axis == CutOffAxis.Vertical && FullSpanLimits(c, bounds, settings))
|
.Where(c => c.Axis == CutOffAxis.Vertical && Spans(c))
|
||||||
.Select(c => sign * c.Position.X)
|
.Select(c => sign * c.Position.X)
|
||||||
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
|
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
|
||||||
.Distinct().OrderBy(distance => distance).Append(usedSpan);
|
.Distinct().OrderBy(distance => distance).Append(usedSpan);
|
||||||
@@ -212,6 +314,15 @@ public static class AutomaticCutOffPlanner
|
|||||||
+ "and strip lines continue to the sheet end. Existing cut-offs are unchanged. No retained tail is claimed.",
|
+ "and strip lines continue to the sheet end. Existing cut-offs are unchanged. No retained tail is claimed.",
|
||||||
X: sign * separator));
|
X: sign * separator));
|
||||||
|
|
||||||
|
if (horizontalSeparator != null)
|
||||||
|
definitions.Add(horizontalSeparator);
|
||||||
|
if (horizontalY.HasValue)
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.HorizontalRemnant,
|
||||||
|
$"Vertical lines stop at the {(horizontalExists ? "existing" : "new")} horizontal separator at "
|
||||||
|
+ $"Y = {horizontalY.Value:G8}, keeping a {width - horizontalDistance.Value:G8}-wide band "
|
||||||
|
+ "along the sheet length."));
|
||||||
|
|
||||||
if (diagnostics.Any(d => d.IsBlocking))
|
if (diagnostics.Any(d => d.IsBlocking))
|
||||||
{
|
{
|
||||||
// A partial proposal must not accidentally be accepted around a manual conflict.
|
// A partial proposal must not accidentally be accepted around a manual conflict.
|
||||||
@@ -219,12 +330,24 @@ public static class AutomaticCutOffPlanner
|
|||||||
stripCutLength = 0;
|
stripCutLength = 0;
|
||||||
separated = false;
|
separated = false;
|
||||||
separatorX = null;
|
separatorX = null;
|
||||||
|
horizontalY = null;
|
||||||
}
|
}
|
||||||
// The legacy placement loop stays untouched. Its detached programs are already
|
// The legacy placement loop stays untouched. Its detached programs are already
|
||||||
// regenerated, so reporting their actual new strip cutting needs no new search.
|
// regenerated, so reporting their actual new strip cutting needs no new search.
|
||||||
if (!shifted)
|
if (!shifted)
|
||||||
fixedGridCutLength = stripCutLength;
|
fixedGridCutLength = stripCutLength;
|
||||||
|
|
||||||
|
// Lines are planned and measured without the head-locked overcut: it only extends the
|
||||||
|
// segment reaching the far edge past the sheet, where there is nothing to separate.
|
||||||
|
if (settings.NewCutOffHeadLockOvercut > 0)
|
||||||
|
{
|
||||||
|
foreach (var definition in definitions)
|
||||||
|
{
|
||||||
|
definition.HeadLockOvercut = settings.NewCutOffHeadLockOvercut;
|
||||||
|
definition.Regenerate(plate, settings, cache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new AutomaticCutOffPlan
|
return new AutomaticCutOffPlan
|
||||||
{
|
{
|
||||||
Definitions = definitions.AsReadOnly(),
|
Definitions = definitions.AsReadOnly(),
|
||||||
@@ -236,8 +359,22 @@ public static class AutomaticCutOffPlanner
|
|||||||
TailSeparatorX = separatorX,
|
TailSeparatorX = separatorX,
|
||||||
StripCutLength = stripCutLength,
|
StripCutLength = stripCutLength,
|
||||||
FixedGridCutLength = fixedGridCutLength,
|
FixedGridCutLength = fixedGridCutLength,
|
||||||
|
HorizontalSeparatorY = horizontalY,
|
||||||
|
HorizontalRemnantWidth = horizontalY.HasValue ? width - horizontalDistance.Value : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
CutOff NewVertical(double x) => new(new Vector(x, 0), CutOffAxis.Vertical)
|
||||||
|
{
|
||||||
|
StartLimit = startLimit,
|
||||||
|
EndLimit = endLimit,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A full-span line, or one with the limits this plan gives its own vertical lines.
|
||||||
|
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
|
||||||
|
horizontalY.HasValue && cut.Gaps.Count == 0 &&
|
||||||
|
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
|
||||||
|
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
|
||||||
|
|
||||||
void AddShiftedGap(double start, double end, int count, double step)
|
void AddShiftedGap(double start, double end, int count, double step)
|
||||||
{
|
{
|
||||||
var nominal = new List<double>(count);
|
var nominal = new List<double>(count);
|
||||||
@@ -290,8 +427,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
RegisterCandidate(distance);
|
RegisterCandidate(distance);
|
||||||
if (!measurements.TryGetValue(distance, out var measured))
|
if (!measurements.TryGetValue(distance, out var measured))
|
||||||
{
|
{
|
||||||
measured = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical)
|
measured = NewVertical(sign * distance).MeasureSegments(plate, settings, cache);
|
||||||
.MeasureSegments(plate, settings, cache);
|
|
||||||
measurements.Add(distance, measured);
|
measurements.Add(distance, measured);
|
||||||
}
|
}
|
||||||
return measured;
|
return measured;
|
||||||
@@ -302,7 +438,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
|
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
|
||||||
Near(c.Position.X, x)).ToList();
|
Near(c.Position.X, x)).ToList();
|
||||||
|
|
||||||
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
|
if (matches.Any(c => !Spans(c)))
|
||||||
{
|
{
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
|
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
|
||||||
@@ -324,7 +460,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);
|
||||||
@@ -335,9 +471,9 @@ public static class AutomaticCutOffPlanner
|
|||||||
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
|
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
|
||||||
var existing = matches.FirstOrDefault();
|
var existing = matches.FirstOrDefault();
|
||||||
var candidate = existing == null
|
var candidate = existing == null
|
||||||
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
|
? 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);
|
||||||
@@ -353,7 +489,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
|
|
||||||
if (isSeparator)
|
if (isSeparator)
|
||||||
{
|
{
|
||||||
if (!usable || !IsFullSpanProgram(program, bounds))
|
if (!usable || !IsFullSpanProgram(program, true, spanLow, spanHigh))
|
||||||
{
|
{
|
||||||
WarnNoSeparator(x);
|
WarnNoSeparator(x);
|
||||||
return;
|
return;
|
||||||
@@ -361,7 +497,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
separated = true;
|
separated = true;
|
||||||
separatorX = candidate.Position.X;
|
separatorX = candidate.Position.X;
|
||||||
}
|
}
|
||||||
else if (usable && !IsFullSpanProgram(program, bounds))
|
else if (usable && !IsFullSpanProgram(program, true, spanLow, spanHigh))
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
AutomaticCutOffDiagnosticCode.SegmentedCut,
|
AutomaticCutOffDiagnosticCode.SegmentedCut,
|
||||||
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
|
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
|
||||||
@@ -386,7 +522,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,
|
||||||
@@ -408,18 +544,22 @@ 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,
|
||||||
|
"A head-locked overcut must be finite and positive.", nameof(settings));
|
||||||
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
|
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
|
||||||
Require(plate.Parts != null && plate.CutOffs != null,
|
Require(plate.Parts != null && plate.CutOffs != null,
|
||||||
"Plate parts and cut-off collections are required.", nameof(plate));
|
"Plate parts and cut-off collections are required.", nameof(plate));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double ValidatePart(Part part, Box sheet, int sign)
|
private static (double X, double Y) ValidatePart(Part part, Box sheet, int sign, int ySign)
|
||||||
{
|
{
|
||||||
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
|
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
|
||||||
"Part pose must be finite.", "plate");
|
"Part pose must be finite.", "plate");
|
||||||
@@ -432,19 +572,32 @@ public static class AutomaticCutOffPlanner
|
|||||||
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
|
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
|
||||||
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
|
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
|
||||||
"Part geometry has stale bounds; update it before planning.", "plate");
|
"Part geometry has stale bounds; update it before planning.", "plate");
|
||||||
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
var hasDisplayPerimeter = DisplayPerimeterTail.HasDisplayPerimeterMotion(part.Program);
|
||||||
|
Require(Inside(box, sheet, hasDisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
|
||||||
|
"Part geometry extends outside the physical sheet.", "plate");
|
||||||
|
|
||||||
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
|
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
|
||||||
// Checking converted entities catches overflowing incremental moves and curve bounds.
|
// Checking converted entities catches overflowing incremental moves and curve bounds.
|
||||||
var hasMaterial = false;
|
var hasMaterial = false;
|
||||||
var occupied = sign > 0 ? box.Right : -box.Left;
|
var occupied = sign > 0 ? box.Right : -box.Left;
|
||||||
|
var occupiedY = ySign > 0 ? box.Top : -box.Bottom;
|
||||||
var roundoff = CutOff.GetBoundsRoundoff(part);
|
var roundoff = CutOff.GetBoundsRoundoff(part);
|
||||||
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
|
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||||
|
var outline = entities.Where(e => e.Layer == SpecialLayers.DisplayPerimeter).ToList();
|
||||||
|
foreach (var entity in entities)
|
||||||
{
|
{
|
||||||
var entityBox = entity.BoundingBox;
|
var entityBox = entity.BoundingBox;
|
||||||
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
|
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
|
||||||
if (!SpecialLayers.IsMaterial(entity.Layer))
|
if (!SpecialLayers.IsMaterial(entity.Layer))
|
||||||
|
{
|
||||||
|
// Travel and marks (a scribe is lasered) keep the ordinary tolerance. Only a rapid
|
||||||
|
// ending on the outline (its first corner, or an applied program's display tail)
|
||||||
|
// shares the outline's allowance.
|
||||||
|
Require(Inside(entityBox.Translate(part.Location), sheet)
|
||||||
|
|| hasDisplayPerimeter && IsRapidOntoOutline(entity, outline, part.Location, sheet),
|
||||||
|
"Part geometry extends outside the physical sheet.", "plate");
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
entityBox = entityBox.Translate(part.Location);
|
entityBox = entityBox.Translate(part.Location);
|
||||||
// Permit only bounded floating-point roundoff, with the same conservative
|
// Permit only bounded floating-point roundoff, with the same conservative
|
||||||
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
|
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
|
||||||
@@ -452,12 +605,15 @@ public static class AutomaticCutOffPlanner
|
|||||||
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
|
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
|
||||||
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
|
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
|
||||||
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
|
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
|
||||||
Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
Require(Inside(entityBox, sheet,
|
||||||
|
entity.Layer == SpecialLayers.DisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
|
||||||
|
"Part geometry extends outside the physical sheet.", "plate");
|
||||||
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
|
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
|
||||||
|
occupiedY = System.Math.Max(occupiedY, ySign > 0 ? entityBox.Top : -entityBox.Bottom);
|
||||||
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
|
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
|
||||||
}
|
}
|
||||||
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
|
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
|
||||||
return occupied;
|
return (occupied, occupiedY);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateProgram(Program program, HashSet<Program> path)
|
private static void ValidateProgram(Program program, HashSet<Program> path)
|
||||||
@@ -498,11 +654,13 @@ 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);
|
||||||
|
|
||||||
private static bool HasUsableSegments(Program program)
|
private static bool HasUsableSegments(Program program, bool vertical = true)
|
||||||
{
|
{
|
||||||
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
|
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
|
||||||
return false;
|
return false;
|
||||||
@@ -510,30 +668,50 @@ public static class AutomaticCutOffPlanner
|
|||||||
{
|
{
|
||||||
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
|
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
|
||||||
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
|
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
|
||||||
!Near(from.EndPoint.X, to.EndPoint.X) ||
|
(vertical ? !Near(from.EndPoint.X, to.EndPoint.X) : !Near(from.EndPoint.Y, to.EndPoint.Y)) ||
|
||||||
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance)
|
(vertical ? System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y)
|
||||||
|
: System.Math.Abs(from.EndPoint.X - to.EndPoint.X)) <= GeometryTolerance)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsFullSpanProgram(Program program, Box bounds)
|
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||||
|
cut.Gaps.Count == 0 &&
|
||||||
|
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
|
||||||
|
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
|
||||||
|
|
||||||
|
private static bool IsFullSpanProgram(Program program, bool vertical, double low, double high)
|
||||||
{
|
{
|
||||||
// No gaps, bridges, or filtered middle segments can separate the tail.
|
// No gaps, bridges, or filtered middle segments can separate the tail.
|
||||||
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
|
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
|
||||||
program.Codes[1] is not LinearMove to)
|
program.Codes[1] is not LinearMove to)
|
||||||
return false;
|
return false;
|
||||||
return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance &&
|
var start = vertical ? from.EndPoint.Y : from.EndPoint.X;
|
||||||
System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance;
|
var end = vertical ? to.EndPoint.Y : to.EndPoint.X;
|
||||||
|
return System.Math.Min(start, end) <= low + GeometryTolerance &&
|
||||||
|
System.Math.Max(start, end) >= high - GeometryTolerance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
|
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
|
||||||
Nonnegative(box.Length) && Nonnegative(box.Width) &&
|
Nonnegative(box.Length) && Nonnegative(box.Width) &&
|
||||||
double.IsFinite(box.Right) && double.IsFinite(box.Top);
|
double.IsFinite(box.Right) && double.IsFinite(box.Top);
|
||||||
|
|
||||||
private static bool Inside(Box box, Box sheet) =>
|
// A rapid that leaves the sheet only at an end lying on the display perimeter, within its allowance.
|
||||||
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
|
private static bool IsRapidOntoOutline(Entity entity, List<Entity> outline, Vector location, Box sheet)
|
||||||
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
|
{
|
||||||
|
if (entity.Layer != SpecialLayers.Rapid || entity is not Line rapid
|
||||||
|
|| !Inside(rapid.BoundingBox.Translate(location), sheet, DisplayPerimeterSheetAllowance))
|
||||||
|
return false;
|
||||||
|
bool Allowed(Vector point) =>
|
||||||
|
Inside(new Box(point.X + location.X, point.Y + location.Y, 0, 0), sheet)
|
||||||
|
|| outline.Any(e => e.ClosestPointTo(point).DistanceTo(point) <= GeometryTolerance);
|
||||||
|
return Allowed(rapid.StartPoint) && Allowed(rapid.EndPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Inside(Box box, Box sheet, double tolerance = GeometryTolerance) =>
|
||||||
|
box.Left >= sheet.Left - tolerance && box.Right <= sheet.Right + tolerance &&
|
||||||
|
box.Bottom >= sheet.Bottom - tolerance && box.Top <= sheet.Top + tolerance;
|
||||||
|
|
||||||
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||||
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
|
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
|
||||||
|
|||||||
@@ -21,12 +21,17 @@ namespace OpenNest.BestFit
|
|||||||
IReadOnlyList<double> HullAngles
|
IReadOnlyList<double> HullAngles
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>The saved best-fit results for one drawing at one plate size and spacing.</summary>
|
/// <summary>
|
||||||
|
/// The saved best-fit results for one drawing at one plate size and spacing.
|
||||||
|
/// <paramref name="CanonicalAngle"/> is the drawing's canonical angle (radians) when the
|
||||||
|
/// set was saved: the frame its pair geometry is in. Null means the frame is unknown.
|
||||||
|
/// </summary>
|
||||||
public sealed record SavedPairSet(
|
public sealed record SavedPairSet(
|
||||||
double PlateWidth,
|
double PlateWidth,
|
||||||
double PlateHeight,
|
double PlateHeight,
|
||||||
double Spacing,
|
double Spacing,
|
||||||
IReadOnlyList<SavedPairResult> Results
|
IReadOnlyList<SavedPairResult> Results,
|
||||||
|
double? CanonicalAngle = null
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,7 +50,10 @@ namespace OpenNest.BestFit
|
|||||||
IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys
|
IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Restores saved sets for <paramref name="drawing"/>.</summary>
|
/// <summary>
|
||||||
|
/// Restores saved sets for <paramref name="drawing"/>. Sets saved in a frame other
|
||||||
|
/// than the drawing's current canonical frame, or in an unknown one, are stale.
|
||||||
|
/// </summary>
|
||||||
void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets);
|
void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ namespace OpenNest.CNC
|
|||||||
builder.PartIndex = i;
|
builder.PartIndex = i;
|
||||||
var mode = Mode.Absolute;
|
var mode = Mode.Absolute;
|
||||||
var pos = part.Location;
|
var pos = part.Location;
|
||||||
builder.AddProgram(part.Program, part.Location, ref mode, ref pos);
|
builder.AddProgram(DisplayPerimeterTail.WithoutTail(part.Program), part.Location, ref mode, ref pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
|
var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
|
||||||
|
|||||||
@@ -36,14 +36,45 @@ internal enum AutomaticEntryKind
|
|||||||
|
|
||||||
/// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary>
|
/// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary>
|
||||||
Pinned = 7,
|
Pinned = 7,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The perimeter point where a tab best keeps the part level on its support bed; only a
|
||||||
|
/// perimeter tabbed for support has it (see <see cref="PreparedContours.WithSupportTab"/>).
|
||||||
|
/// </summary>
|
||||||
|
SupportTab = 8,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A cutout entry pierced where eroding the cutout leaves room (see
|
||||||
|
/// <see cref="PreparedContours.ErodedPierceEntries"/>): only offered once no configured
|
||||||
|
/// lead-in of the cutout fits.
|
||||||
|
/// </summary>
|
||||||
|
ErodedPierce = 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which automatic entries a perimeter tabbed for support offers (see
|
||||||
|
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalog.
|
||||||
|
/// </summary>
|
||||||
|
internal enum SupportEntries
|
||||||
|
{
|
||||||
|
/// <summary>Entries whose tab steadies the part, clear of inside corners, plus its best tab point.</summary>
|
||||||
|
Steadying,
|
||||||
|
|
||||||
|
/// <summary>Every entry clear of inside corners: the fallback when no steadying entry fits.</summary>
|
||||||
|
ClearOfInsideCorners,
|
||||||
|
|
||||||
|
/// <summary>Every entry: the last fallback, when nothing clear of inside corners fits.</summary>
|
||||||
|
Any,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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);
|
||||||
|
|||||||
@@ -55,23 +55,24 @@ internal static class ContourProgramVerifier
|
|||||||
var first = run.Curves[0];
|
var first = run.Curves[0];
|
||||||
var ringIndex = -1;
|
var ringIndex = -1;
|
||||||
var entityIndex = -1;
|
var entityIndex = -1;
|
||||||
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
|
// The admitted slack is far below any extent margin, so only boundaries near the run's
|
||||||
|
// start can contain it; they come in ring order, as the full scan met them.
|
||||||
|
foreach (var (r, e) in material.PositionsNear(new(first.Start.X, first.Start.Y, first.Start.X, first.Start.Y)))
|
||||||
{
|
{
|
||||||
|
Query(token, ref budget);
|
||||||
var admitted = AdmittedClosureSlack(material.Rings[r]);
|
var admitted = AdmittedClosureSlack(material.Rings[r]);
|
||||||
for (var e = 0; e < material.Rings[r].Length; e++)
|
var curve = material.Rings[r][e];
|
||||||
|
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
|
||||||
|
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
||||||
{
|
{
|
||||||
Query(token, ref budget);
|
ringIndex = r;
|
||||||
var curve = material.Rings[r][e];
|
entityIndex = e;
|
||||||
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
|
break;
|
||||||
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
|
||||||
{
|
|
||||||
ringIndex = r;
|
|
||||||
entityIndex = e;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ringIndex < 0) return false;
|
if (ringIndex < 0) return false;
|
||||||
|
// A display perimeter is never cut: any run along it is not this plan.
|
||||||
|
if (ringIndex == 0 && material.HasDisplayPerimeter) return false;
|
||||||
var tabs = material.Tabs[ringIndex];
|
var tabs = material.Tabs[ringIndex];
|
||||||
if (tabs.Any(tab => tab))
|
if (tabs.Any(tab => tab))
|
||||||
{
|
{
|
||||||
@@ -108,7 +109,7 @@ 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;
|
||||||
}
|
}
|
||||||
return visited.Count == material.Rings.Count;
|
return visited.Count == material.Rings.Count - (material.HasDisplayPerimeter ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
|
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
|
||||||
|
|||||||
@@ -22,20 +22,39 @@ internal sealed class PlannedPartProgram
|
|||||||
public CuttingParameters Parameters { get; }
|
public CuttingParameters Parameters { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An owned cutoff segment program cut the other way at the far sheet edge
|
||||||
|
/// (<see cref="CutOff.ReverseAtFarEdge"/>); Apply installs it and records the direction on the
|
||||||
|
/// segment's cutoff definition, so regeneration keeps it.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PlannedCutOffProgram
|
||||||
|
{
|
||||||
|
public PlannedCutOffProgram(Part segment, Program program)
|
||||||
|
{
|
||||||
|
Segment = segment;
|
||||||
|
Program = program;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Part Segment { get; }
|
||||||
|
public Program Program { get; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
|
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
|
||||||
internal sealed class PlateCuttingPlan
|
internal sealed class PlateCuttingPlan
|
||||||
{
|
{
|
||||||
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
|
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
|
||||||
IEnumerable<PlannedPartProgram> programs = null)
|
IEnumerable<PlannedPartProgram> programs = null, IEnumerable<PlannedCutOffProgram> cutOffs = null)
|
||||||
{
|
{
|
||||||
Expected = expected;
|
Expected = expected;
|
||||||
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
|
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
|
||||||
Programs = Array.AsReadOnly((programs ?? []).ToArray());
|
Programs = Array.AsReadOnly((programs ?? []).ToArray());
|
||||||
|
CutOffs = Array.AsReadOnly((cutOffs ?? []).ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlateCuttingState Expected { get; }
|
public PlateCuttingState Expected { get; }
|
||||||
public IReadOnlyList<Part> Order { get; }
|
public IReadOnlyList<Part> Order { get; }
|
||||||
public IReadOnlyList<PlannedPartProgram> Programs { get; }
|
public IReadOnlyList<PlannedPartProgram> Programs { get; }
|
||||||
|
public IReadOnlyList<PlannedCutOffProgram> CutOffs { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CuttingCommitStatus
|
public enum CuttingCommitStatus
|
||||||
@@ -43,7 +62,7 @@ public enum CuttingCommitStatus
|
|||||||
Applied,
|
Applied,
|
||||||
Stale,
|
Stale,
|
||||||
InvalidInput,
|
InvalidInput,
|
||||||
Cancelled,
|
Canceled,
|
||||||
Failed
|
Failed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,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.");
|
||||||
@@ -107,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);
|
||||||
@@ -161,27 +180,49 @@ internal static class CuttingPlanCommit
|
|||||||
}
|
}
|
||||||
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
|
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
|
||||||
}
|
}
|
||||||
|
var cutOffs = new List<(Part, Program, CutOff)>();
|
||||||
|
foreach (var planned in plan.CutOffs)
|
||||||
|
{
|
||||||
|
if (planned?.Segment == null || !onPlate.Contains(planned.Segment) || !targets.Add(planned.Segment))
|
||||||
|
return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
|
||||||
|
var definition = plate.CutOffs.FirstOrDefault(c => ReferenceEquals(c?.Drawing, planned.Segment.BaseDrawing));
|
||||||
|
// Only the placed far-edge segment, cut exactly the other way, may be installed.
|
||||||
|
if (definition == null || planned.Program == null || live.Contains(planned.Program)
|
||||||
|
|| !installed.Add(planned.Program)
|
||||||
|
|| CutOff.ReverseAtFarEdge(planned.Segment.Program) is not { } expected
|
||||||
|
|| !ProgramContent.Equal(expected, planned.Program, token))
|
||||||
|
return Invalid(plate, "A cutoff segment can only be installed cut the other way at the far edge.");
|
||||||
|
cutOffs.Add((planned.Segment, planned.Program, definition));
|
||||||
|
}
|
||||||
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
|
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
|
||||||
staged.Add(new(plate, order, programs, reordered || programs.Count != 0));
|
staged.Add(new(plate, order, programs, cutOffs, reordered || programs.Count != 0 || cutOffs.Count != 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last cancellation point. The boundary below has no await, search or geometry work.
|
// 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)>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var item in staged)
|
foreach (var item in staged)
|
||||||
{
|
{
|
||||||
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
|
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
|
||||||
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray()));
|
item.Programs.Select(p => p.Part).Concat(item.CutOffs.Select(c => c.Segment))
|
||||||
|
.Select(part => (part, part.CaptureCuttingState())).ToArray(),
|
||||||
|
item.CutOffs.Select(c => (c.CutOff, c.CutOff.FarEdgeFirst, c.CutOff.Drawing.Program)).ToArray()));
|
||||||
item.Plate.Parts.SetOrder(item.Order);
|
item.Plate.Parts.SetOrder(item.Order);
|
||||||
foreach (var (part, program, bounds, parameters) in item.Programs)
|
foreach (var (part, program, bounds, parameters) in item.Programs)
|
||||||
{
|
{
|
||||||
beforeInstall?.Invoke(item.Plate, part);
|
beforeInstall?.Invoke(item.Plate, part);
|
||||||
part.InstallPlannedProgram(program, bounds, parameters);
|
part.InstallPlannedProgram(program, bounds, parameters);
|
||||||
}
|
}
|
||||||
|
foreach (var (segment, program, cutOff) in item.CutOffs)
|
||||||
|
{
|
||||||
|
beforeInstall?.Invoke(item.Plate, segment);
|
||||||
|
cutOff.InstallFarEdgeSegment(segment, program);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -190,6 +231,11 @@ internal static class CuttingPlanCommit
|
|||||||
{
|
{
|
||||||
foreach (var (part, state) in undo[i].States)
|
foreach (var (part, state) in undo[i].States)
|
||||||
part.RestoreCuttingState(state);
|
part.RestoreCuttingState(state);
|
||||||
|
foreach (var (cutOff, farEdgeFirst, program) in undo[i].CutOffs)
|
||||||
|
{
|
||||||
|
cutOff.FarEdgeFirst = farEdgeFirst;
|
||||||
|
cutOff.Drawing.Program = program;
|
||||||
|
}
|
||||||
undo[i].Plate.Parts.SetOrder(undo[i].Order);
|
undo[i].Plate.Parts.SetOrder(undo[i].Order);
|
||||||
}
|
}
|
||||||
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
|
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
|
||||||
@@ -208,5 +254,6 @@ internal static class CuttingPlanCommit
|
|||||||
new(CuttingCommitStatus.InvalidInput, message, plate);
|
new(CuttingCommitStatus.InvalidInput, message, plate);
|
||||||
|
|
||||||
private sealed record Staged(Plate Plate, Part[] Order,
|
private sealed record Staged(Plate Plate, Part[] Order,
|
||||||
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs, bool Changed);
|
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs,
|
||||||
|
List<(Part Segment, Program Program, CutOff CutOff)> CutOffs, bool Changed);
|
||||||
}
|
}
|
||||||
@@ -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,13 +43,24 @@ 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.");
|
||||||
if (code == null)
|
if (code == null)
|
||||||
throw new ArgumentException("Program contains a missing instruction.");
|
throw new ArgumentException("Program contains a missing instruction.");
|
||||||
|
// A display perimeter's tail is never executed: an applied program ends at its marker.
|
||||||
|
// Only a well-formed tail at the end of the part's own program may end the read.
|
||||||
|
if (code is Comment { Value: DisplayPerimeterTail.Marker })
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(current, program))
|
||||||
|
throw new ArgumentException("A display perimeter tail can only end the part's own program, not a sub-program.");
|
||||||
|
DisplayPerimeterTail.IndexOf(current);
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (requireSupportedTypes)
|
if (requireSupportedTypes)
|
||||||
{
|
{
|
||||||
var type = code.GetType();
|
var type = code.GetType();
|
||||||
@@ -61,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;
|
||||||
}
|
}
|
||||||
@@ -82,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
|
||||||
{
|
{
|
||||||
@@ -97,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ internal static class IncrementalClosureDrift
|
|||||||
|
|
||||||
// The chains material capture closes: cut, display and tab moves between rapids and other layers.
|
// The chains material capture closes: cut, display and tab moves between rapids and other layers.
|
||||||
private static bool IsContour(Motion motion) => !motion.Suppressed
|
private static bool IsContour(Motion motion) => !motion.Suppressed
|
||||||
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab }
|
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter }
|
||||||
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab };
|
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter };
|
||||||
|
|
||||||
private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
|
private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
|
||||||
{
|
{
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ namespace OpenNest.CNC.CuttingPlanning;
|
|||||||
public sealed class LeadMaterialSnapshot
|
public sealed class LeadMaterialSnapshot
|
||||||
{
|
{
|
||||||
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
|
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
|
||||||
string reason)
|
string reason, bool displayPerimeter = false)
|
||||||
{
|
{
|
||||||
|
HasDisplayPerimeter = displayPerimeter;
|
||||||
Rings = rings;
|
Rings = rings;
|
||||||
Tabs = tabs;
|
Tabs = tabs;
|
||||||
Reason = reason;
|
Reason = reason;
|
||||||
@@ -41,6 +42,12 @@ public sealed class LeadMaterialSnapshot
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal IReadOnlyList<bool[]> Tabs { get; }
|
internal IReadOnlyList<bool[]> Tabs { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the perimeter (<see cref="Rings"/>[0]) is a display perimeter: a supplied blank's
|
||||||
|
/// edge that bounds material but is never cut. Every other ring is a hole the program cuts.
|
||||||
|
/// </summary>
|
||||||
|
internal bool HasDisplayPerimeter { get; }
|
||||||
|
|
||||||
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
|
/// <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,
|
||||||
@@ -53,6 +60,8 @@ public sealed class LeadMaterialSnapshot
|
|||||||
var ringTabs = new List<bool[]>();
|
var ringTabs = new List<bool[]>();
|
||||||
var chain = new List<PostVerificationGeometry.Curve>();
|
var chain = new List<PostVerificationGeometry.Curve>();
|
||||||
var chainTabs = new List<bool>();
|
var chainTabs = new List<bool>();
|
||||||
|
var ringDisplay = new List<bool>();
|
||||||
|
var chainDisplay = new List<bool>();
|
||||||
foreach (var motion in execution.Motions)
|
foreach (var motion in execution.Motions)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -63,7 +72,8 @@ public sealed class LeadMaterialSnapshot
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// A tabbed cutout's tabs are never cut but still close its hole.
|
// A tabbed cutout's tabs are never cut but still close its hole.
|
||||||
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab))
|
// A display perimeter is never cut but still bounds the blank's material.
|
||||||
|
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter))
|
||||||
throw new ArgumentException("Material capture contains an unsupported layer.");
|
throw new ArgumentException("Material capture contains an unsupported layer.");
|
||||||
var curve = motion.Curve;
|
var curve = motion.Curve;
|
||||||
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
|
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
|
||||||
@@ -72,6 +82,7 @@ public sealed class LeadMaterialSnapshot
|
|||||||
throw new ArgumentException("Material contour is discontinuous.");
|
throw new ArgumentException("Material contour is discontinuous.");
|
||||||
chain.Add(curve);
|
chain.Add(curve);
|
||||||
chainTabs.Add(motion.Layer == LayerType.Tab);
|
chainTabs.Add(motion.Layer == LayerType.Tab);
|
||||||
|
chainDisplay.Add(motion.Layer == LayerType.DisplayPerimeter);
|
||||||
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||||
Finish();
|
Finish();
|
||||||
}
|
}
|
||||||
@@ -80,42 +91,46 @@ public sealed class LeadMaterialSnapshot
|
|||||||
throw new ArgumentException("Material has no closed contour.");
|
throw new ArgumentException("Material has no closed contour.");
|
||||||
var budget = 1000000;
|
var budget = 1000000;
|
||||||
// Certify simple rings and mutually disjoint boundaries before containment.
|
// Certify simple rings and mutually disjoint boundaries before containment.
|
||||||
for (var r = 0; r < rings.Count; r++)
|
foreach (var (r, i, s, j) in ContactCandidates(rings, token))
|
||||||
for (var s = r; s < rings.Count; s++)
|
{
|
||||||
for (var i = 0; i < rings[r].Length; i++)
|
token.ThrowIfCancellationRequested();
|
||||||
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++)
|
if (--budget < 0)
|
||||||
{
|
throw new NotSupportedException("Material validation exceeds the native query limit.");
|
||||||
token.ThrowIfCancellationRequested();
|
var a = rings[r][i];
|
||||||
if (--budget < 0)
|
var b = rings[s][j];
|
||||||
throw new NotSupportedException("Material validation exceeds the native query limit.");
|
var contacts = a.Contacts(b, out var overlap);
|
||||||
var a = rings[r][i];
|
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
||||||
var b = rings[s][j];
|
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
|
||||||
var contacts = a.Contacts(b, out var overlap);
|
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|
||||||
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
|
||||||
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
|
// Native arc queries pad angles by 1e-5 radians, admitting
|
||||||
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|
// intersections on an extension (distance grows with radius).
|
||||||
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
|
// At a shared arc joint require both actual finite spans;
|
||||||
// Native arc queries pad angles by 1e-5 radians, admitting
|
// overlap and genuine second intersections still refuse.
|
||||||
// intersections on an extension (distance grows with radius).
|
if (arcJoint)
|
||||||
// At a shared arc joint require both actual finite spans;
|
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
|
||||||
// overlap and genuine second intersections still refuse.
|
// Native contacts at rounded arc/line joints can drift from the
|
||||||
if (arcJoint)
|
// authored endpoint. Use the shared geometry tolerance only to
|
||||||
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
|
// recognize that joint; overlaps and other contacts still refuse.
|
||||||
// Native contacts at rounded arc/line joints can drift from the
|
if (overlap || contacts.Any(p => !adjacent
|
||||||
// authored endpoint. Use the shared geometry tolerance only to
|
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
|
||||||
// recognize that joint; overlaps and other contacts still refuse.
|
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
|
||||||
if (overlap || contacts.Any(p => !adjacent
|
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
|
||||||
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
|
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
|
||||||
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
|
throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s])
|
||||||
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
|
? "A display perimeter must enclose every other contour."
|
||||||
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
|
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
||||||
throw new ArgumentException($"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.");
|
||||||
@@ -123,16 +138,23 @@ public sealed class LeadMaterialSnapshot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (outer < 0)
|
if (outer < 0)
|
||||||
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
|
throw new ArgumentException(ringDisplay.Contains(true)
|
||||||
|
? "A display perimeter must enclose every other contour."
|
||||||
|
: "Material must have exactly one enclosing perimeter.");
|
||||||
if (ringTabs[outer].Any(tab => tab))
|
if (ringTabs[outer].Any(tab => tab))
|
||||||
throw new ArgumentException("A perimeter cannot have tabs.");
|
throw new ArgumentException("A perimeter cannot have tabs.");
|
||||||
var holes = rings.Where((_, i) => i != outer).ToArray();
|
if (ringDisplay.Where((_, i) => i != outer).Any(display => display))
|
||||||
for (var i = 0; i < holes.Length; i++)
|
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||||
for (var j = i + 1; j < holes.Length; j++)
|
if (ringDisplay[outer] && rings.Count == 1)
|
||||||
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
|
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
||||||
|
var holeRings = Enumerable.Range(0, rings.Count).Where(i => i != outer).ToArray();
|
||||||
|
var holes = holeRings.Select(i => rings[i]).ToArray();
|
||||||
|
for (var i = 0; i < holeRings.Length; i++)
|
||||||
|
for (var j = i + 1; j < holeRings.Length; j++)
|
||||||
|
if (Encloses(holeRings[j], holes[i][0].Start) || Encloses(holeRings[i], holes[j][0].Start))
|
||||||
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);
|
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
|
||||||
|
|
||||||
void Finish()
|
void Finish()
|
||||||
{
|
{
|
||||||
@@ -145,10 +167,14 @@ public sealed class LeadMaterialSnapshot
|
|||||||
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
|
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
|
||||||
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
|
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
|
||||||
}
|
}
|
||||||
|
if (chainDisplay.Contains(true) && chainDisplay.Contains(false))
|
||||||
|
throw new ArgumentException("A display perimeter must be one whole closed contour.");
|
||||||
rings.Add(chain.ToArray());
|
rings.Add(chain.ToArray());
|
||||||
ringTabs.Add(chainTabs.ToArray());
|
ringTabs.Add(chainTabs.ToArray());
|
||||||
|
ringDisplay.Add(chainDisplay.Count > 0 && chainDisplay[0]);
|
||||||
chain.Clear();
|
chain.Clear();
|
||||||
chainTabs.Clear();
|
chainTabs.Clear();
|
||||||
|
chainDisplay.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||||
@@ -157,18 +183,182 @@ public sealed class LeadMaterialSnapshot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token)
|
/// <summary>
|
||||||
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token));
|
/// The boundary pairs certification queries natively, in the order of the full pairwise scan
|
||||||
|
/// (ring, other ring, curve, other curve). A pair whose extents are clear by the lead checks'
|
||||||
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token)
|
/// margin (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>) can report no
|
||||||
|
/// native contact, so it is left out; a sweep over X finds the rest without visiting every pair.
|
||||||
|
/// Material that is not well conditioned keeps every pair.
|
||||||
|
/// </summary>
|
||||||
|
private static List<(int R, int I, int S, int J)> ContactCandidates(
|
||||||
|
IReadOnlyList<PostVerificationGeometry.Curve[]> rings, CancellationToken token)
|
||||||
{
|
{
|
||||||
var inside = false;
|
var curves = new List<(int Ring, int Index, PostVerificationGeometry.Extent Extent)>();
|
||||||
foreach (var curve in ring)
|
var whole = PostVerificationGeometry.Extent.None;
|
||||||
|
for (var r = 0; r < rings.Count; r++)
|
||||||
|
for (var i = 0; i < rings[r].Length; i++)
|
||||||
|
{
|
||||||
|
curves.Add((r, i, rings[r][i].Extent));
|
||||||
|
whole = whole.Union(rings[r][i].Extent);
|
||||||
|
}
|
||||||
|
var pairs = new List<(int R, int I, int S, int J)>();
|
||||||
|
if (!whole.IsWellConditioned)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
for (var a = 0; a < curves.Count; a++)
|
||||||
if (curve.CrossesRay(point))
|
for (var b = a + 1; b < curves.Count; b++)
|
||||||
inside = !inside;
|
pairs.Add((curves[a].Ring, curves[a].Index, curves[b].Ring, curves[b].Index));
|
||||||
}
|
}
|
||||||
return inside;
|
else
|
||||||
|
{
|
||||||
|
// No pair spans more than the whole material, so this bounds every pair's margin.
|
||||||
|
var reach = PostVerificationGeometry.Extent.ClearMargin + PostVerificationGeometry.Extent.SpanRounding
|
||||||
|
* System.Math.Max(whole.MaxX - whole.MinX, whole.MaxY - whole.MinY);
|
||||||
|
var byMinX = curves.OrderBy(c => c.Extent.MinX).ToArray();
|
||||||
|
for (var a = 0; a < byMinX.Length; a++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var first = byMinX[a];
|
||||||
|
for (var b = a + 1; b < byMinX.Length && byMinX[b].Extent.MinX <= first.Extent.MaxX + reach; b++)
|
||||||
|
{
|
||||||
|
var second = byMinX[b];
|
||||||
|
if (first.Extent.IsClearOfQueriesFrom(second.Extent))
|
||||||
|
continue;
|
||||||
|
pairs.Add((first.Ring, first.Index).CompareTo((second.Ring, second.Index)) < 0
|
||||||
|
? (first.Ring, first.Index, second.Ring, second.Index)
|
||||||
|
: (second.Ring, second.Index, first.Ring, first.Index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pairs.Sort((x, y) => (x.R, x.S, x.I, x.J).CompareTo((y.R, y.S, y.I, y.J)));
|
||||||
|
return pairs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Material with fewer boundaries is scanned whole, exactly as before the index.</summary>
|
||||||
|
private const int IndexedBoundaries = 64;
|
||||||
|
|
||||||
|
private BoundaryIndex index;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The boundaries a native query from <paramref name="query"/> can report a contact with, in
|
||||||
|
/// ring order: every boundary but those whose extents are clear of it by the lead checks'
|
||||||
|
/// margin plus <paramref name="reach"/> (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>).
|
||||||
|
/// Small material returns every boundary.
|
||||||
|
/// </summary>
|
||||||
|
internal IEnumerable<PostVerificationGeometry.Curve> BoundariesNear(PostVerificationGeometry.Extent query,
|
||||||
|
double reach = 0) => PositionsNear(query, reach).Select(p => Rings[p.Ring][p.Index]);
|
||||||
|
|
||||||
|
/// <summary>As <see cref="BoundariesNear"/>, each boundary as its ring and its index in that ring.</summary>
|
||||||
|
internal IEnumerable<(int Ring, int Index)> PositionsNear(PostVerificationGeometry.Extent query, double reach = 0)
|
||||||
|
{
|
||||||
|
if (BoundaryCount < IndexedBoundaries)
|
||||||
|
return Rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i)));
|
||||||
|
return LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).Near(query, reach);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool ContainsMaterial(Vector point, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (BoundaryCount < IndexedBoundaries)
|
||||||
|
return Inside(point, Rings[0], token) && !Rings.Skip(1).Any(hole => Inside(point, hole, token));
|
||||||
|
var holes = LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).HolesOnRow(point.Y);
|
||||||
|
return Inside(point, Rings[0], token) && !holes.Any(hole => Inside(point, Rings[hole], token));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A uniform grid of boundary extents over the material, for material with many holes. Read
|
||||||
|
/// only once built, so concurrent searches may share it.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class BoundaryIndex
|
||||||
|
{
|
||||||
|
private readonly (int Ring, int Index)[] positions;
|
||||||
|
private readonly PostVerificationGeometry.Extent[] extents;
|
||||||
|
private readonly List<int>[] cells;
|
||||||
|
private readonly List<int>[] bands;
|
||||||
|
private readonly PostVerificationGeometry.Extent[] ringExtents;
|
||||||
|
private readonly int ringCount;
|
||||||
|
private readonly PostVerificationGeometry.Extent whole;
|
||||||
|
private readonly int columns;
|
||||||
|
private readonly int rows;
|
||||||
|
private readonly double width;
|
||||||
|
private readonly double height;
|
||||||
|
|
||||||
|
internal BoundaryIndex(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, PostVerificationGeometry.Extent whole)
|
||||||
|
{
|
||||||
|
positions = rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i))).ToArray();
|
||||||
|
extents = rings.SelectMany(r => r).Select(b => b.Extent).ToArray();
|
||||||
|
ringCount = rings.Count;
|
||||||
|
ringExtents = new PostVerificationGeometry.Extent[rings.Count];
|
||||||
|
this.whole = whole;
|
||||||
|
var side = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Sqrt(positions.Length)));
|
||||||
|
columns = side;
|
||||||
|
rows = side;
|
||||||
|
width = System.Math.Max(whole.MaxX - whole.MinX, double.Epsilon) / columns;
|
||||||
|
height = System.Math.Max(whole.MaxY - whole.MinY, double.Epsilon) / rows;
|
||||||
|
cells = new List<int>[columns * rows];
|
||||||
|
bands = new List<int>[rows];
|
||||||
|
if (!whole.IsWellConditioned)
|
||||||
|
return;
|
||||||
|
for (var ring = 1; ring < rings.Count; ring++)
|
||||||
|
{
|
||||||
|
var extent = rings[ring].Aggregate(PostVerificationGeometry.Extent.None, (e, curve) => e.Union(curve.Extent));
|
||||||
|
ringExtents[ring] = extent;
|
||||||
|
for (var band = Row(extent.MinY - PostVerificationGeometry.Extent.ClearMargin);
|
||||||
|
band <= Row(extent.MaxY + PostVerificationGeometry.Extent.ClearMargin); band++)
|
||||||
|
(bands[band] ??= new List<int>()).Add(ring);
|
||||||
|
}
|
||||||
|
for (var b = 0; b < positions.Length; b++)
|
||||||
|
{
|
||||||
|
var (x0, y0, x1, y1) = Cells(extents[b], 0);
|
||||||
|
for (var y = y0; y <= y1; y++)
|
||||||
|
for (var x = x0; x <= x1; x++)
|
||||||
|
(cells[y * columns + x] ??= new List<int>()).Add(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The holes, in ring order, whose curves a ray along row <paramref name="y"/> can cross:
|
||||||
|
/// every hole but those whose extents are clear of the row (see
|
||||||
|
/// <see cref="PostVerificationGeometry.Extent.IsClearOfRow"/>).
|
||||||
|
/// </summary>
|
||||||
|
internal IEnumerable<int> HolesOnRow(double y)
|
||||||
|
{
|
||||||
|
if (!whole.IsWellConditioned || !(System.Math.Abs(y) <= PostVerificationGeometry.Extent.WellConditionedLimit))
|
||||||
|
return Enumerable.Range(1, ringCount - 1);
|
||||||
|
return (bands[Row(y)] ?? []).Where(ring => !ringExtents[ring].IsClearOfRow(y));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IEnumerable<(int Ring, int Index)> Near(PostVerificationGeometry.Extent query, double reach)
|
||||||
|
{
|
||||||
|
// The margin covers every pair's span, and each skip is then decided for the pair alone.
|
||||||
|
// Material or a query that is not well conditioned (whose cells were never built, for
|
||||||
|
// the material) keeps every boundary.
|
||||||
|
var union = whole.Union(query);
|
||||||
|
var margin = PostVerificationGeometry.Extent.ClearMargin + reach
|
||||||
|
+ PostVerificationGeometry.Extent.SpanRounding * System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY);
|
||||||
|
if (!union.IsWellConditioned || !double.IsFinite(margin))
|
||||||
|
return positions;
|
||||||
|
var (x0, y0, x1, y1) = Cells(query, margin);
|
||||||
|
var found = new List<int>();
|
||||||
|
for (var y = y0; y <= y1; y++)
|
||||||
|
for (var x = x0; x <= x1; x++)
|
||||||
|
if (cells[y * columns + x] is { } cell)
|
||||||
|
found.AddRange(cell);
|
||||||
|
found.Sort();
|
||||||
|
var near = new List<(int Ring, int Index)>();
|
||||||
|
for (var i = 0; i < found.Count; i++)
|
||||||
|
if ((i == 0 || found[i] != found[i - 1]) && !query.IsClearOfQueriesFrom(extents[found[i]], reach))
|
||||||
|
near.Add(positions[found[i]]);
|
||||||
|
return near;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cells an extent widened by margin covers, clamped to the grid.
|
||||||
|
private (int X0, int Y0, int X1, int Y1) Cells(PostVerificationGeometry.Extent extent, double margin) => (
|
||||||
|
Column(extent.MinX - margin), Row(extent.MinY - margin), Column(extent.MaxX + margin), Row(extent.MaxY + margin));
|
||||||
|
|
||||||
|
private int Column(double x) => (int)System.Math.Clamp(System.Math.Floor((x - whole.MinX) / width), 0, columns - 1);
|
||||||
|
|
||||||
|
private int Row(double y) => (int)System.Math.Clamp(System.Math.Floor((y - whole.MinY) / height), 0, rows - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token) =>
|
||||||
|
PostVerificationGeometry.Inside(point, ring, token);
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,8 @@ public static class LeadPathValidator
|
|||||||
var edge = execution.Motions[groupEdge];
|
var edge = execution.Motions[groupEdge];
|
||||||
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
|
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
|
||||||
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
|
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
|
||||||
foreach (var boundary in target.Rings.SelectMany(r => r))
|
// A boundary clear of the contour's first motion contains none of its points.
|
||||||
|
foreach (var boundary in target.BoundariesNear(adjacent.Curve.Extent))
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (--budget < 0)
|
if (--budget < 0)
|
||||||
@@ -190,10 +191,7 @@ public static class LeadPathValidator
|
|||||||
// Every boundary of material this far away is farther than the zone.
|
// Every boundary of material this far away is farther than the zone.
|
||||||
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
|
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
|
||||||
.IsClearOfQueriesFrom(material.Extent, pierceRadius))
|
.IsClearOfQueriesFrom(material.Extent, pierceRadius))
|
||||||
{
|
|
||||||
Charge(material.BoundaryCount);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
if (Clearance(material) is { } distance)
|
if (Clearance(material) is { } distance)
|
||||||
return (Short(distance, "another placed part"), other);
|
return (Short(distance, "another placed part"), other);
|
||||||
}
|
}
|
||||||
@@ -232,11 +230,11 @@ public static class LeadPathValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Material too far from the lead for any native boundary query to report a contact
|
// Material too far from the lead for any native boundary query to report a contact
|
||||||
// (see IsClearOfQueriesFrom): the result the full scan gives, charged as if it ran.
|
// (see IsClearOfQueriesFrom): the result the full scan gives. Only queries that run
|
||||||
// Ray parity is still asked natively unless the lead lies beside the material's rows.
|
// are charged, so many far parts or holes do not exhaust the budget. Ray parity is
|
||||||
|
// still asked natively unless the lead lies beside the material's rows.
|
||||||
bool FarInside(LeadMaterialSnapshot material)
|
bool FarInside(LeadMaterialSnapshot material)
|
||||||
{
|
{
|
||||||
Charge(material.BoundaryCount);
|
|
||||||
var midpoint = move.Curve.Midpoint;
|
var midpoint = move.Curve.Midpoint;
|
||||||
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
|
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
|
||||||
}
|
}
|
||||||
@@ -289,19 +287,32 @@ public static class LeadPathValidator
|
|||||||
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
|
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
|
||||||
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
|
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
|
||||||
{
|
{
|
||||||
foreach (var boundary in material.Rings.SelectMany(r => r))
|
// Boundaries no native query from the curve can reach report no contact; only the others run.
|
||||||
|
foreach (var boundary in material.BoundariesNear(curve.Extent))
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
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;
|
||||||
|
|
||||||
@@ -54,19 +53,6 @@ internal static class NestedPartContainment
|
|||||||
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary>
|
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary>
|
||||||
internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
|
internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
|
||||||
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 && 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);
|
||||||
|
|||||||
@@ -33,18 +33,18 @@ internal static class OwnedCuttingParameters
|
|||||||
PierceDiameter = Dimension(source.PierceDiameter),
|
PierceDiameter = Dimension(source.PierceDiameter),
|
||||||
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
|
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
|
||||||
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
|
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
|
||||||
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
|
|
||||||
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
|
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
|
||||||
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
|
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
|
||||||
ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
|
|
||||||
RoundLeadInAngles = source.RoundLeadInAngles,
|
RoundLeadInAngles = source.RoundLeadInAngles,
|
||||||
LeadInAngleIncrement = source.LeadInAngleIncrement,
|
LeadInAngleIncrement = source.LeadInAngleIncrement,
|
||||||
AutoTabMinSize = Dimension(source.AutoTabMinSize),
|
|
||||||
AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
|
|
||||||
TabConfig = source.TabConfig == null
|
TabConfig = source.TabConfig == null
|
||||||
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
|
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
|
||||||
: CopyTab(source.TabConfig),
|
: CopyTab(source.TabConfig),
|
||||||
TabsEnabled = source.TabsEnabled,
|
TabsEnabled = source.TabsEnabled,
|
||||||
|
SupportTabs = source.SupportTabs,
|
||||||
|
NoBedTabMinWidth = Dimension(source.NoBedTabMinWidth),
|
||||||
|
NoBedTabMaxWidth = Dimension(source.NoBedTabMaxWidth),
|
||||||
|
TabHeldMaxLength = Dimension(source.TabHeldMaxLength),
|
||||||
Sequencing = new SequenceParameters
|
Sequencing = new SequenceParameters
|
||||||
{
|
{
|
||||||
Method = sequence.Method,
|
Method = sequence.Method,
|
||||||
@@ -71,7 +71,8 @@ internal static class OwnedCuttingParameters
|
|||||||
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
|
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
|
||||||
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
|
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
|
||||||
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
|
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
|
||||||
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn { Radius = Dimension(n.Radius) },
|
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn
|
||||||
|
{ Radius = Dimension(n.Radius), SweepAngle = Sweep(n.SweepAngle) },
|
||||||
LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
|
LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
|
||||||
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
|
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
|
||||||
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
|
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
|
||||||
@@ -137,4 +138,7 @@ internal static class OwnedCuttingParameters
|
|||||||
// Approach angles are degrees, not lengths. Signed and periodic angles are valid.
|
// Approach angles are degrees, not lengths. Signed and periodic angles are valid.
|
||||||
private static double Angle(double value) => double.IsFinite(value)
|
private static double Angle(double value) => double.IsFinite(value)
|
||||||
? value : throw new ArgumentException("Angles must be finite.");
|
? value : throw new ArgumentException("Angles must be finite.");
|
||||||
|
|
||||||
|
private static double Sweep(double value) => double.IsFinite(value) && value > 0 && value <= 180
|
||||||
|
? value : throw new ArgumentException("Arc sweep must be in (0, 180] degrees.");
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,8 @@ 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)).ToArray();
|
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit, c.Gaps,
|
||||||
|
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
|
||||||
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
||||||
|
|
||||||
void Fingerprint(CuttingParameters parameters)
|
void Fingerprint(CuttingParameters parameters)
|
||||||
@@ -155,7 +156,10 @@ public sealed class PlateCuttingState
|
|||||||
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|
||||||
|| 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)
|
||||||
return $"Cutoff {i + 1} definition changed.";
|
return $"Cutoff {i + 1} definition changed.";
|
||||||
}
|
}
|
||||||
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
|
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
|
||||||
@@ -204,5 +208,6 @@ public sealed class PlateCuttingState
|
|||||||
List<ContourCutPin> Pins, string PinsFingerprint);
|
List<ContourCutPin> Pins, string PinsFingerprint);
|
||||||
|
|
||||||
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? 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.Support;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingPlanning;
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
@@ -14,9 +15,11 @@ public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector
|
|||||||
internal PreparedContours Owner { get; init; }
|
internal PreparedContours Owner { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A perimeter pierce the planner placed in open scrap for the pierce zone (see
|
/// A pierce the planner placed off the contour: a perimeter's in open scrap for the pierce
|
||||||
/// <see cref="PreparedContours.ScrapPierceEntry"/>): the lead-in runs straight from here to
|
/// zone (see <see cref="PreparedContours.ScrapPierceEntry"/>), or a cutout's where eroding it
|
||||||
/// <see cref="Point"/> instead of the configured lead-in. Null for every other choice.
|
/// leaves room (see <see cref="PreparedContours.ErodedPierceEntries"/>). The lead-in runs
|
||||||
|
/// straight from here to <see cref="Point"/> instead of the configured lead-in; a cutout so
|
||||||
|
/// pierced has no lead-out. Null for every other choice.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Vector? Pierce { get; internal init; }
|
public Vector? Pierce { get; internal init; }
|
||||||
}
|
}
|
||||||
@@ -36,15 +39,34 @@ 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;
|
||||||
private readonly CuttingParameters[] contourParameters;
|
private readonly CuttingParameters[] contourParameters;
|
||||||
private readonly int[] pinNumbers;
|
private readonly int[] pinNumbers;
|
||||||
|
// Set when the support bed cannot hold the part level: the perimeter is tabbed and its
|
||||||
|
// automatic entries keep to points where the tab steadies the part. World = local + origin.
|
||||||
|
private readonly PartSupport support;
|
||||||
|
private readonly Vector supportOrigin;
|
||||||
|
// The outside when it is a display perimeter: shown and material, never cut. It is not one of
|
||||||
|
// the contours, so every contour is a hole; a complete emission only appends it as the
|
||||||
|
// never-executed DisplayPerimeterTail. Null for an ordinary cut perimeter.
|
||||||
|
private readonly Shape displayOutline;
|
||||||
|
private bool hasCutPerimeter => displayOutline == null;
|
||||||
|
|
||||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
||||||
CuttingParameters parameters)
|
CuttingParameters parameters)
|
||||||
|
: this(shapes, scribes, breakCuts, tabbedCutouts, parameters, displayOutline: null)
|
||||||
{
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
||||||
|
CuttingParameters parameters, Shape displayOutline, PierceZoneMemo pierceZones = null)
|
||||||
|
{
|
||||||
|
this.pierceZones = pierceZones ?? new PierceZoneMemo();
|
||||||
|
this.displayOutline = displayOutline;
|
||||||
this.shapes = shapes;
|
this.shapes = shapes;
|
||||||
this.scribes = scribes;
|
this.scribes = scribes;
|
||||||
this.breakCuts = breakCuts;
|
this.breakCuts = breakCuts;
|
||||||
@@ -55,7 +77,8 @@ public sealed class PreparedContours
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.pierceZones)
|
||||||
{
|
{
|
||||||
contourParameters = new CuttingParameters[shapes.Length];
|
contourParameters = new CuttingParameters[shapes.Length];
|
||||||
var rejected = new List<CutPinResolution>();
|
var rejected = new List<CutPinResolution>();
|
||||||
@@ -78,7 +101,9 @@ public sealed class PreparedContours
|
|||||||
rejected.Add(pin);
|
rejected.Add(pin);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var choice = new ContourChoice(contour, pin.EntityOrdinal, pin.Point) { Owner = this };
|
var choice = new ContourChoice(contour, pin.EntityOrdinal,
|
||||||
|
SnapToVertex(shapes[contour], pin.EntityOrdinal, pin.Point))
|
||||||
|
{ Owner = this };
|
||||||
ValidateChoice(choice);
|
ValidateChoice(choice);
|
||||||
pinned[contour] = choice;
|
pinned[contour] = choice;
|
||||||
pinNumbers[contour] = pin.PinIndex + 1;
|
pinNumbers[contour] = pin.PinIndex + 1;
|
||||||
@@ -87,17 +112,86 @@ public sealed class PreparedContours
|
|||||||
RejectedPins = rejected.AsReadOnly();
|
RejectedPins = rejected.AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A copy that tabs the perimeter for support; every pin keeps its entry, now owned here, and
|
||||||
|
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review.
|
||||||
|
private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
|
||||||
|
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
|
||||||
|
source.displayOutline, source.pierceZones)
|
||||||
|
{
|
||||||
|
for (var contour = 0; contour < Count; contour++)
|
||||||
|
{
|
||||||
|
pinned[contour] = source.pinned[contour] is { } pin ? pin with { Owner = this } : null;
|
||||||
|
pinNumbers[contour] = source.pinNumbers[contour];
|
||||||
|
}
|
||||||
|
contourParameters = source.contourParameters == null
|
||||||
|
? new CuttingParameters[Count]
|
||||||
|
: (CuttingParameters[])source.contourParameters.Clone();
|
||||||
|
if (pinned[PerimeterOrdinal] == null)
|
||||||
|
{
|
||||||
|
var perimeter = OwnedCuttingParameters.Copy(source.ParametersFor(PerimeterOrdinal));
|
||||||
|
perimeter.TabsEnabled = true;
|
||||||
|
perimeter.TabConfig = OwnedCuttingParameters.CopyTab(tab);
|
||||||
|
contourParameters[PerimeterOrdinal] = perimeter;
|
||||||
|
}
|
||||||
|
RejectedPins = source.RejectedPins;
|
||||||
|
this.support = support;
|
||||||
|
supportOrigin = location;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy whose perimeter is tabbed with <paramref name="tab"/> where it steadies the part
|
||||||
|
/// on its support bed: <paramref name="support"/> analyzed this part's material placed at
|
||||||
|
/// <paramref name="location"/>, and the perimeter's automatic entries keep to points it
|
||||||
|
/// accepts (<see cref="PartSupport.AcceptsTabAt"/>) plus its best tab point. A pinned
|
||||||
|
/// perimeter keeps its pin's entry, leads and tab (the drawing wins); the copy only keeps
|
||||||
|
/// the analysis, so replay can report a pin that leaves the part unsteady.
|
||||||
|
/// </summary>
|
||||||
|
public PreparedContours WithSupportTab(PartSupport support, Vector location, Tab tab)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(support);
|
||||||
|
ArgumentNullException.ThrowIfNull(tab);
|
||||||
|
PostVerificationGeometry.Validate(location);
|
||||||
|
if (!support.IsComplete || support.BestTabPoint == null)
|
||||||
|
throw new ArgumentException("Support tabs need a complete analysis with a tab point.", nameof(support));
|
||||||
|
if (!hasCutPerimeter)
|
||||||
|
throw new ArgumentException("A display perimeter is never cut, so it cannot hold a support tab.", nameof(support));
|
||||||
|
return new PreparedContours(this, support, location, tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The analysis of a part its support bed cannot hold level (see <see cref="WithSupportTab"/>),
|
||||||
|
/// or null; <see cref="SupportOrigin"/> places its local contours in its world frame.
|
||||||
|
/// </summary>
|
||||||
|
public PartSupport Support => support;
|
||||||
|
|
||||||
|
/// <summary>The part's location: world = local + this, for <see cref="Support"/>.</summary>
|
||||||
|
public Vector SupportOrigin => supportOrigin;
|
||||||
|
|
||||||
|
/// <summary>Whether the emitted perimeter is tabbed: by the plate settings, its pin or support.</summary>
|
||||||
|
internal bool PerimeterTabbed => hasCutPerimeter && ParametersFor(PerimeterOrdinal).TabsEnabled;
|
||||||
|
|
||||||
public int Count => shapes.Length;
|
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();
|
||||||
public int PerimeterOrdinal => Count - 1;
|
|
||||||
|
|
||||||
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
|
/// <summary>The pierce-zone resolutions these contours' emissions share; for tests.</summary>
|
||||||
|
internal PierceZoneMemo PierceZones => pierceZones;
|
||||||
|
/// <summary>
|
||||||
|
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
|
||||||
|
/// is a display perimeter, so every contour below it is a hole.
|
||||||
|
/// </summary>
|
||||||
|
public int PerimeterOrdinal => hasCutPerimeter ? Count - 1 : Count;
|
||||||
|
|
||||||
|
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
|
||||||
|
public bool HasCutPerimeter => hasCutPerimeter;
|
||||||
|
|
||||||
|
/// <summary>Resolved pins this preparation could not 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>
|
||||||
public bool IsPinned(int contourOrdinal) => GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
|
public bool IsPinned(int contourOrdinal) => !(contourOrdinal == PerimeterOrdinal && !hasCutPerimeter)
|
||||||
|
&& GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
|
||||||
|
|
||||||
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
|
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
|
||||||
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
|
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
|
||||||
@@ -122,17 +216,15 @@ public sealed class PreparedContours
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The pin's leads replace every contour type's leads, since the type the emitter detects is
|
// The pin's leads replace both lead sets, since the type the emitter detects is
|
||||||
// not the caller's concern; angle rounding is off because a pinned entry never moves.
|
// not the caller's concern; angle rounding is off because a pinned entry never moves.
|
||||||
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
|
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
|
||||||
{
|
{
|
||||||
var owned = OwnedCuttingParameters.Copy(shared);
|
var owned = OwnedCuttingParameters.Copy(shared);
|
||||||
owned.ExternalLeadIn = pin.LeadIn;
|
owned.ExternalLeadIn = pin.LeadIn;
|
||||||
owned.InternalLeadIn = pin.LeadIn;
|
owned.InternalLeadIn = pin.LeadIn;
|
||||||
owned.ArcCircleLeadIn = pin.LeadIn;
|
|
||||||
owned.ExternalLeadOut = pin.LeadOut;
|
owned.ExternalLeadOut = pin.LeadOut;
|
||||||
owned.InternalLeadOut = pin.LeadOut;
|
owned.InternalLeadOut = pin.LeadOut;
|
||||||
owned.ArcCircleLeadOut = pin.LeadOut;
|
|
||||||
owned.RoundLeadInAngles = false;
|
owned.RoundLeadInAngles = false;
|
||||||
owned.TabsEnabled = pin.HasTab;
|
owned.TabsEnabled = pin.HasTab;
|
||||||
if (pin.HasTab)
|
if (pin.HasTab)
|
||||||
@@ -155,6 +247,7 @@ public sealed class PreparedContours
|
|||||||
var scribes = new List<Entity>();
|
var scribes = new List<Entity>();
|
||||||
var breakCuts = new List<Shape>();
|
var breakCuts = new List<Shape>();
|
||||||
var tabbedCutouts = new List<Shape>();
|
var tabbedCutouts = new List<Shape>();
|
||||||
|
Shape displayPerimeter = null;
|
||||||
Shape breakCut = null;
|
Shape breakCut = null;
|
||||||
var current = new Shape();
|
var current = new Shape();
|
||||||
foreach (var entity in geometryProgram.ToGeometry())
|
foreach (var entity in geometryProgram.ToGeometry())
|
||||||
@@ -185,7 +278,8 @@ public sealed class PreparedContours
|
|||||||
scribes.Add(entity);
|
scribes.Add(entity);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab)
|
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab
|
||||||
|
&& entity.Layer != SpecialLayers.DisplayPerimeter)
|
||||||
throw new ArgumentException("Preparation requires clean cut/display contours.");
|
throw new ArgumentException("Preparation requires clean cut/display contours.");
|
||||||
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
||||||
throw new ArgumentException("Degenerate contour entity.");
|
throw new ArgumentException("Degenerate contour entity.");
|
||||||
@@ -203,6 +297,28 @@ 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 (displayPerimeter != null)
|
||||||
|
{
|
||||||
|
if (current.Entities.Count != 0)
|
||||||
|
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
||||||
|
if (contours.Count == 0 && tabbedCutouts.Count == 0 && breakCuts.Count == 0)
|
||||||
|
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
||||||
|
// Every cut must lie on the blank: a non-rectangular blank's box is not enough.
|
||||||
|
// Each cut is sampled along its arcs and must not cross or touch the outline anywhere.
|
||||||
|
var outline = displayPerimeter.ToPolygon();
|
||||||
|
foreach (var shape in contours.Concat(tabbedCutouts).Concat(breakCuts))
|
||||||
|
if (shape.ToPolygon().Vertices.Any(vertex => !outline.ContainsPoint(vertex))
|
||||||
|
|| displayPerimeter.Intersects(shape))
|
||||||
|
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||||
|
// The search plans a part through its closed contours; tabbed cutouts and break cuts
|
||||||
|
// alone leave it nothing to choose.
|
||||||
|
if (contours.Count == 0)
|
||||||
|
throw new ArgumentException("A display perimeter needs at least one closed cutout without tabs.");
|
||||||
|
foreach (var cut in breakCuts)
|
||||||
|
cut.UpdateBounds();
|
||||||
|
// Never a contour: nothing can choose, emit or lead it. Every contour is a hole.
|
||||||
|
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, displayPerimeter);
|
||||||
|
}
|
||||||
if (current.Entities.Count != 0 || contours.Count == 0)
|
if (current.Entities.Count != 0 || contours.Count == 0)
|
||||||
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
||||||
var perimeter = 0;
|
var perimeter = 0;
|
||||||
@@ -223,7 +339,15 @@ public sealed class PreparedContours
|
|||||||
void FinishContour()
|
void FinishContour()
|
||||||
{
|
{
|
||||||
current.UpdateBounds();
|
current.UpdateBounds();
|
||||||
if (ContourCuttingStrategy.IsTabbedCutout(current))
|
if (current.Entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
||||||
|
{
|
||||||
|
if (!current.Entities.TrueForAll(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
||||||
|
throw new ArgumentException("A display perimeter must be one whole closed contour.");
|
||||||
|
if (displayPerimeter != null)
|
||||||
|
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||||
|
displayPerimeter = current;
|
||||||
|
}
|
||||||
|
else if (ContourCuttingStrategy.IsTabbedCutout(current))
|
||||||
{
|
{
|
||||||
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
|
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
|
||||||
throw new ArgumentException("A tabbed cutout has nothing to cut.");
|
throw new ArgumentException("A tabbed cutout has nothing to cut.");
|
||||||
@@ -285,8 +409,12 @@ public sealed class PreparedContours
|
|||||||
void Add(int entityOrdinal, Vector point)
|
void Add(int entityOrdinal, Vector point)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
|
if (entries.Count >= maxEntries)
|
||||||
entries.Add(Entry(contourOrdinal, entityOrdinal, point));
|
return;
|
||||||
|
// Compare the entry as emitted: snapping can bring two raw points together.
|
||||||
|
var entry = Entry(contourOrdinal, entityOrdinal, point);
|
||||||
|
if (!entries.Any(e => e.Point.DistanceTo(entry.Point) <= PostVerificationGeometry.Epsilon))
|
||||||
|
entries.Add(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,6 +439,7 @@ public sealed class PreparedContours
|
|||||||
|
|
||||||
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
|
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
|
||||||
{
|
{
|
||||||
|
point = SnapToVertex(GetShape(contourOrdinal), entityOrdinal, point);
|
||||||
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
|
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
|
||||||
ValidateChoice(choice);
|
ValidateChoice(choice);
|
||||||
return choice;
|
return choice;
|
||||||
@@ -323,12 +452,63 @@ public sealed class PreparedContours
|
|||||||
/// emission reads only this choice and the perimeter, and the lead validator checks the
|
/// emission reads only this choice and the perimeter, and the lead validator checks the
|
||||||
/// lead as usual. A pinned perimeter keeps its pin's entry.
|
/// lead as usual. A pinned perimeter keeps its pin's entry.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal ContourChoice ScrapPierceEntry(Vector pierce)
|
internal ContourChoice ScrapPierceEntry(Vector pierce) => PiercedEntry(PerimeterOrdinal, pierce);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entries of cutout <paramref name="contourOrdinal"/> pierced where eroding it leaves room
|
||||||
|
/// (see <see cref="ErodedPierce"/>): the planner's fallback when no configured lead-in fits the
|
||||||
|
/// cutout. For each of the first <paramref name="max"/> <paramref name="targets"/> (its own
|
||||||
|
/// entries, usually ranked) the pierce is the eroded point nearest it, and the lead-in runs
|
||||||
|
/// straight from there to the target itself, then, as an alternative, to the cutout's native
|
||||||
|
/// point closest to the pierce. Neither has a lead-out. Nothing for the perimeter or a pinned
|
||||||
|
/// cutout; the lead validator still checks each one.
|
||||||
|
/// </summary>
|
||||||
|
internal IReadOnlyList<ContourChoice> ErodedPierceEntries(int contourOrdinal, IReadOnlyList<ContourChoice> targets,
|
||||||
|
int max, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var shape = GetShape(contourOrdinal);
|
||||||
|
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
|
||||||
|
return Array.Empty<ContourChoice>();
|
||||||
|
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
|
||||||
|
var depth = System.Math.Max(ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
|
||||||
|
ApplicableLeadInLength(contourOrdinal));
|
||||||
|
var found = ErodedPierce.Find(shape, depth,
|
||||||
|
targets.Select(t => t.Point).ToArray(), max);
|
||||||
|
var entries = new List<ContourChoice>();
|
||||||
|
for (var i = 0; i < found.Pierces.Count; i++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var pierce = found.Pierces[i];
|
||||||
|
Add(() => targets[i] with { Pierce = pierce });
|
||||||
|
Add(() => PiercedEntry(contourOrdinal, pierce));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
|
||||||
|
void Add(Func<ContourChoice> entry)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var choice = entry();
|
||||||
|
ValidateChoice(choice);
|
||||||
|
if (!entries.Any(e => e.Point == choice.Point && e.Pierce == choice.Pierce))
|
||||||
|
entries.Add(choice);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// A pierce on the wall itself has no lead; the others still stand.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A contour entry at the closest native point to a pierce off it, along a straight lead-in.
|
||||||
|
private ContourChoice PiercedEntry(int contourOrdinal, Vector pierce)
|
||||||
{
|
{
|
||||||
PostVerificationGeometry.Validate(pierce);
|
PostVerificationGeometry.Validate(pierce);
|
||||||
var shape = GetShape(PerimeterOrdinal);
|
var shape = GetShape(contourOrdinal);
|
||||||
var point = shape.ClosestPointTo(pierce, out var entity);
|
var point = shape.ClosestPointTo(pierce, out var entity);
|
||||||
var choice = new ContourChoice(PerimeterOrdinal, shape.Entities.IndexOf(entity), point)
|
point = SnapToVertex(shape, shape.Entities.IndexOf(entity), point);
|
||||||
|
var choice = new ContourChoice(contourOrdinal, shape.Entities.IndexOf(entity), point)
|
||||||
{
|
{
|
||||||
Owner = this,
|
Owner = this,
|
||||||
Pierce = pierce,
|
Pierce = pierce,
|
||||||
@@ -338,7 +518,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
|
||||||
@@ -354,12 +534,12 @@ public sealed class PreparedContours
|
|||||||
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 (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 MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
|
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
|
||||||
@@ -367,12 +547,13 @@ 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,
|
||||||
Vector? lookAhead = null,
|
Vector? lookAhead = null,
|
||||||
CancellationToken token = default)
|
CancellationToken token = default,
|
||||||
|
SupportEntries supportEntries = SupportEntries.Steadying)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var shape = GetShape(contourOrdinal);
|
var shape = GetShape(contourOrdinal);
|
||||||
@@ -382,7 +563,45 @@ public sealed class PreparedContours
|
|||||||
? new List<ContourEntryCandidate>()
|
? new List<ContourEntryCandidate>()
|
||||||
: PreferredCandidates(shape, contourOrdinal, token);
|
: PreferredCandidates(shape, contourOrdinal, token);
|
||||||
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
|
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
|
||||||
return MergeByGeometry(all);
|
return SupportFiltered(contourOrdinal, all, supportEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
|
||||||
|
/// its automatic catalog then keeps to steadying points unless asked for a fallback.
|
||||||
|
/// </summary>
|
||||||
|
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The support margin with this perimeter's tab at <paramref name="choice"/> (see
|
||||||
|
/// <see cref="PartSupport.MarginWith"/>); negative infinity when it is not tabbed for support.
|
||||||
|
/// </summary>
|
||||||
|
internal double SupportMarginAt(ContourChoice choice) =>
|
||||||
|
support?.MarginWith(choice.Point + supportOrigin) ?? double.NegativeInfinity;
|
||||||
|
|
||||||
|
/// <summary>Whether a support tab at <paramref name="choice"/> holds the part level (see <see cref="PartSupport.HoldsLevelWithTabAt"/>).</summary>
|
||||||
|
internal bool HoldsLevelAt(ContourChoice choice) =>
|
||||||
|
support != null && support.HoldsLevelWithTabAt(choice.Point + supportOrigin);
|
||||||
|
|
||||||
|
/// <summary>Whether a support tab at <paramref name="choice"/> keeps the inside-corner clearance.</summary>
|
||||||
|
internal bool IsClearOfInsideCorners(ContourChoice choice) =>
|
||||||
|
support == null || support.IsClearOfInsideCorners(choice.Point + supportOrigin);
|
||||||
|
|
||||||
|
// A perimeter tabbed for support keeps the candidates whose tab steadies the part, and gains
|
||||||
|
// the outline's best tab point. The search's fallbacks, when none of them has a lead-in that
|
||||||
|
// fits, keep every candidate clear of inside corners and then every candidate, still tabbed.
|
||||||
|
private IReadOnlyList<ContourEntryCandidate> SupportFiltered(int contourOrdinal,
|
||||||
|
List<ContourEntryCandidate> candidates, SupportEntries entries)
|
||||||
|
{
|
||||||
|
if (support == null || contourOrdinal != PerimeterOrdinal || entries == SupportEntries.Any)
|
||||||
|
return MergeByGeometry(candidates);
|
||||||
|
if (entries == SupportEntries.ClearOfInsideCorners)
|
||||||
|
return MergeByGeometry(candidates.Where(c => IsClearOfInsideCorners(c.Choice)).ToList());
|
||||||
|
var kept = candidates.Where(c => support.AcceptsTabAt(c.Choice.Point + supportOrigin)).ToList();
|
||||||
|
var best = ClosestEntry(PerimeterOrdinal, support.BestTabPoint!.Value - supportOrigin);
|
||||||
|
if (!IsForbiddenVertex(GetShape(PerimeterOrdinal), best.Point))
|
||||||
|
kept.Add(new ContourEntryCandidate(best, AutomaticEntryKind.SupportTab));
|
||||||
|
return MergeByGeometry(kept);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsSingleCircle(Shape shape) =>
|
private static bool IsSingleCircle(Shape shape) =>
|
||||||
@@ -390,8 +609,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>
|
||||||
@@ -436,7 +655,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();
|
||||||
@@ -475,9 +694,10 @@ public sealed class PreparedContours
|
|||||||
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
|
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
|
||||||
// Strictly inside the edge by projection parameter (distance alone loses the
|
// Strictly inside the edge by projection parameter (distance alone loses the
|
||||||
// sign when a short edge is overshoot): never the corner, never the far
|
// sign when a short edge is overshoot): never the corner, never the far
|
||||||
// endpoint (where a reflex vertex might sit). Short edges omit the point.
|
// endpoint (where a reflex vertex might sit). Short edges omit the point. The
|
||||||
|
// margin is the vertex snap distance, so Entry cannot snap it onto either end.
|
||||||
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
|
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
|
||||||
if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
|
if (t <= OpenNest.Math.Tolerance.Epsilon || t >= line.Length - OpenNest.Math.Tolerance.Epsilon)
|
||||||
return;
|
return;
|
||||||
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
|
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
|
||||||
}
|
}
|
||||||
@@ -549,14 +769,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)
|
||||||
{
|
{
|
||||||
@@ -571,7 +793,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);
|
||||||
@@ -580,36 +802,72 @@ public sealed class PreparedContours
|
|||||||
switch (corner.Kind)
|
switch (corner.Kind)
|
||||||
{
|
{
|
||||||
case ContourCuttingStrategy.CornerKind.Convex:
|
case ContourCuttingStrategy.CornerKind.Convex:
|
||||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
|
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
|
||||||
break;
|
break;
|
||||||
case ContourCuttingStrategy.CornerKind.Smooth
|
case ContourCuttingStrategy.CornerKind.Smooth
|
||||||
when entity is Line && Next(i) is Arc:
|
when entity is Line && Next(i) is Arc:
|
||||||
// A line leaving into an arc: the tangent joint. The reverse travel
|
// A line leaving into an arc: the tangent joint. The reverse travel
|
||||||
// order classifies the same joint from the arc, matched below.
|
// order classifies the same joint from the arc, matched below.
|
||||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
found.Add(Joint(i, vertex, (i + 1) % count));
|
||||||
break;
|
break;
|
||||||
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
||||||
// of a plain straight edge are not preferred automatic starts here.
|
// of a plain straight edge are not preferred automatic starts here.
|
||||||
case ContourCuttingStrategy.CornerKind.Smooth
|
case ContourCuttingStrategy.CornerKind.Smooth
|
||||||
when entity is Arc && Next(i) is Line:
|
when entity is Arc && Next(i) is Line:
|
||||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
found.Add(Joint(i, vertex, i));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entity is Line line)
|
if (entity is Line line)
|
||||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
|
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
|
|
||||||
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
||||||
|
|
||||||
|
(int, Vector, AutomaticEntryKind, double) Joint(int entityOrdinal, Vector vertex, int arcOrdinal) =>
|
||||||
|
FilletReach(shape, arcOrdinal) is { } reach
|
||||||
|
? (entityOrdinal, vertex, AutomaticEntryKind.ConvexCorner, reach)
|
||||||
|
: (entityOrdinal, vertex, AutomaticEntryKind.TangentJoint, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How far the joints of the arc at <paramref name="arcOrdinal"/> lie from the sharp corner it
|
||||||
|
/// rounds, when it is a corner fillet: tangent to straight edges at both ends, turning less
|
||||||
|
/// than a half circle the convex way, and reaching back along each edge no further than the
|
||||||
|
/// edge is long. A fillet's joints start the cut at the corner, where the mismatch when the
|
||||||
|
/// cut closes is least seen, instead of on a straight edge. Null for any other arc.
|
||||||
|
/// </summary>
|
||||||
|
private static double? FilletReach(Shape shape, int arcOrdinal)
|
||||||
|
{
|
||||||
|
var count = shape.Entities.Count;
|
||||||
|
if (shape.Entities[arcOrdinal] is not Arc arc
|
||||||
|
|| shape.Entities[(arcOrdinal + count - 1) % count] is not Line before
|
||||||
|
|| shape.Entities[(arcOrdinal + 1) % count] is not Line after)
|
||||||
|
return null;
|
||||||
|
var sweep = arc.SweepAngle();
|
||||||
|
if (sweep >= System.Math.PI - OpenNest.Math.Tolerance.Epsilon)
|
||||||
|
return null;
|
||||||
|
if (!IsSmooth(before.EndPoint, before) || !IsSmooth(arc.EndPoint(), arc))
|
||||||
|
return null;
|
||||||
|
var winding = ContourCuttingStrategy.DetermineWinding(shape);
|
||||||
|
if (ContourCuttingStrategy.ClassifyTurn((before.EndPoint - before.StartPoint).Normalize(),
|
||||||
|
(after.EndPoint - after.StartPoint).Normalize(), winding) != ContourCuttingStrategy.CornerKind.Convex)
|
||||||
|
return null;
|
||||||
|
var reach = arc.Radius * System.Math.Tan(sweep / 2);
|
||||||
|
return reach <= System.Math.Min(before.Length, after.Length) ? reach : null;
|
||||||
|
|
||||||
|
bool IsSmooth(Vector vertex, Entity entity) =>
|
||||||
|
ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)
|
||||||
|
&& corner.Kind == ContourCuttingStrategy.CornerKind.Smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
||||||
public Program Emit(IReadOnlyList<ContourChoice> choices)
|
public Program Emit(IReadOnlyList<ContourChoice> choices)
|
||||||
{
|
{
|
||||||
Validate(choices, true);
|
Validate(choices, true);
|
||||||
return EmitPrefix(choices);
|
return EmitOwned(choices, complete: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -626,21 +884,28 @@ public sealed class PreparedContours
|
|||||||
throw new ArgumentException("Foreign contour choice.");
|
throw new ArgumentException("Foreign contour choice.");
|
||||||
ValidateChoice(choice);
|
ValidateChoice(choice);
|
||||||
// Same owned clones as a real emission; the source shapes/settings are never used
|
// Same owned clones as a real emission; the source shapes/settings are never used
|
||||||
// directly, so the probe cannot drift the preparation or mutate it.
|
// directly, so the probe cannot drift the preparation or mutate it. Isolated emission
|
||||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
|
// reads only the chosen contour and the contour count, so only that contour is cloned.
|
||||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
|
var owned = new Shape[shapes.Length];
|
||||||
contourParameters);
|
owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
|
||||||
|
return new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitCandidateIsolated(
|
||||||
|
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
|
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
|
||||||
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
|
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
|
||||||
{
|
{
|
||||||
Validate(choices, false);
|
Validate(choices, false);
|
||||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
|
return EmitOwned(choices, complete: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a complete emission carries a display perimeter's tail: prefixes are search probes.
|
||||||
|
private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) =>
|
||||||
|
new ContourCuttingStrategy { Parameters = parameters, 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());
|
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal,
|
||||||
}
|
complete ? (Shape)displayOutline?.Clone() : null);
|
||||||
|
|
||||||
/// <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;
|
||||||
@@ -661,6 +926,8 @@ 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.
|
||||||
|
if (!hasCutPerimeter)
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -695,10 +962,8 @@ public sealed class PreparedContours
|
|||||||
if (choice.Pierce is { } pierce)
|
if (choice.Pierce is { } pierce)
|
||||||
{
|
{
|
||||||
PostVerificationGeometry.Validate(pierce);
|
PostVerificationGeometry.Validate(pierce);
|
||||||
if (choice.ContourOrdinal != PerimeterOrdinal)
|
|
||||||
throw new ArgumentException("Only a perimeter entry can be pierced in scrap.");
|
|
||||||
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
|
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
|
||||||
throw new ArgumentException("A scrap pierce must lie off its contour.");
|
throw new ArgumentException("A planner-placed pierce must lie off its contour.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,22 +971,25 @@ public sealed class PreparedContours
|
|||||||
? 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>
|
||||||
|
internal Box ContourBounds(int contourOrdinal) => GetShape(contourOrdinal).BoundingBox;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
|
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
|
||||||
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
|
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
|
||||||
@@ -733,6 +1001,33 @@ public sealed class PreparedContours
|
|||||||
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
|
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emission splits a contour at its entry with <see cref="Arc.SplitAt"/>/<see cref="Line.SplitAt"/>,
|
||||||
|
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
|
||||||
|
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
|
||||||
|
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
|
||||||
|
/// stored center sits beside the chord's midpoint) is never cut.
|
||||||
|
/// </summary>
|
||||||
|
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
|
||||||
|
{
|
||||||
|
if (entityOrdinal < 0 || entityOrdinal >= shape.Entities.Count
|
||||||
|
|| shape.Entities[entityOrdinal] is not (Line or Arc))
|
||||||
|
return point;
|
||||||
|
var entity = shape.Entities[entityOrdinal];
|
||||||
|
// Within the verifier's epsilon the entry already cuts the contour whole: leave it, so
|
||||||
|
// programs emitted before this snap (saved pinned entries) re-emit unchanged.
|
||||||
|
bool Snaps(Vector vertex)
|
||||||
|
{
|
||||||
|
var distance = point.DistanceTo(vertex);
|
||||||
|
return distance > PostVerificationGeometry.Epsilon && distance < OpenNest.Math.Tolerance.Epsilon;
|
||||||
|
}
|
||||||
|
var start = Start(entity);
|
||||||
|
if (point.DistanceTo(start) < OpenNest.Math.Tolerance.Epsilon)
|
||||||
|
return Snaps(start) ? start : point;
|
||||||
|
var end = End(entity);
|
||||||
|
return Snaps(end) ? end : point;
|
||||||
|
}
|
||||||
|
|
||||||
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
|
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
|
||||||
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
|
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
|
||||||
|
|
||||||
|
|||||||
@@ -12,19 +12,37 @@ 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;
|
||||||
|
// How the last analysed part ended: the contours it numbered, and whether nothing it left
|
||||||
|
// (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
|
||||||
|
// motions appended to it are judged. See Continue.
|
||||||
|
private int partContours;
|
||||||
|
private bool partClosed;
|
||||||
|
|
||||||
public ReleasedContourState() : this(true) { }
|
public ReleasedContourState() : this(true) { }
|
||||||
|
|
||||||
/// <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 = reportMissingLeadIns;
|
this.reportMissingLeadIns = reportMissingLeadIns;
|
||||||
|
this.tabMayNotHold = tabMayNotHold;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReleasedContourState Copy()
|
public ReleasedContourState Copy()
|
||||||
{
|
{
|
||||||
var copy = new ReleasedContourState(reportMissingLeadIns);
|
var copy = new ReleasedContourState(reportMissingLeadIns, tabMayNotHold)
|
||||||
|
{
|
||||||
|
partContours = partContours,
|
||||||
|
partClosed = partClosed,
|
||||||
|
};
|
||||||
copy.obstacles.AddRange(obstacles);
|
copy.obstacles.AddRange(obstacles);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
@@ -45,9 +63,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)
|
||||||
{
|
{
|
||||||
@@ -58,14 +78,39 @@ 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>
|
||||||
|
/// Checks <paramref name="execution"/> as the continuation of the part the last check ended
|
||||||
|
/// in, as if that part's whole execution had been checked with these motions appended: the
|
||||||
|
/// findings are those the appended motions would add, and the state advances the same way.
|
||||||
|
/// It needs that part to have ended with every cut closed and nothing deferred, and the
|
||||||
|
/// continuation to open with a rapid and end the same way; otherwise the result is null and
|
||||||
|
/// this state must be discarded, and the caller checks the whole execution instead. Fixed
|
||||||
|
/// emissions and cutoffs are not continued.
|
||||||
|
/// </summary>
|
||||||
|
internal IReadOnlyList<PostVerificationFinding> Continue(OwnedExecution execution, Vector arrival,
|
||||||
|
int partNumber, CancellationToken token)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(execution);
|
||||||
|
PostVerificationGeometry.Validate(arrival);
|
||||||
|
if (!partClosed || execution.Motions.Count == 0 || !execution.Motions[0].Rapid)
|
||||||
|
return null;
|
||||||
|
var moves = execution.Motions.ToArray();
|
||||||
|
moves[0] = moves[0].WithStart(arrival);
|
||||||
|
var findings = new List<PostVerificationFinding>();
|
||||||
|
AnalyzeMoves(moves, false, findings, 1, partNumber, token, contoursBefore: partContours);
|
||||||
|
return partClosed ? findings.AsReadOnly() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
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, List<PostVerificationFinding> raisedFindings = null)
|
||||||
{
|
{
|
||||||
var current = new CutGroup();
|
var current = new CutGroup();
|
||||||
var unfinished = new List<CutGroup>();
|
var unfinished = new List<CutGroup>();
|
||||||
@@ -78,7 +123,7 @@ public sealed class ReleasedContourState
|
|||||||
CutGroup previous = null;
|
CutGroup previous = null;
|
||||||
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
|
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
|
||||||
var hasLead = false;
|
var hasLead = false;
|
||||||
var contourNumber = 0;
|
var contourNumber = contoursBefore;
|
||||||
var inBreakCut = false;
|
var inBreakCut = false;
|
||||||
var reportedTab = false;
|
var reportedTab = false;
|
||||||
for (var index = 0; index < moves.Count; index++)
|
for (var index = 0; index < moves.Count; index++)
|
||||||
@@ -95,7 +140,7 @@ public sealed class ReleasedContourState
|
|||||||
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 has no nonzero placed lead-in motion.")
|
"A break cut is missing lead-in geometry.")
|
||||||
{ Location = move.Curve.Start }, owned);
|
{ Location = move.Curve.Start }, owned);
|
||||||
inBreakCut = true;
|
inBreakCut = true;
|
||||||
hasLead = false;
|
hasLead = false;
|
||||||
@@ -127,16 +172,31 @@ public sealed class ReleasedContourState
|
|||||||
// A rapid well clear of a contour's extent can neither cross nor touch it.
|
// A rapid well clear of a contour's extent can neither cross nor touch it.
|
||||||
if (reach.IsClearOf(obstacle.Extent))
|
if (reach.IsClearOf(obstacle.Extent))
|
||||||
continue;
|
continue;
|
||||||
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
|
// A rapid leaving an overcut departs from its retraced tail, which lies on the first
|
||||||
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
// cut only within the seam tolerance its arcs were matched at. Contact within that
|
||||||
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
// much of the departure is the departure itself.
|
||||||
$"of part {obstacle.Part}.")
|
var from = start;
|
||||||
{
|
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
|
||||||
Location = (start + move.End) * 0.5,
|
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
|
||||||
RapidStart = start,
|
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
|
||||||
RapidEnd = move.End,
|
if (!PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
|
||||||
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token)
|
continue;
|
||||||
}, owned);
|
var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
|
||||||
|
obstacle.Part, (move.HeadRaised ? "Head-raised" : "Direct") + " XY rapid crosses or touches "
|
||||||
|
+ (obstacle.TabMayNotHold
|
||||||
|
? $"the tabbed outside of part {obstacle.Part}, which its tab may not hold level."
|
||||||
|
: $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}."))
|
||||||
|
{
|
||||||
|
Location = (start + move.End) * 0.5,
|
||||||
|
RapidStart = start,
|
||||||
|
RapidEnd = move.End,
|
||||||
|
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
||||||
|
};
|
||||||
|
// The head clears a cut part on a raised rapid; the crossing is the raise's reason.
|
||||||
|
if (move.HeadRaised)
|
||||||
|
raisedFindings?.Add(crossing);
|
||||||
|
else
|
||||||
|
Add(crossing, owned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (move.Layer == LayerType.Leadin)
|
else if (move.Layer == LayerType.Leadin)
|
||||||
@@ -179,7 +239,7 @@ public sealed class ReleasedContourState
|
|||||||
contourNumber++;
|
contourNumber++;
|
||||||
if (reportMissingLeadIns && !cutoff && !hasLead)
|
if (reportMissingLeadIns && !cutoff && !hasLead)
|
||||||
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||||
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
|
$"Cutting contour {contourNumber} is missing lead-in geometry.")
|
||||||
{ Location = move.Curve.Start }, owned);
|
{ Location = move.Curve.Start }, owned);
|
||||||
hasLead = false;
|
hasLead = false;
|
||||||
// A rapid can pause/reposition without leaving any material gap.
|
// A rapid can pause/reposition without leaving any material gap.
|
||||||
@@ -201,19 +261,24 @@ public sealed class ReleasedContourState
|
|||||||
// A completed contour becomes an obstacle immediately, not at part end.
|
// A completed contour becomes an obstacle immediately, not at part end.
|
||||||
if (PostVerificationGeometry.Closed(current.Curves))
|
if (PostVerificationGeometry.Closed(current.Curves))
|
||||||
Finish();
|
Finish();
|
||||||
|
else if (OvercutClosure(current.Curves) is { } closing)
|
||||||
|
{
|
||||||
|
current.OvercutEnd = current.Curves[^1].End;
|
||||||
|
current.Curves[^1] = closing;
|
||||||
|
Finish();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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.
|
|
||||||
var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0)
|
var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0)
|
||||||
&& unfinished.All(LeadsClearOpenEnds)
|
&& 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.
|
||||||
@@ -222,6 +287,19 @@ 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 (!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;
|
||||||
|
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
|
||||||
|
&& !inBreakCut && loose.Leads.Count == 0;
|
||||||
|
|
||||||
void Add(PostVerificationFinding finding, bool fixedEmission)
|
void Add(PostVerificationFinding finding, bool fixedEmission)
|
||||||
{
|
{
|
||||||
@@ -244,9 +322,9 @@ public sealed class ReleasedContourState
|
|||||||
return;
|
return;
|
||||||
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
|
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
|
||||||
// no flag or tab configuration is used as evidence of retention.
|
// no flag or tab configuration is used as evidence of retention.
|
||||||
if (!cutoff && PostVerificationGeometry.Closed(current.Curves))
|
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
|
||||||
{
|
{
|
||||||
obstacles.Add(new(part, contourNumber, current.Curves.ToArray()));
|
obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd));
|
||||||
closed.Add(current);
|
closed.Add(current);
|
||||||
}
|
}
|
||||||
else if (!cutoff)
|
else if (!cutoff)
|
||||||
@@ -255,12 +333,37 @@ public sealed class ReleasedContourState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The closing piece of a contour cut on past its own start along its first cut, as a source
|
||||||
|
/// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
|
||||||
|
/// cut passes back through the start and its tail retraces the first cut. The tail encloses
|
||||||
|
/// nothing, so the contour is closed by the last cut up to its start. Null for any other shape.
|
||||||
|
/// Arcs of one circle may carry centers derived from their own rounded endpoints, so the seam
|
||||||
|
/// is matched at <see cref="PostVerificationGeometry.RaySeamTolerance"/>, the closure seam
|
||||||
|
/// ray parity already admits; the obstacle keeps that seam, never the retraced tail.
|
||||||
|
/// </summary>
|
||||||
|
private static PostVerificationGeometry.Curve OvercutClosure(IReadOnlyList<PostVerificationGeometry.Curve> curves)
|
||||||
|
{
|
||||||
|
if (curves.Count < 2)
|
||||||
|
return null;
|
||||||
|
const double tolerance = PostVerificationGeometry.RaySeamTolerance;
|
||||||
|
var first = curves[0];
|
||||||
|
var last = curves[^1];
|
||||||
|
if (!last.SameDirection(first, tolerance) || !last.Contains(first.Start, tolerance)
|
||||||
|
|| !first.Contains(last.End, tolerance))
|
||||||
|
return null;
|
||||||
|
var seam = last.ClosestPoint(first.Start);
|
||||||
|
var along = last.DistanceAlong(seam);
|
||||||
|
var tail = last.Length - along;
|
||||||
|
return along > tolerance && tail > tolerance && tail <= first.Length + tolerance ? last.Until(seam) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether one part's open cuts, with their leads, can release no material. A closed
|
/// Whether one part's open cuts, with their leads, can release no material. A closed
|
||||||
/// contour with its own leads already encloses whatever it releases and counts as one node.
|
/// 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;
|
||||||
|
|
||||||
@@ -396,6 +499,9 @@ public sealed class ReleasedContourState
|
|||||||
internal List<PostVerificationGeometry.Curve> Curves { get; } = new();
|
internal List<PostVerificationGeometry.Curve> Curves { get; } = new();
|
||||||
internal List<PostVerificationGeometry.Curve> Leads { get; } = new();
|
internal List<PostVerificationGeometry.Curve> Leads { get; } = new();
|
||||||
internal bool Fixed { get; set; }
|
internal bool Fixed { get; set; }
|
||||||
|
// Where the cut really ended when OvercutClosure closed it: the retraced tail's end, on the
|
||||||
|
// first cut. Its last curve now ends at the seam instead.
|
||||||
|
internal Vector? OvercutEnd { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class DisjointSet(int count)
|
private sealed class DisjointSet(int count)
|
||||||
@@ -412,9 +518,13 @@ public sealed class ReleasedContourState
|
|||||||
internal void Union(int a, int b) => parents[Find(a)] = Find(b);
|
internal void Union(int a, int b) => parents[Find(a)] = Find(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves)
|
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves,
|
||||||
|
Vector? OvercutEnd)
|
||||||
{
|
{
|
||||||
internal PostVerificationGeometry.Extent Extent { get; } =
|
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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,8 +72,8 @@ internal static class SeamOvercut
|
|||||||
// Incremental coordinates only need a consistent origin within one contour.
|
// Incremental coordinates only need a consistent origin within one contour.
|
||||||
var reference = incremental ? position ?? new Vector() : new Vector();
|
var reference = incremental ? position ?? new Vector() : new Vector();
|
||||||
var end = reference + motion.EndPoint;
|
var end = reference + motion.EndPoint;
|
||||||
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display }
|
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter }
|
||||||
or ArcMove { Layer: LayerType.Cut or LayerType.Display };
|
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter };
|
||||||
if (!cut || position == null)
|
if (!cut || position == null)
|
||||||
{
|
{
|
||||||
Finish();
|
Finish();
|
||||||
@@ -160,7 +160,7 @@ 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 = System.Math.Atan2(from.Y - center.Y, from.X - center.X);
|
||||||
|
|||||||
@@ -83,16 +83,16 @@ internal static class StateFingerprint
|
|||||||
Out(p.ExternalLeadOut);
|
Out(p.ExternalLeadOut);
|
||||||
Lead(p.InternalLeadIn);
|
Lead(p.InternalLeadIn);
|
||||||
Out(p.InternalLeadOut);
|
Out(p.InternalLeadOut);
|
||||||
Lead(p.ArcCircleLeadIn);
|
|
||||||
Out(p.ArcCircleLeadOut);
|
|
||||||
Number(p.PierceClearance);
|
Number(p.PierceClearance);
|
||||||
Number(p.PierceDiameter);
|
Number(p.PierceDiameter);
|
||||||
Flag(p.RoundLeadInAngles);
|
Flag(p.RoundLeadInAngles);
|
||||||
Number(p.LeadInAngleIncrement);
|
Number(p.LeadInAngleIncrement);
|
||||||
Number(p.AutoTabMinSize);
|
|
||||||
Number(p.AutoTabMaxSize);
|
|
||||||
TabSettings(p.TabConfig);
|
TabSettings(p.TabConfig);
|
||||||
Flag(p.TabsEnabled);
|
Flag(p.TabsEnabled);
|
||||||
|
Flag(p.SupportTabs);
|
||||||
|
Number(p.NoBedTabMinWidth);
|
||||||
|
Number(p.NoBedTabMaxWidth);
|
||||||
|
Number(p.TabHeldMaxLength);
|
||||||
Sequence(p.Sequencing);
|
Sequence(p.Sequencing);
|
||||||
Assignment(p.Assignment);
|
Assignment(p.Assignment);
|
||||||
text.Append('}');
|
text.Append('}');
|
||||||
@@ -141,6 +141,7 @@ internal static class StateFingerprint
|
|||||||
break;
|
break;
|
||||||
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
|
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
|
||||||
Number(l.Radius);
|
Number(l.Radius);
|
||||||
|
Number(l.SweepAngle);
|
||||||
break;
|
break;
|
||||||
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
|
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
|
||||||
Number(l.LineLength);
|
Number(l.LineLength);
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -20,6 +26,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
|
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
|
||||||
{
|
{
|
||||||
var entities = partProgram.ToGeometry();
|
var entities = partProgram.ToGeometry();
|
||||||
|
RefuseDisplayPerimeter(entities);
|
||||||
var breakCuts = TakeBreakCuts(entities);
|
var breakCuts = TakeBreakCuts(entities);
|
||||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||||
|
|
||||||
@@ -107,6 +114,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new CuttingResult { Program = result, LastCutPoint = perimeterPt };
|
return new CuttingResult { Program = result, LastCutPoint = perimeterPt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// These tools would lead the outline or mistake the largest cutout for it (an External lead
|
||||||
|
// pierced on the blank). Only the cutting planner knows every contour of such a part is a hole.
|
||||||
|
private static void RefuseDisplayPerimeter(List<Entity> entities)
|
||||||
|
{
|
||||||
|
if (entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
||||||
|
throw new InvalidOperationException(DisplayPerimeterTail.LeadInRefusal);
|
||||||
|
}
|
||||||
|
|
||||||
public CuttingResult ApplySingle(
|
public CuttingResult ApplySingle(
|
||||||
Program partProgram,
|
Program partProgram,
|
||||||
Vector point,
|
Vector point,
|
||||||
@@ -115,6 +130,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var entities = partProgram.ToGeometry();
|
var entities = partProgram.ToGeometry();
|
||||||
|
RefuseDisplayPerimeter(entities);
|
||||||
var breakCuts = TakeBreakCuts(entities);
|
var breakCuts = TakeBreakCuts(entities);
|
||||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||||
|
|
||||||
@@ -251,14 +267,20 @@ 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)
|
IReadOnlyList<Shape> tabbedCutouts = null, int? perimeterOrdinal = null, Shape displayTail = 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);
|
EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
|
||||||
|
if (displayTail != null)
|
||||||
|
{
|
||||||
|
// Display only, after every cut: executed reads and posts stop at its marker.
|
||||||
|
var start = GetShapeStartPoint(displayTail);
|
||||||
|
DisplayPerimeterTail.Append(result, ConvertShapeToMoves(displayTail, start, LayerType.DisplayPerimeter), start);
|
||||||
|
}
|
||||||
result.Mode = Mode.Incremental;
|
result.Mode = Mode.Incremental;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -269,20 +291,23 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
// perimeter never degrades to a hole because it was isolated. Output must not be
|
// perimeter never degrades to a hole because it was isolated. Output must not be
|
||||||
// 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)
|
||||||
{
|
{
|
||||||
var result = new Program(Mode.Absolute);
|
var result = new Program(Mode.Absolute);
|
||||||
EmitScribeContours(result, scribes);
|
EmitScribeContours(result, scribes);
|
||||||
EmitChosenContours(result, shapes, new[] { choice }, contourParameters);
|
EmitChosenContours(result, shapes, new[] { choice }, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
|
||||||
result.Mode = Mode.Incremental;
|
result.Mode = Mode.Incremental;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One contour per choice in order; the perimeter (last shape) is always External.
|
// One contour per choice in order; the perimeter is always External. Without a cut perimeter
|
||||||
|
// (a display perimeter outside) perimeterOrdinal is past the last shape: every contour is a hole.
|
||||||
// Contour emission itself reads only the contour's own geometry and settings, never
|
// Contour emission itself reads only the contour's own geometry and settings, never
|
||||||
// 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)
|
||||||
{
|
{
|
||||||
var shared = Parameters;
|
var shared = Parameters;
|
||||||
try
|
try
|
||||||
@@ -294,8 +319,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
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],
|
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||||
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
|
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
|
||||||
exactCirclePrograms: true, scrapPierce: choice.Pierce);
|
exactCirclePrograms: true, scrapPierce: choice.Pierce,
|
||||||
|
stopAtCenterLine: contourParameters?[choice.ContourOrdinal] == null,
|
||||||
|
memoContour: choice.ContourOrdinal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -421,7 +448,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
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);
|
||||||
@@ -430,6 +459,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
var leadIn = SelectLeadIn(contourType);
|
var leadIn = SelectLeadIn(contourType);
|
||||||
var leadOut = SelectLeadOut(contourType);
|
var leadOut = SelectLeadOut(contourType);
|
||||||
|
// A cutout the planner pierced by erosion is too small for its configured leads.
|
||||||
|
if (scrapPierce != null && contourType != ContourType.External)
|
||||||
|
leadOut = new NoLeadOut();
|
||||||
|
|
||||||
if (contourType == ContourType.ArcCircle && entity is Circle circle)
|
if (contourType == ContourType.ArcCircle && entity is Circle circle)
|
||||||
{
|
{
|
||||||
@@ -446,9 +478,16 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
if (scrapPierce is { } holePierce)
|
||||||
Parameters.GetEffectivePierceClearance());
|
leadIn = StraightLeadIn(point, holePierce, out normal);
|
||||||
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius());
|
else
|
||||||
|
{
|
||||||
|
if (stopAtCenterLine)
|
||||||
|
leadIn = StopAtCenterLine(shape, point, leadIn, normal, winding);
|
||||||
|
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
||||||
|
Parameters.GetEffectivePierceClearance());
|
||||||
|
leadIn = PierceZone(memoContour, shape, point, leadIn, normal, winding);
|
||||||
|
}
|
||||||
|
|
||||||
// Build hole sub-program relative to (0,0)
|
// Build hole sub-program relative to (0,0)
|
||||||
var holeCenter = circle.Center;
|
var holeCenter = circle.Center;
|
||||||
@@ -487,7 +526,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -496,9 +537,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());
|
{
|
||||||
|
if (stopAtCenterLine)
|
||||||
|
leadIn = StopAtCenterLine(shape, point, leadIn, leadInNormal, turn);
|
||||||
|
leadIn = PierceZone(memoContour, shape, point, leadIn, leadInNormal, turn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
|
|
||||||
|
|
||||||
var reindexedShape = shape.ReindexAt(point, entity);
|
var reindexedShape = shape.ReindexAt(point, entity);
|
||||||
|
|
||||||
@@ -511,6 +555,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:
|
||||||
@@ -520,6 +565,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
|
||||||
@@ -528,10 +585,22 @@ 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, winding));
|
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, turn));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The direction a lead arc turns to meet a contour of <paramref name="winding"/>
|
||||||
|
/// tangentially, in the cut direction. Normals point away from the material, so the arc
|
||||||
|
/// center lies outside an outside perimeter (turning against its winding) and inside a
|
||||||
|
/// cutout (turning with it).
|
||||||
|
/// </summary>
|
||||||
|
public static RotationType LeadTurn(ContourType contourType, RotationType winding) =>
|
||||||
|
contourType == ContourType.External
|
||||||
|
? winding == RotationType.CW ? RotationType.CCW : RotationType.CW
|
||||||
|
: winding;
|
||||||
|
|
||||||
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||||
{
|
{
|
||||||
if (scribeEntities.Count == 0)
|
if (scribeEntities.Count == 0)
|
||||||
@@ -616,8 +685,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
|
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
|
||||||
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
|
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
|
||||||
/// reflex corner it bisects the notch. The result does not depend on which of
|
/// reflex corner it bisects the notch. The result does not depend on which of
|
||||||
/// the two edges meeting at the corner was picked. Other styles and contour
|
/// the two edges meeting at the corner was picked. A curved lead-in at a convex
|
||||||
/// types keep <see cref="ComputeLeadInNormal"/>.
|
/// perimeter corner uses the normal of the edge cut first, so its arc meets that
|
||||||
|
/// edge tangentially. Other styles and contour types keep
|
||||||
|
/// <see cref="ComputeLeadInNormal"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static LeadIn ResolveLeadIn(
|
public static LeadIn ResolveLeadIn(
|
||||||
Shape shape,
|
Shape shape,
|
||||||
@@ -631,8 +702,19 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
|
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
|
||||||
if (contourType != ContourType.External || leadIn is not LineLeadIn line
|
if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
|
||||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
return leadIn;
|
||||||
|
|
||||||
|
if (leadIn is ArcLeadIn or LineArcLeadIn or CleanHoleLeadIn)
|
||||||
|
{
|
||||||
|
// The arc meets the edge cut first tangentially; its center sits off that edge,
|
||||||
|
// which at a convex corner keeps the whole arc clear of the part.
|
||||||
|
if (ClassifyCorner(corner, winding) is CornerKind.Convex or CornerKind.Smooth)
|
||||||
|
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||||
|
return leadIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leadIn is not LineLeadIn line)
|
||||||
return leadIn;
|
return leadIn;
|
||||||
|
|
||||||
switch (ClassifyCorner(corner, winding))
|
switch (ClassifyCorner(corner, winding))
|
||||||
@@ -662,7 +744,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
|
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
|
||||||
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
|
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
|
||||||
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
|
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
|
||||||
/// bisects the notch. Other styles and contour types keep the entity normal.
|
/// bisects the notch. An <see cref="ArcLeadOut"/> at a convex corner uses the normal of
|
||||||
|
/// the edge cut last. Other styles and contour types keep the entity normal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static LeadOut ResolveLeadOut(
|
public static LeadOut ResolveLeadOut(
|
||||||
Shape shape,
|
Shape shape,
|
||||||
@@ -676,8 +759,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
normal = ComputeNormal(point, entity, contourType, winding);
|
normal = ComputeNormal(point, entity, contourType, winding);
|
||||||
if (contourType != ContourType.External || leadOut is not LineLeadOut line
|
if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
|
||||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
return leadOut;
|
||||||
|
|
||||||
|
if (leadOut is ArcLeadOut)
|
||||||
|
{
|
||||||
|
// The arc leaves the edge cut last tangentially.
|
||||||
|
if (ClassifyCorner(corner, winding) is CornerKind.Convex or CornerKind.Smooth)
|
||||||
|
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||||
|
return leadOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leadOut is not LineLeadOut line)
|
||||||
return leadOut;
|
return leadOut;
|
||||||
|
|
||||||
switch (ClassifyCorner(corner, winding))
|
switch (ClassifyCorner(corner, winding))
|
||||||
@@ -804,10 +897,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)
|
||||||
@@ -836,6 +937,46 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return Angle.NormalizeRad(System.Math.Atan2(y, x));
|
return Angle.NormalizeRad(System.Math.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 = System.Math.Cos(startNormal) + System.Math.Cos(endNormal);
|
||||||
|
var y = System.Math.Sin(startNormal) + System.Math.Sin(endNormal);
|
||||||
|
if (x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var normal = Angle.NormalizeRad(System.Math.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>
|
||||||
/// A straight lead from <paramref name="end"/> to the corner stays in the scrap:
|
/// A straight lead from <paramref name="end"/> to the corner stays in the scrap:
|
||||||
/// its free end keeps <paramref name="clearance"/> from the contour and the lead
|
/// its free end keeps <paramref name="clearance"/> from the contour and the lead
|
||||||
@@ -945,7 +1086,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
if (leadIn is NoLeadIn || clearance <= 0)
|
if (leadIn is NoLeadIn || clearance <= 0)
|
||||||
return leadIn;
|
return leadIn;
|
||||||
|
|
||||||
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle);
|
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle, circle.Rotation);
|
||||||
var maxRadius = circle.Radius - clearance;
|
var maxRadius = circle.Radius - clearance;
|
||||||
if (maxRadius <= 0)
|
if (maxRadius <= 0)
|
||||||
return leadIn;
|
return leadIn;
|
||||||
@@ -987,17 +1128,30 @@ 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.
|
/// manual placement preview. <paramref name="turn"/> is the hole's
|
||||||
|
/// <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)
|
double pierceRadius, RotationType turn)
|
||||||
{
|
{
|
||||||
if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|
if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|
||||||
|| !hole.IsClosed())
|
|| !hole.IsClosed())
|
||||||
@@ -1007,29 +1161,42 @@ 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)))
|
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);
|
||||||
@@ -1050,6 +1217,97 @@ 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(System.Math.Cos(normal), System.Math.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.
|
||||||
@@ -1063,12 +1321,16 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
// 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
|
||||||
{
|
{
|
||||||
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn,
|
ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadIn,
|
||||||
ContourType.Internal => Parameters.InternalLeadIn,
|
|
||||||
_ => Parameters.ExternalLeadIn,
|
_ => Parameters.ExternalLeadIn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1077,14 +1339,29 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
return contourType switch
|
return contourType switch
|
||||||
{
|
{
|
||||||
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut,
|
ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadOut,
|
||||||
ContourType.Internal => Parameters.InternalLeadOut,
|
|
||||||
_ => Parameters.ExternalLeadOut,
|
_ => Parameters.ExternalLeadOut,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 * System.Math.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);
|
||||||
|
|
||||||
@@ -1261,7 +1538,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <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>
|
||||||
@@ -1351,7 +1628,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return mirrored;
|
return mirrored;
|
||||||
}
|
}
|
||||||
// Reaching both ways, or tangent to an arc: pierce straight in from the same distance.
|
// Reaching both ways, or tangent to an arc: pierce straight in from the same distance.
|
||||||
var pierce = leadIn.GetPiercePoint(entry, normal);
|
var pierce = leadIn.GetPiercePoint(entry, normal, winding);
|
||||||
return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
|
return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
|
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
|
||||||
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
|
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The lead-in of every cutout, full circles included; circles still get their own
|
||||||
|
/// geometry handling (radius clamp, angle rounding) when the lead is emitted.
|
||||||
|
/// </summary>
|
||||||
public LeadIn InternalLeadIn { get; set; } =
|
public LeadIn InternalLeadIn { get; set; } =
|
||||||
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
|
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
|
||||||
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
|
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
|
||||||
|
|
||||||
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
|
|
||||||
public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut();
|
|
||||||
|
|
||||||
public double PierceClearance { get; set; } = 0.0625;
|
public double PierceClearance { get; set; } = 0.0625;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -40,12 +41,55 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public bool RoundLeadInAngles { get; set; }
|
public bool RoundLeadInAngles { get; set; }
|
||||||
public double LeadInAngleIncrement { get; set; } = 5.0;
|
public double LeadInAngleIncrement { get; set; } = 5.0;
|
||||||
|
|
||||||
public double AutoTabMinSize { get; set; }
|
|
||||||
public double AutoTabMaxSize { get; set; }
|
|
||||||
|
|
||||||
public Tab TabConfig { get; set; }
|
public Tab TabConfig { get; set; }
|
||||||
public bool TabsEnabled { get; set; }
|
public bool TabsEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tabs each part its plate's support bed cannot hold level once it is cut free, at the
|
||||||
|
/// perimeter point that best keeps its center of gravity over the bed's contacts (see
|
||||||
|
/// docs/support-tabs.md). Needs <see cref="TabConfig"/> for the tab size.
|
||||||
|
/// </summary>
|
||||||
|
public bool SupportTabs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the narrowest part
|
||||||
|
/// width that is tabbed instead (see <see cref="TabsWithoutBedAt"/>).
|
||||||
|
/// </summary>
|
||||||
|
public double NoBedTabMinWidth { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the widest part
|
||||||
|
/// width that is tabbed instead; 0 turns the rule off, so nothing is tabbed without a bed.
|
||||||
|
/// </summary>
|
||||||
|
public double NoBedTabMaxWidth { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether <see cref="NoBedTabMinWidth"/> to <see cref="NoBedTabMaxWidth"/> is a usable range.</summary>
|
||||||
|
public bool HasNoBedTabRange() =>
|
||||||
|
NoBedTabMaxWidth > 0 && double.IsFinite(NoBedTabMaxWidth)
|
||||||
|
&& NoBedTabMinWidth >= 0 && NoBedTabMinWidth <= NoBedTabMaxWidth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a part <paramref name="width"/> wide (the short side of its minimum-area
|
||||||
|
/// bounding rectangle) is tabbed when its plate has no support bed.
|
||||||
|
/// </summary>
|
||||||
|
public bool TabsWithoutBedAt(double width) =>
|
||||||
|
HasNoBedTabRange()
|
||||||
|
&& width >= NoBedTabMinWidth - Math.Tolerance.Epsilon
|
||||||
|
&& width <= NoBedTabMaxWidth + Math.Tolerance.Epsilon;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// With <see cref="SupportTabs"/> on a plate with a support bed, the longest part (the
|
||||||
|
/// long side of its outline's minimum-area bounding rectangle) its tab alone holds: such a
|
||||||
|
/// part the bed cannot hold level is tabbed, and any tab clear of inside corners steadies
|
||||||
|
/// it. 0 turns the rule off. New settings start at 6; settings saved without it read as 0.
|
||||||
|
/// </summary>
|
||||||
|
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>
|
||||||
|
public bool TabHoldsAt(double length) =>
|
||||||
|
TabHeldMaxLength > 0 && double.IsFinite(TabHeldMaxLength)
|
||||||
|
&& length <= TabHeldMaxLength + Math.Tolerance.Epsilon;
|
||||||
|
|
||||||
public SequenceParameters Sequencing { get; set; } = new SequenceParameters();
|
public SequenceParameters Sequencing { get; set; } = new SequenceParameters();
|
||||||
public AssignmentParameters Assignment { get; set; } = new AssignmentParameters();
|
public AssignmentParameters Assignment { get; set; } = new AssignmentParameters();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Clipper2Lib;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pierces found by eroding a cutout: the part of it that keeps a clearance from its wall
|
||||||
|
/// (see <see cref="ErodedPierce.Find"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Pierces">The pierce for each target in turn, up to the maximum asked for.</param>
|
||||||
|
/// <param name="Clearance">The least any pierce keeps from the wall, at the erosion level that found it.</param>
|
||||||
|
public sealed record ErodedPierces(IReadOnlyList<Vector> Pierces, double Clearance)
|
||||||
|
{
|
||||||
|
public static ErodedPierces None { get; } = new(System.Array.Empty<Vector>(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The pierce search for cutouts too small for their configured lead-in (text, slots, small
|
||||||
|
/// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
|
||||||
|
/// The erosion goes as deep as the caller asks when the cutout has room for it, else almost
|
||||||
|
/// 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
|
||||||
|
/// 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
|
||||||
|
/// straight from the pierce to the wall.
|
||||||
|
/// </summary>
|
||||||
|
public static class ErodedPierce
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
|
||||||
|
/// not fit: deep enough to stay near the center line, with room for every place the
|
||||||
|
/// cutout is about as wide as its widest.
|
||||||
|
/// </summary>
|
||||||
|
public const double DepthFraction = 0.9;
|
||||||
|
|
||||||
|
private const int PoleSearchCells = 4096;
|
||||||
|
|
||||||
|
// Shallower erosion levels a target may fall back to, each a tenth of the deepest less.
|
||||||
|
private const int LevelCount = 6;
|
||||||
|
private const double LevelStep = 0.1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A pierce in the closed cutout <paramref name="hole"/> for each of the first
|
||||||
|
/// <paramref name="max"/> <paramref name="targets"/>: the eroded point nearest it. A cutout
|
||||||
|
/// too small to erode is pierced at its pole for every target. Nothing for an open or
|
||||||
|
/// degenerate contour.
|
||||||
|
/// </summary>
|
||||||
|
public static ErodedPierces Find(Shape hole, double depth, IReadOnlyList<Vector> targets, int max)
|
||||||
|
{
|
||||||
|
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|
||||||
|
|| !double.IsFinite(depth))
|
||||||
|
return ErodedPierces.None;
|
||||||
|
|
||||||
|
// The inscribed radius, from the true wall: the deepest any erosion can go.
|
||||||
|
var coarse = ClipperBridge.Flatten(hole, Tolerance(hole.BoundingBox.Width, hole.BoundingBox.Length), false);
|
||||||
|
if (coarse.Vertices.Count < 3)
|
||||||
|
return ErodedPierces.None;
|
||||||
|
var pole = PolyLabel.Find(coarse, null, Tolerance(coarse.BoundingBox.Width, coarse.BoundingBox.Length) / 10,
|
||||||
|
PoleSearchCells);
|
||||||
|
if (!double.IsFinite(pole.X) || !double.IsFinite(pole.Y) || !coarse.ContainsPoint(pole))
|
||||||
|
return ErodedPierces.None;
|
||||||
|
var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
|
||||||
|
if (!(inscribed > 0))
|
||||||
|
return ErodedPierces.None;
|
||||||
|
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,
|
||||||
|
// each by the tolerance; Clipper rounds to its precision. Erode past both.
|
||||||
|
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 margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
|
||||||
|
if (depth + margin >= inscribed - tolerance)
|
||||||
|
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
|
||||||
|
|
||||||
|
var polygon = ClipperBridge.Flatten(hole, tolerance, circumscribe: false);
|
||||||
|
var path = new PathsD { ClipperBridge.ToPath(polygon, positive: true) };
|
||||||
|
var levels = new List<PathD>[LevelCount];
|
||||||
|
List<PathD> Level(int level) => levels[level] ??= Clipper.InflatePaths(path,
|
||||||
|
-(Depth(level) + margin), JoinType.Round, EndType.Polygon, 2.0, ClipperBridge.Precision, tolerance)
|
||||||
|
.Where(p => p.Count >= 3).ToList();
|
||||||
|
double Depth(int level) => depth * (1 - level * LevelStep);
|
||||||
|
if (Level(0).Count == 0)
|
||||||
|
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
|
||||||
|
|
||||||
|
// Each target gets the deepest level that reaches the wall beside it: a stroke
|
||||||
|
// narrower than the cutout's widest place is still pierced on its own center line, near
|
||||||
|
// the entry, rather than only at the widest place.
|
||||||
|
var pierces = new List<Vector>();
|
||||||
|
var shallowest = depth;
|
||||||
|
foreach (var target in targets)
|
||||||
|
{
|
||||||
|
var pierce = Nearest(Level(0), target);
|
||||||
|
var reached = 0;
|
||||||
|
for (var level = 0; level < LevelCount; level++)
|
||||||
|
{
|
||||||
|
var region = Level(level);
|
||||||
|
if (region.Count == 0)
|
||||||
|
continue;
|
||||||
|
var nearest = Nearest(region, target);
|
||||||
|
if (nearest.DistanceTo(target) <= 2 * (Depth(level) + margin))
|
||||||
|
{
|
||||||
|
pierce = nearest;
|
||||||
|
reached = level;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pierces.Add(pierce);
|
||||||
|
shallowest = System.Math.Min(shallowest, Depth(reached));
|
||||||
|
if (pierces.Count == max)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return new(pierces, shallowest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hundredth of the smaller side: fine enough to find a thin cutout's pole.
|
||||||
|
private static double Tolerance(double width, double length) =>
|
||||||
|
System.Math.Max(System.Math.Min(width, length) / 100, System.Math.Pow(10, -ClipperBridge.Precision));
|
||||||
|
|
||||||
|
// The closest point of the region's boundary; targets lie on the wall, outside it.
|
||||||
|
private static Vector Nearest(List<PathD> paths, Vector target)
|
||||||
|
{
|
||||||
|
var best = default(Vector);
|
||||||
|
var bestDistance = double.PositiveInfinity;
|
||||||
|
foreach (var path in paths)
|
||||||
|
for (var i = 0; i < path.Count; i++)
|
||||||
|
{
|
||||||
|
var a = new Vector(path[i].x, path[i].y);
|
||||||
|
var b = new Vector(path[(i + 1) % path.Count].x, path[(i + 1) % path.Count].y);
|
||||||
|
var point = new Line(a, b).ClosestPointTo(target);
|
||||||
|
var distance = point.DistanceTo(target);
|
||||||
|
if (distance < bestDistance)
|
||||||
|
{
|
||||||
|
bestDistance = distance;
|
||||||
|
best = point;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,43 +1,62 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A single arc that ends tangent to the cut. Its center lies <see cref="Radius"/> along the
|
||||||
|
/// contour normal and it turns <see cref="SweepAngle"/> degrees in the lead's turn direction:
|
||||||
|
/// 90 is a quarter circle, 180 (the default) a half circle starting two radii out.
|
||||||
|
/// </summary>
|
||||||
public class ArcLeadIn : LeadIn
|
public class ArcLeadIn : LeadIn
|
||||||
{
|
{
|
||||||
|
public const double DefaultSweepAngle = 180.0;
|
||||||
|
|
||||||
public double Radius { get; set; }
|
public double Radius { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Degrees of arc, in (0, 180].</summary>
|
||||||
|
public double SweepAngle { get; set; } = DefaultSweepAngle;
|
||||||
|
|
||||||
public override List<ICode> Generate(
|
public override List<ICode> Generate(
|
||||||
Vector contourStartPoint,
|
Vector contourStartPoint,
|
||||||
double contourNormalAngle,
|
double contourNormalAngle,
|
||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
|
||||||
|
|
||||||
var arcCenter = new Vector(
|
|
||||||
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
|
|
||||||
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
|
|
||||||
);
|
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(piercePoint),
|
new RapidMove(GetPiercePoint(contourStartPoint, contourNormalAngle, winding)),
|
||||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
new ArcMove(contourStartPoint, ArcCenter(contourStartPoint, contourNormalAngle), winding)
|
||||||
|
{
|
||||||
|
Layer = LayerType.Leadin,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
public override Vector GetPiercePoint(
|
||||||
|
Vector contourStartPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var arcCenterX = contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle);
|
// The contour point sits opposite the normal from the center; the pierce is the
|
||||||
var arcCenterY = contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
// sweep back from there, against the turn. A half circle pierces on the normal.
|
||||||
|
var shortfall = Angle.ToRadians(DefaultSweepAngle - SweepAngle);
|
||||||
|
var angle = contourNormalAngle + (winding == RotationType.CCW ? shortfall : -shortfall);
|
||||||
|
var center = ArcCenter(contourStartPoint, contourNormalAngle);
|
||||||
return new Vector(
|
return new Vector(
|
||||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
|
center.X + Radius * System.Math.Cos(angle),
|
||||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
|
center.Y + Radius * System.Math.Sin(angle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor };
|
public override LeadIn Scale(double factor) =>
|
||||||
|
new ArcLeadIn { Radius = Radius * factor, SweepAngle = SweepAngle };
|
||||||
|
|
||||||
|
private Vector ArcCenter(Vector contourStartPoint, double contourNormalAngle) => new Vector(
|
||||||
|
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
|
||||||
|
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
public class CleanHoleLeadIn : LeadIn
|
public class CleanHoleLeadIn : LeadIn
|
||||||
{
|
{
|
||||||
|
private const double ApproachAngle = 135.0;
|
||||||
|
|
||||||
public double LineLength { get; set; }
|
public double LineLength { get; set; }
|
||||||
public double ArcRadius { get; set; }
|
public double ArcRadius { get; set; }
|
||||||
public double Kerf { get; set; }
|
public double Kerf { get; set; }
|
||||||
@@ -14,42 +15,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
Vector contourStartPoint,
|
Vector contourStartPoint,
|
||||||
double contourNormalAngle,
|
double contourNormalAngle,
|
||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
)
|
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
|
||||||
{
|
|
||||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
|
||||||
|
|
||||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
RotationType winding = RotationType.CW) =>
|
||||||
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
|
||||||
|
|
||||||
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
|
|
||||||
var arcStart = new Vector(
|
|
||||||
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
|
||||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
|
||||||
);
|
|
||||||
|
|
||||||
return new List<ICode>
|
|
||||||
{
|
|
||||||
new RapidMove(piercePoint),
|
|
||||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
|
||||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
|
||||||
{
|
|
||||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
|
||||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
|
||||||
|
|
||||||
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
|
|
||||||
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
|
|
||||||
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
|
|
||||||
|
|
||||||
return new Vector(
|
|
||||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
|
||||||
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override LeadIn Scale(double factor) =>
|
public override LeadIn Scale(double factor) =>
|
||||||
new CleanHoleLeadIn
|
new CleanHoleLeadIn
|
||||||
@@ -58,5 +28,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
ArcRadius = ArcRadius * factor,
|
ArcRadius = ArcRadius * factor,
|
||||||
Kerf = Kerf,
|
Kerf = Kerf,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
|
||||||
|
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,11 @@ using OpenNest.Geometry;
|
|||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A lead into a contour point. <c>winding</c> is the direction a lead arc turns to meet the
|
||||||
|
/// cut tangentially: the cut's own direction around the arc center, which lies along the
|
||||||
|
/// normal (see <see cref="ContourCuttingStrategy.LeadTurn"/>). Straight styles ignore it.
|
||||||
|
/// </summary>
|
||||||
public abstract class LeadIn
|
public abstract class LeadIn
|
||||||
{
|
{
|
||||||
public abstract List<ICode> Generate(
|
public abstract List<ICode> Generate(
|
||||||
@@ -11,7 +16,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
);
|
);
|
||||||
|
|
||||||
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
|
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW);
|
||||||
|
|
||||||
public virtual LeadIn Scale(double factor) => this;
|
public virtual LeadIn Scale(double factor) => this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A straight line into an arc that ends on the contour start, shared by
|
||||||
|
/// <see cref="LineArcLeadIn"/> and <see cref="CleanHoleLeadIn"/>. The arc's center lies
|
||||||
|
/// the arc radius along the contour normal; the line meets the arc at the approach angle
|
||||||
|
/// (degrees from the normal, on the side the arc turns from) and starts at the pierce point.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly struct LineArcApproach
|
||||||
|
{
|
||||||
|
private LineArcApproach(Vector arcCenter, Vector arcStart, Vector piercePoint)
|
||||||
|
{
|
||||||
|
ArcCenter = arcCenter;
|
||||||
|
ArcStart = arcStart;
|
||||||
|
PiercePoint = piercePoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vector ArcCenter { get; }
|
||||||
|
|
||||||
|
public Vector ArcStart { get; }
|
||||||
|
|
||||||
|
public Vector PiercePoint { get; }
|
||||||
|
|
||||||
|
public static LineArcApproach Create(Vector contourStartPoint, double contourNormalAngle,
|
||||||
|
double arcRadius, double approachAngle, double lineLength, RotationType winding)
|
||||||
|
{
|
||||||
|
var arcCenterX = contourStartPoint.X + arcRadius * System.Math.Cos(contourNormalAngle);
|
||||||
|
var arcCenterY = contourStartPoint.Y + arcRadius * System.Math.Sin(contourNormalAngle);
|
||||||
|
|
||||||
|
// The approach angle is measured back from the normal against the turn, so the arc
|
||||||
|
// runs the short way round to the contour point whichever way it turns.
|
||||||
|
var approach = Angle.ToRadians(approachAngle);
|
||||||
|
var lineAngle = contourNormalAngle + (winding == RotationType.CCW ? approach : -approach);
|
||||||
|
var arcStartX = arcCenterX + arcRadius * System.Math.Cos(lineAngle);
|
||||||
|
var arcStartY = arcCenterY + arcRadius * System.Math.Sin(lineAngle);
|
||||||
|
|
||||||
|
return new LineArcApproach(
|
||||||
|
new Vector(arcCenterX, arcCenterY),
|
||||||
|
new Vector(arcStartX, arcStartY),
|
||||||
|
new Vector(
|
||||||
|
arcStartX + lineLength * System.Math.Cos(lineAngle),
|
||||||
|
arcStartY + lineLength * System.Math.Sin(lineAngle)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rapid to the pierce point, then the line and arc on the lead-in layer.</summary>
|
||||||
|
public List<ICode> ToCodes(Vector contourStartPoint, RotationType winding) =>
|
||||||
|
new List<ICode>
|
||||||
|
{
|
||||||
|
new RapidMove(PiercePoint),
|
||||||
|
new LinearMove(ArcStart) { Layer = LayerType.Leadin },
|
||||||
|
new ArcMove(contourStartPoint, ArcCenter, winding) { Layer = LayerType.Leadin },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -14,42 +13,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
Vector contourStartPoint,
|
Vector contourStartPoint,
|
||||||
double contourNormalAngle,
|
double contourNormalAngle,
|
||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
)
|
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
|
||||||
{
|
|
||||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
|
||||||
|
|
||||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
RotationType winding = RotationType.CW) =>
|
||||||
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
|
||||||
|
|
||||||
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
|
||||||
var arcStart = new Vector(
|
|
||||||
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
|
||||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
|
||||||
);
|
|
||||||
|
|
||||||
return new List<ICode>
|
|
||||||
{
|
|
||||||
new RapidMove(piercePoint),
|
|
||||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
|
||||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
|
||||||
{
|
|
||||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
|
||||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
|
||||||
|
|
||||||
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
|
||||||
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
|
|
||||||
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
|
|
||||||
|
|
||||||
return new Vector(
|
|
||||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
|
||||||
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override LeadIn Scale(double factor) =>
|
public override LeadIn Scale(double factor) =>
|
||||||
new LineArcLeadIn
|
new LineArcLeadIn
|
||||||
@@ -58,5 +26,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
ArcRadius = ArcRadius * factor,
|
ArcRadius = ArcRadius * factor,
|
||||||
ApproachAngle = ApproachAngle,
|
ApproachAngle = ApproachAngle,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
|
||||||
|
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW)
|
||||||
{
|
{
|
||||||
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
||||||
return new Vector(
|
return new Vector(
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW)
|
||||||
{
|
{
|
||||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||||
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
|
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new List<ICode> { new RapidMove(contourStartPoint) };
|
return new List<ICode> { new RapidMove(contourStartPoint) };
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW)
|
||||||
{
|
{
|
||||||
return contourStartPoint;
|
return contourStartPoint;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ using OpenNest.Geometry;
|
|||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A quarter circle leaving the cut tangentially, turning <c>winding</c> about a center
|
||||||
|
/// <see cref="Radius"/> along the contour normal.
|
||||||
|
/// </summary>
|
||||||
public class ArcLeadOut : LeadOut
|
public class ArcLeadOut : LeadOut
|
||||||
{
|
{
|
||||||
public double Radius { get; set; }
|
public double Radius { get; set; }
|
||||||
@@ -17,9 +21,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
var arcCenterY = contourEndPoint.Y + Radius * System.Math.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.
|
||||||
|
var endAngle = contourNormalAngle
|
||||||
|
+ (winding == RotationType.CCW ? -System.Math.PI / 2 : System.Math.PI / 2);
|
||||||
var endPoint = new Vector(
|
var endPoint = new Vector(
|
||||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2),
|
arcCenterX + Radius * System.Math.Cos(endAngle),
|
||||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)
|
arcCenterY + Radius * System.Math.Sin(endAngle)
|
||||||
);
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The never-cut outline of a supplied blank, kept at the end of an applied cutting program.
|
||||||
|
/// An applied program for a blank with a <see cref="LayerType.DisplayPerimeter"/> outline cuts
|
||||||
|
/// only its cutouts; the outline follows them after <see cref="Marker"/> so the part keeps the
|
||||||
|
/// blank's extent for bounds, overlap and cut-offs. Nothing that executes or posts a program may
|
||||||
|
/// run the tail: executed reads stop at the marker and posts take <see cref="ForPosting"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class DisplayPerimeterTail
|
||||||
|
{
|
||||||
|
/// <summary>The comment that starts the tail; every code after it is display only.</summary>
|
||||||
|
public const string Marker = "DISPLAY PERIMETER NEVER CUT";
|
||||||
|
|
||||||
|
/// <summary>Why the manual and legacy lead-in tools leave a display-perimeter part alone.</summary>
|
||||||
|
public const string LeadInRefusal =
|
||||||
|
"This part's outline is a display perimeter: a supplied blank that is never cut. "
|
||||||
|
+ "Lead-ins for its cutouts come from Plan Cutting, which keeps every pierce inside a cutout.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The index of the tail's marker in <paramref name="program"/>'s codes, or -1. A marker that is
|
||||||
|
/// repeated, sits in a sub-program, or is followed by anything but one rapid and moves on the
|
||||||
|
/// display perimeter layer is a malformed program (<see cref="ArgumentException"/>): cuts after
|
||||||
|
/// it must never be dropped silently.
|
||||||
|
/// </summary>
|
||||||
|
public static int IndexOf(Program program)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(program);
|
||||||
|
var marker = -1;
|
||||||
|
for (var i = 0; i < program.Codes.Count; i++)
|
||||||
|
{
|
||||||
|
if (program.Codes[i] is Comment { Value: Marker })
|
||||||
|
{
|
||||||
|
if (marker >= 0)
|
||||||
|
throw new ArgumentException("A program has more than one display perimeter tail.");
|
||||||
|
marker = i;
|
||||||
|
}
|
||||||
|
else if (program.Codes[i] is SubProgramCall call
|
||||||
|
&& HasMarker(call.Program, new HashSet<Program>(ReferenceEqualityComparer.Instance)))
|
||||||
|
throw new ArgumentException("A display perimeter tail can only end the part's own program, not a sub-program.");
|
||||||
|
}
|
||||||
|
if (marker < 0)
|
||||||
|
return -1;
|
||||||
|
var tail = program.Codes.Count - marker - 1;
|
||||||
|
if (tail < 2 || program.Codes[marker + 1] is not RapidMove
|
||||||
|
|| !program.Codes.Skip(marker + 2).All(code =>
|
||||||
|
code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter }))
|
||||||
|
throw new ArgumentException(
|
||||||
|
"A display perimeter tail must be one rapid followed only by the outline on the display perimeter layer.");
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasMarker(Program program, HashSet<Program> visited)
|
||||||
|
{
|
||||||
|
if (program?.Codes == null || !visited.Add(program))
|
||||||
|
return false;
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
if (code is Comment { Value: Marker } || code is SubProgramCall call && HasMarker(call.Program, visited))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasTail(Program program) => IndexOf(program) >= 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends the outline after the marker: a rapid to its start, then its entities on the
|
||||||
|
/// display perimeter layer. <paramref name="program"/> must still be in absolute mode.
|
||||||
|
/// </summary>
|
||||||
|
internal static void Append(Program program, IReadOnlyList<ICode> outline, Vector start)
|
||||||
|
{
|
||||||
|
if (program.Mode != Mode.Absolute)
|
||||||
|
throw new InvalidOperationException("Append the display tail before switching to incremental mode.");
|
||||||
|
program.Codes.Add(new Comment(Marker));
|
||||||
|
program.Codes.Add(new RapidMove(start));
|
||||||
|
program.Codes.AddRange(outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The executed program for estimates and drawing: <paramref name="program"/> itself when it
|
||||||
|
/// has no well-formed tail, otherwise a shallow copy of the codes before the marker. Posts
|
||||||
|
/// and verification use the strict <see cref="ForPosting"/> and executed reads instead.
|
||||||
|
/// </summary>
|
||||||
|
public static Program WithoutTail(Program program)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var marker = IndexOf(program);
|
||||||
|
return marker < 0 ? program : program.HeadCopy(marker);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether <paramref name="program"/> or any sub-program it calls moves on the display perimeter layer.</summary>
|
||||||
|
public static bool HasDisplayPerimeterMotion(Program program) =>
|
||||||
|
HasDisplayPerimeterMotion(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
|
||||||
|
|
||||||
|
private static bool HasDisplayPerimeterMotion(Program program, HashSet<Program> visited)
|
||||||
|
{
|
||||||
|
if (program?.Codes == null || !visited.Add(program))
|
||||||
|
return false;
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
{
|
||||||
|
if (code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter })
|
||||||
|
return true;
|
||||||
|
if (code is SubProgramCall call && HasDisplayPerimeterMotion(call.Program, visited))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The program a post may write for a part: its tail removed. A display perimeter anywhere
|
||||||
|
/// else means the part was never planned, and posting it would cut the blank's outline.
|
||||||
|
/// </summary>
|
||||||
|
public static Program ForPosting(Program program, string partName)
|
||||||
|
{
|
||||||
|
int marker;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
marker = IndexOf(program);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{partName} cannot be posted: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
var executed = marker < 0 ? program : program.HeadCopy(marker);
|
||||||
|
if (HasDisplayPerimeterMotion(executed))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{partName} has a display perimeter (a supplied blank's outline that is never cut) but no "
|
||||||
|
+ "cutting plan. Plan cutting first (Plate > Plan Cutting..., or --plan-cutting) so only its cutouts are cut.");
|
||||||
|
return executed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The name posts give a part in a refusal.</summary>
|
||||||
|
public static string PartName(Part part) => $"Part '{part.BaseDrawing?.Name}'";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refuses a nest a post cannot write, before any output is created: every part's program
|
||||||
|
/// must pass <see cref="ForPosting"/>, so a refusal never leaves an empty or partial file.
|
||||||
|
/// </summary>
|
||||||
|
public static void EnsurePostable(Nest nest)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(nest);
|
||||||
|
foreach (var plate in nest.Plates)
|
||||||
|
foreach (var part in plate.Parts)
|
||||||
|
if (part?.Program != null && part.BaseDrawing?.IsCutOff != true)
|
||||||
|
ForPosting(part.Program, PartName(part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The head-height lock around a cut-off's overcut past the nominal far sheet edge. Stock can
|
||||||
|
/// arrive wider than nominal, so a cut-off overcuts past the far edge to cut the oversize off;
|
||||||
|
/// past the real edge there is no material to sense, so the head height is locked first. A
|
||||||
|
/// cut-off program marks the locked stretch with a <see cref="Lock"/> comment and ends it with an
|
||||||
|
/// <see cref="Unlock"/> comment; a rapid between them keeps the head locked and is not a head
|
||||||
|
/// raise. Posts write each marker as their machine's lock or unlock code and refuse to post a
|
||||||
|
/// marked program when no code is configured, because the overcut is unsafe without the lock.
|
||||||
|
/// See docs/automatic-scrap-cutoffs.md.
|
||||||
|
/// </summary>
|
||||||
|
public static class HeadHeightLock
|
||||||
|
{
|
||||||
|
/// <summary>The comment before the first motion made with the head height locked.</summary>
|
||||||
|
public const string Lock = "HEAD HEIGHT LOCK";
|
||||||
|
|
||||||
|
/// <summary>The comment after the last motion made with the head height locked.</summary>
|
||||||
|
public const string Unlock = "HEAD HEIGHT UNLOCK";
|
||||||
|
|
||||||
|
public static bool IsLock(ICode code) => code is Comment { Value: Lock };
|
||||||
|
|
||||||
|
public static bool IsUnlock(ICode code) => code is Comment { Value: Unlock };
|
||||||
|
|
||||||
|
/// <summary>Whether <paramref name="program"/>'s own codes hold a lock marker.</summary>
|
||||||
|
public static bool IsMarked(Program program)
|
||||||
|
{
|
||||||
|
if (program?.Codes == null)
|
||||||
|
return false;
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
if (IsLock(code))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For each of <paramref name="codes"/>, whether the head height is locked while it runs: the
|
||||||
|
/// codes after a lock marker up to its unlock marker. Markers must alternate, starting with a
|
||||||
|
/// lock and ending unlocked; anything else is a malformed program (<see cref="ArgumentException"/>),
|
||||||
|
/// since a post that lost track of the lock could cut off the sheet unlocked or never unlock.
|
||||||
|
/// </summary>
|
||||||
|
public static bool[] LockedMask(IReadOnlyList<ICode> codes)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(codes);
|
||||||
|
var mask = new bool[codes.Count];
|
||||||
|
var locked = false;
|
||||||
|
for (var i = 0; i < codes.Count; i++)
|
||||||
|
{
|
||||||
|
if (IsLock(codes[i]))
|
||||||
|
{
|
||||||
|
if (locked)
|
||||||
|
throw new ArgumentException("A head-height lock is set twice without an unlock.");
|
||||||
|
locked = true;
|
||||||
|
}
|
||||||
|
else if (IsUnlock(codes[i]))
|
||||||
|
{
|
||||||
|
if (!locked)
|
||||||
|
throw new ArgumentException("A head-height unlock has no lock before it.");
|
||||||
|
locked = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
mask[i] = locked;
|
||||||
|
}
|
||||||
|
if (locked)
|
||||||
|
throw new ArgumentException("A head-height lock is never released.");
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked
|
||||||
|
/// overcut needs the post's lock and unlock codes, and its markers must be well formed.
|
||||||
|
/// </summary>
|
||||||
|
public static void EnsurePostable(Nest nest, bool hasCodes, string postName) =>
|
||||||
|
PostMarkers.EnsurePostable(nest, hasCodes, IsMarked, program => LockedMask(program.Codes),
|
||||||
|
name => $"{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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A rapid the head must make raised because it passes over parts already cut, which may have
|
||||||
|
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing: a
|
||||||
|
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>, 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 the post's configuration before posting.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,5 +21,14 @@ namespace OpenNest.CNC
|
|||||||
/// leaves it out, cutting the rest of the contour as open pieces.
|
/// leaves it out, cutting the rest of the contour as open pieces.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Tab,
|
Tab,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The outside edge of a supplied blank that is only shown, never cut (for example a
|
||||||
|
/// purchased door whose instrument cutouts are the only cuts). It bounds material like
|
||||||
|
/// <see cref="Cut"/>, so nesting, containment, lead clearance and cut-offs all see the
|
||||||
|
/// blank, but it has no pierce, lead or motion in any emitted program. It must be one whole
|
||||||
|
/// closed contour enclosing every other contour.
|
||||||
|
/// </summary>
|
||||||
|
DisplayPerimeter,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
@@ -508,6 +480,21 @@ namespace OpenNest.CNC
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A shallow copy holding only the first <paramref name="count"/> codes: the same
|
||||||
|
/// instruction, variable and sub-program instances, mode and rotation.
|
||||||
|
/// </summary>
|
||||||
|
internal Program HeadCopy(int count)
|
||||||
|
{
|
||||||
|
var head = new Program() { mode = this.mode, Rotation = this.Rotation };
|
||||||
|
head.Codes.AddRange(Codes.GetRange(0, count));
|
||||||
|
foreach (var kvp in Variables)
|
||||||
|
head.Variables[kvp.Key] = kvp.Value;
|
||||||
|
foreach (var kvp in SubPrograms)
|
||||||
|
head.SubPrograms[kvp.Key] = kvp.Value;
|
||||||
|
return head;
|
||||||
|
}
|
||||||
|
|
||||||
public object Clone()
|
public object Clone()
|
||||||
{
|
{
|
||||||
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
|
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
|
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
|
||||||
{
|
{
|
||||||
|
// The head never travels to a display perimeter's tail.
|
||||||
|
pgm = DisplayPerimeterTail.WithoutTail(pgm);
|
||||||
// Draw the approach to the first rapid destination, or to the program
|
// Draw the approach to the first rapid destination, or to the program
|
||||||
// origin when it starts cutting without a rapid. Only an initial rapid
|
// origin when it starts cutting without a rapid. Only an initial rapid
|
||||||
// (or call arrival) is already represented by this approach; a later
|
// (or call arrival) is already represented by this approach; a later
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ namespace OpenNest.Converters
|
|||||||
if (string.Equals(name, SpecialLayers.Tab.Name, System.StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(name, SpecialLayers.Tab.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||||
return LayerType.Tab;
|
return LayerType.Tab;
|
||||||
|
|
||||||
|
if (string.Equals(name, SpecialLayers.DisplayPerimeter.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||||
|
return LayerType.DisplayPerimeter;
|
||||||
|
|
||||||
return LayerType.Cut;
|
return LayerType.Cut;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,9 @@ namespace OpenNest.Converters
|
|||||||
case LayerType.Tab:
|
case LayerType.Tab:
|
||||||
return SpecialLayers.Tab;
|
return SpecialLayers.Tab;
|
||||||
|
|
||||||
|
case LayerType.DisplayPerimeter:
|
||||||
|
return SpecialLayers.DisplayPerimeter;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return new Layer(layer.ToString());
|
return new Layer(layer.ToString());
|
||||||
}
|
}
|
||||||
|
|||||||
+366
-26
@@ -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,83 @@ 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>
|
||||||
|
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the
|
||||||
|
/// segment reaching that edge cuts with the head height locked; zero for none. Stock can
|
||||||
|
/// arrive wider than nominal, so only the origin-side edge is certain: the overcut cuts the
|
||||||
|
/// oversize off. It replaces <see cref="CutOffSettings.Overtravel"/> at the far edge and
|
||||||
|
/// applies only when the line runs to that edge (no limit on that side).
|
||||||
|
/// </summary>
|
||||||
|
public double HeadLockOvercut { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the segment reaching the far edge is cut from it toward the origin: it starts at
|
||||||
|
/// the nominal far edge, overcuts outward with the head locked, rapids back with the head
|
||||||
|
/// still locked, unlocks and cuts toward the origin. Ignored without a head-locked overcut,
|
||||||
|
/// since nothing else may start a cut at an edge whose position is unknown.
|
||||||
|
/// </summary>
|
||||||
|
public bool FarEdgeFirst { get; set; }
|
||||||
|
|
||||||
public CutOff(Vector position, CutOffAxis axis)
|
public CutOff(Vector position, CutOffAxis axis)
|
||||||
{
|
{
|
||||||
Position = position;
|
Position = position;
|
||||||
@@ -35,10 +116,128 @@ namespace OpenNest
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var segments = ComputeSegments(plate, settings, cache);
|
var segments = ComputeSegments(plate, settings, cache);
|
||||||
var program = BuildProgram(segments, settings);
|
var program = BuildProgram(segments, settings, LockedFarEdge(plate));
|
||||||
Drawing.Program = program;
|
Drawing.Program = program;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The regenerated program split into its cutting segments, one rapid-and-cut program
|
||||||
|
/// each in program order, so the plate can sequence every segment on its own. A rapid with
|
||||||
|
/// the head height locked returns along the overcut and stays in its segment.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<Program> SegmentPrograms()
|
||||||
|
{
|
||||||
|
var program = Drawing.Program;
|
||||||
|
var result = new List<Program>();
|
||||||
|
if (program == null)
|
||||||
|
return result;
|
||||||
|
Program current = null;
|
||||||
|
var locked = false;
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
{
|
||||||
|
if (code is RapidMove && !locked || current == null)
|
||||||
|
{
|
||||||
|
current = new Program(program.Mode);
|
||||||
|
result.Add(current);
|
||||||
|
}
|
||||||
|
if (HeadHeightLock.IsLock(code))
|
||||||
|
locked = true;
|
||||||
|
else if (HeadHeightLock.IsUnlock(code))
|
||||||
|
locked = false;
|
||||||
|
current.Codes.Add(code);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the far edge, along this axis on <paramref name="plate"/>, is the high
|
||||||
|
/// coordinate: the sheet runs from the origin toward positive coordinates on that axis.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool FarEdgeIsHigh(CutOffAxis axis, int quadrant) =>
|
||||||
|
axis == CutOffAxis.Vertical ? quadrant is 1 or 2 : quadrant is 1 or 4;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The nominal far edge this cut-off overcuts with the head locked, along its axis, or null
|
||||||
|
/// when it has no head-locked overcut or a limit stops it short of that edge.
|
||||||
|
/// </summary>
|
||||||
|
private (double Edge, bool High)? LockedFarEdge(Plate plate)
|
||||||
|
{
|
||||||
|
if (!(HeadLockOvercut > 0) || !double.IsFinite(HeadLockOvercut))
|
||||||
|
return null;
|
||||||
|
var bounds = plate.BoundingBox(includeParts: false);
|
||||||
|
var high = FarEdgeIsHigh(Axis, plate.Quadrant);
|
||||||
|
if ((high ? EndLimit : StartLimit) != null)
|
||||||
|
return null;
|
||||||
|
var vertical = Axis == CutOffAxis.Vertical;
|
||||||
|
return (high ? (vertical ? bounds.Top : bounds.Right) : (vertical ? bounds.Bottom : bounds.Left), high);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The same segment cut the other way at the far edge: a head-locked segment cut toward the
|
||||||
|
/// far edge becomes one started there, and back; null when <paramref name="segment"/> is not
|
||||||
|
/// one of the two forms <see cref="Regenerate"/> builds for the segment reaching that edge.
|
||||||
|
/// </summary>
|
||||||
|
public static Program ReverseAtFarEdge(Program segment)
|
||||||
|
{
|
||||||
|
if (segment?.Codes == null || segment.Mode != Mode.Absolute)
|
||||||
|
return null;
|
||||||
|
var codes = segment.Codes;
|
||||||
|
var result = new Program(segment.Mode);
|
||||||
|
// Toward the far edge: rapid near, cut to the edge, lock, overcut, unlock.
|
||||||
|
if (codes.Count == 5 && codes[0] is RapidMove { Suppressed: false } near
|
||||||
|
&& codes[1] is LinearMove { Suppressed: false, Layer: LayerType.Cut } toEdge
|
||||||
|
&& HeadHeightLock.IsLock(codes[2])
|
||||||
|
&& codes[3] is LinearMove { Suppressed: false, Layer: LayerType.Cut } overcut
|
||||||
|
&& HeadHeightLock.IsUnlock(codes[4]))
|
||||||
|
{
|
||||||
|
result.Codes.Add(new RapidMove(toEdge.EndPoint));
|
||||||
|
result.Codes.Add(new Comment(HeadHeightLock.Lock));
|
||||||
|
result.Codes.Add(new LinearMove(overcut.EndPoint));
|
||||||
|
result.Codes.Add(new RapidMove(toEdge.EndPoint));
|
||||||
|
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
|
||||||
|
result.Codes.Add(new LinearMove(near.EndPoint));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// From the far edge: rapid to the edge, lock, overcut, rapid back, unlock, cut away.
|
||||||
|
if (codes.Count == 6 && codes[0] is RapidMove { Suppressed: false } edge
|
||||||
|
&& HeadHeightLock.IsLock(codes[1])
|
||||||
|
&& codes[2] is LinearMove { Suppressed: false, Layer: LayerType.Cut } outward
|
||||||
|
&& codes[3] is RapidMove { Suppressed: false } back && back.EndPoint == edge.EndPoint
|
||||||
|
&& HeadHeightLock.IsUnlock(codes[4])
|
||||||
|
&& codes[5] is LinearMove { Suppressed: false, Layer: LayerType.Cut } away)
|
||||||
|
{
|
||||||
|
result.Codes.Add(new RapidMove(away.EndPoint));
|
||||||
|
result.Codes.Add(new LinearMove(edge.EndPoint));
|
||||||
|
result.Codes.Add(new Comment(HeadHeightLock.Lock));
|
||||||
|
result.Codes.Add(new LinearMove(outward.EndPoint));
|
||||||
|
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Installs Plan Cutting's choice for the far-edge segment: <paramref name="segment"/> (one
|
||||||
|
/// of this cut-off's placed parts) takes <paramref name="program"/>, the same segment cut the
|
||||||
|
/// other way, and <see cref="FarEdgeFirst"/> and the whole-line program follow it, so
|
||||||
|
/// regeneration keeps the direction. The caller has checked that the program is that flip.
|
||||||
|
/// </summary>
|
||||||
|
internal void InstallFarEdgeSegment(Part segment, Program program)
|
||||||
|
{
|
||||||
|
var codes = new List<ICode>();
|
||||||
|
foreach (var part in SegmentPrograms())
|
||||||
|
codes.AddRange((ReverseAtFarEdge(part) != null ? program : part).Codes);
|
||||||
|
var whole = new Program(Drawing.Program?.Mode ?? Mode.Absolute);
|
||||||
|
whole.Codes.AddRange(codes);
|
||||||
|
segment.ReplaceCutOffProgram(program);
|
||||||
|
FarEdgeFirst = StartsAtFarEdge(program);
|
||||||
|
Drawing.Program = whole;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether <paramref name="segment"/> is the far-edge segment cut from the far edge.</summary>
|
||||||
|
public static bool StartsAtFarEdge(Program segment) =>
|
||||||
|
segment?.Codes is { Count: 6 } codes && codes[0] is RapidMove && HeadHeightLock.IsLock(codes[1]);
|
||||||
|
|
||||||
/// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary>
|
/// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary>
|
||||||
internal (double Length, int SegmentCount) MeasureSegments(
|
internal (double Length, int SegmentCount) MeasureSegments(
|
||||||
Plate plate,
|
Plate plate,
|
||||||
@@ -54,38 +253,106 @@ 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;
|
||||||
var cutting = false;
|
var cutting = false;
|
||||||
|
var locked = false;
|
||||||
foreach (var code in part.Program.Codes)
|
foreach (var code in part.Program.Codes)
|
||||||
{
|
{
|
||||||
|
if (HeadHeightLock.IsLock(code))
|
||||||
|
locked = true;
|
||||||
|
else if (HeadHeightLock.IsUnlock(code))
|
||||||
|
locked = false;
|
||||||
switch (code)
|
switch (code)
|
||||||
{
|
{
|
||||||
case RapidMove rapid:
|
case RapidMove rapid:
|
||||||
if (cutting)
|
// A second pierce leaves an uncut gap; a head-locked rapid returns along
|
||||||
return false; // A second pierce leaves an uncut gap.
|
// the overcut to where the cut toward the origin begins.
|
||||||
|
if (cutting && !locked)
|
||||||
|
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);
|
||||||
@@ -95,10 +362,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;
|
||||||
@@ -119,6 +386,9 @@ 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.
|
||||||
|
var lockedFarEdge = LockedFarEdge(plate);
|
||||||
|
var overtravel = lockedFarEdge is { High: true } ? 0 : settings.Overtravel;
|
||||||
|
|
||||||
double lineStart,
|
double lineStart,
|
||||||
lineEnd,
|
lineEnd,
|
||||||
@@ -128,15 +398,22 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
cutPosition = Position.X;
|
cutPosition = Position.X;
|
||||||
lineStart = StartLimit ?? bounds.Y;
|
lineStart = StartLimit ?? bounds.Y;
|
||||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel);
|
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + overtravel);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
cutPosition = Position.Y;
|
cutPosition = Position.Y;
|
||||||
lineStart = StartLimit ?? bounds.X;
|
lineStart = StartLimit ?? bounds.X;
|
||||||
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.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)
|
||||||
@@ -169,6 +446,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)>();
|
||||||
@@ -181,27 +460,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(
|
||||||
@@ -436,7 +759,8 @@ namespace OpenNest
|
|||||||
|
|
||||||
private Program BuildProgram(
|
private Program BuildProgram(
|
||||||
List<(double Start, double End)> segments,
|
List<(double Start, double End)> segments,
|
||||||
CutOffSettings settings
|
CutOffSettings settings,
|
||||||
|
(double Edge, bool High)? farEdge
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var program = new Program();
|
var program = new Program();
|
||||||
@@ -453,6 +777,22 @@ namespace OpenNest
|
|||||||
|
|
||||||
foreach (var seg in segments)
|
foreach (var seg in segments)
|
||||||
{
|
{
|
||||||
|
if (farEdge is var (edge, high) && (high ? seg.End : seg.Start) == edge)
|
||||||
|
{
|
||||||
|
// The segment reaching the far edge overcuts it with the head height locked,
|
||||||
|
// whichever way the rest of the line runs.
|
||||||
|
var near = MakePoint(cutPos, high ? seg.Start : seg.End);
|
||||||
|
var atEdge = MakePoint(cutPos, edge);
|
||||||
|
var beyond = MakePoint(cutPos, high ? edge + HeadLockOvercut : edge - HeadLockOvercut);
|
||||||
|
var towardEdge = new Program();
|
||||||
|
towardEdge.Codes.Add(new RapidMove(near));
|
||||||
|
towardEdge.Codes.Add(new LinearMove(atEdge));
|
||||||
|
towardEdge.Codes.Add(new Comment(HeadHeightLock.Lock));
|
||||||
|
towardEdge.Codes.Add(new LinearMove(beyond));
|
||||||
|
towardEdge.Codes.Add(new Comment(HeadHeightLock.Unlock));
|
||||||
|
program.Codes.AddRange((FarEdgeFirst ? ReverseAtFarEdge(towardEdge) : towardEdge).Codes);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
var (from, to) = toward ? (seg.End, seg.Start) : (seg.Start, seg.End);
|
var (from, to) = toward ? (seg.End, seg.Start) : (seg.Start, seg.End);
|
||||||
program.Codes.Add(new RapidMove(MakePoint(cutPos, from)));
|
program.Codes.Add(new RapidMove(MakePoint(cutPos, from)));
|
||||||
program.Codes.Add(new LinearMove(MakePoint(cutPos, to)));
|
program.Codes.Add(new LinearMove(MakePoint(cutPos, to)));
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One placed cutting segment of a cut-off: the part that cuts it, where its cut runs along
|
||||||
|
/// the cut-off's axis, and the span of the nominal line it stands for, from the end of the
|
||||||
|
/// segment before it (or the line's start) to the start of the one after it (or the line's
|
||||||
|
/// end), stopping short of a <see cref="CutOff.Gaps">gap</see> between them. The parts the
|
||||||
|
/// nominal line passes through lie in the gaps between segments, so each is inside the span
|
||||||
|
/// of the segment on either side of it; a segment is cut before exactly those parts.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A cut-off's trimmed segments are materialized as one part each, so every segment has its
|
||||||
|
/// own place in the cut sequence. This finds those parts and the span each stands for.
|
||||||
|
/// </summary>
|
||||||
|
public static class CutOffSegments
|
||||||
|
{
|
||||||
|
/// <summary>The definition's placed segment parts in ascending order along its axis.</summary>
|
||||||
|
public static IReadOnlyList<CutOffSegment> Of(Plate plate, CutOff definition) =>
|
||||||
|
Of(definition, plate.Parts, plate.BoundingBox(includeParts: false));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The definition's segment parts among <paramref name="parts"/> (those placed from its
|
||||||
|
/// drawing) in ascending order along its axis, with spans measured against the nominal
|
||||||
|
/// line's limits on <paramref name="sheet"/>. A part whose cut cannot be measured stands
|
||||||
|
/// for the whole line.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<CutOffSegment> Of(CutOff definition, IEnumerable<Part> parts, Box sheet)
|
||||||
|
{
|
||||||
|
var vertical = definition.Axis == CutOffAxis.Vertical;
|
||||||
|
var lineStart = definition.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left);
|
||||||
|
var lineEnd = definition.EndLimit ?? (vertical ? sheet.Top : sheet.Right);
|
||||||
|
var low = System.Math.Min(lineStart, lineEnd);
|
||||||
|
var high = System.Math.Max(lineStart, lineEnd);
|
||||||
|
var measured = new List<(Part Part, double Start, double End)>();
|
||||||
|
var whole = new List<CutOffSegment>();
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(part.BaseDrawing, definition.Drawing))
|
||||||
|
continue;
|
||||||
|
if (Extent(part, vertical) is { } extent)
|
||||||
|
measured.Add((part, extent.Start, extent.End));
|
||||||
|
else
|
||||||
|
whole.Add(new CutOffSegment(part, low, high, low, high));
|
||||||
|
}
|
||||||
|
measured.Sort((a, b) => a.Start != b.Start ? a.Start.CompareTo(b.Start) : a.End.CompareTo(b.End));
|
||||||
|
var result = new List<CutOffSegment>(measured.Count + whole.Count);
|
||||||
|
for (var i = 0; i < measured.Count; i++)
|
||||||
|
{
|
||||||
|
var (part, start, end) = measured[i];
|
||||||
|
var spanStart = i == 0 ? System.Math.Min(low, start) : measured[i - 1].End;
|
||||||
|
var spanEnd = i == measured.Count - 1 ? System.Math.Max(high, end) : measured[i + 1].Start;
|
||||||
|
// 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.AddRange(whole);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The extent along the cut-off's axis of the part's cutting moves, or null when the
|
||||||
|
/// part cuts nothing or its program is not the absolute rapid-and-cut form cut-offs use.
|
||||||
|
/// </summary>
|
||||||
|
public static (double Start, double End)? Extent(Part part, bool vertical)
|
||||||
|
{
|
||||||
|
if (part?.Program == null || part.Program.Mode != Mode.Absolute)
|
||||||
|
return null;
|
||||||
|
double? min = null, max = null;
|
||||||
|
Vector? position = null;
|
||||||
|
foreach (var code in part.Program.Codes)
|
||||||
|
{
|
||||||
|
switch (code)
|
||||||
|
{
|
||||||
|
case RapidMove rapid:
|
||||||
|
position = rapid.EndPoint + part.Location;
|
||||||
|
break;
|
||||||
|
case Motion motion when position != null:
|
||||||
|
var end = motion.EndPoint + part.Location;
|
||||||
|
foreach (var point in new[] { position.Value, end })
|
||||||
|
{
|
||||||
|
var along = vertical ? point.Y : point.X;
|
||||||
|
min = min == null ? along : System.Math.Min(min.Value, along);
|
||||||
|
max = max == null ? along : System.Math.Max(max.Value, along);
|
||||||
|
}
|
||||||
|
position = end;
|
||||||
|
break;
|
||||||
|
case Motion:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return min == null ? null : (min.Value, max.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,5 +12,20 @@ namespace OpenNest
|
|||||||
public double Overtravel { get; set; }
|
public double Overtravel { get; set; }
|
||||||
public double MinSegmentLength { get; set; } = 0.05;
|
public double MinSegmentLength { get; set; } = 0.05;
|
||||||
public CutDirection CutDirection { get; set; } = CutDirection.AwayFromOrigin;
|
public CutDirection CutDirection { get; set; } = CutDirection.AwayFromOrigin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether new cut-offs lock the head height and overcut past the nominal far sheet edge
|
||||||
|
/// by <see cref="HeadLockOvercut"/> (see <see cref="CutOff.HeadLockOvercut"/>). Off until
|
||||||
|
/// the machine's post has head-height lock codes.
|
||||||
|
/// </summary>
|
||||||
|
public bool LockHeadHeight { get; set; }
|
||||||
|
|
||||||
|
/// <summary>How far past the nominal far edge a head-locked cut-off cuts, in model units.</summary>
|
||||||
|
public double HeadLockOvercut { get; set; } = 1.5;
|
||||||
|
|
||||||
|
/// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary>
|
||||||
|
public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,15 +28,26 @@ public static class ClampCutDiagnostics
|
|||||||
var cut = plate.CutOffs[index];
|
var cut = plate.CutOffs[index];
|
||||||
if (cut == null || !Enum.IsDefined(cut.Axis))
|
if (cut == null || !Enum.IsDefined(cut.Axis))
|
||||||
throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate));
|
throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate));
|
||||||
var start = cut.Axis == CutOffAxis.Vertical
|
// A head-locked overcut runs on past the far edge, where an off-sheet clamp can be.
|
||||||
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom)
|
var overcut = cut.HeadLockOvercut > 0 && double.IsFinite(cut.HeadLockOvercut) ? cut.HeadLockOvercut : 0;
|
||||||
: new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y);
|
var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
|
||||||
var end = cut.Axis == CutOffAxis.Vertical
|
var low = farHigh ? 0 : overcut;
|
||||||
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top)
|
var high = farHigh ? overcut : 0;
|
||||||
: new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y);
|
var vertical = cut.Axis == CutOffAxis.Vertical;
|
||||||
|
var from = cut.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left) - low;
|
||||||
|
var to = cut.EndLimit ?? (vertical ? sheet.Top : sheet.Right) + high;
|
||||||
|
// A clamp wholly inside a gap meets no cut.
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
|
|
||||||
namespace OpenNest.Diagnostics;
|
namespace OpenNest.Diagnostics;
|
||||||
@@ -12,20 +13,24 @@ 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)[] 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 OverlapGeometryStamp(Plate plate)
|
private OverlapGeometryStamp(Plate plate)
|
||||||
{
|
{
|
||||||
this.plate = plate;
|
this.plate = plate;
|
||||||
sheet = SheetState(plate);
|
sheet = SheetState(plate);
|
||||||
|
partSpacing = Bits(plate.PartSpacing);
|
||||||
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?)[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,11 +41,12 @@ 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?) 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,
|
||||||
|
cut.Gaps, Bits(cut.HeadLockOvercut));
|
||||||
|
|
||||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||||
|
|
||||||
@@ -73,9 +79,13 @@ public sealed class OverlapGeometryStamp
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal bool SameSpacing(Plate current) =>
|
||||||
|
ReferenceEquals(plate, current) && partSpacing == Bits(current.PartSpacing);
|
||||||
|
|
||||||
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)
|
||||||
|
|| !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,17 @@ 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}";
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +50,21 @@ 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();
|
||||||
|
|
||||||
|
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,31 @@ 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>();
|
||||||
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;
|
||||||
|
/// <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; ")
|
||||||
+ $"{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 +55,50 @@ 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");
|
||||||
|
return string.Join(", ", words);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
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 +121,13 @@ 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;
|
||||||
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 +181,12 @@ 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();
|
||||||
|
if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0 && DisplaySpacingPairs.Count == 0)
|
||||||
ClearDisplayPairs();
|
ClearDisplayPairs();
|
||||||
else
|
else
|
||||||
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
|
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
|
||||||
@@ -133,6 +196,7 @@ public sealed class OverlapReportState
|
|||||||
{
|
{
|
||||||
DisplayPairs = Array.Empty<PlateOverlapPair>();
|
DisplayPairs = Array.Empty<PlateOverlapPair>();
|
||||||
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
||||||
|
DisplaySpacingPairs = Array.Empty<PlateSpacingPair>();
|
||||||
displayStamp = null;
|
displayStamp = null;
|
||||||
observedDisplayStamp = null;
|
observedDisplayStamp = null;
|
||||||
}
|
}
|
||||||
@@ -148,7 +212,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;
|
||||||
|
|||||||
@@ -15,19 +15,24 @@ namespace OpenNest.Diagnostics;
|
|||||||
/// Nest's layout check finds them: each part's clean nesting material
|
/// Nest's layout check finds them: each part's clean nesting material
|
||||||
/// (<see cref="NestingMaterial.ForNesting"/>, leads excluded) outlined by <see cref="SpacingOutline"/>,
|
/// (<see cref="NestingMaterial.ForNesting"/>, leads excluded) outlined by <see cref="SpacingOutline"/>,
|
||||||
/// the leftmost of a pair inflated by <see cref="SpacingOutline.InflationFor"/>. That outline
|
/// the leftmost of a pair inflated by <see cref="SpacingOutline.InflationFor"/>. That outline
|
||||||
/// circumscribes arcs by up to its chord tolerance, more than the slack, and engines add a margin
|
/// circumscribes arcs by up to its chord tolerance, more than the slack, and interactive fills
|
||||||
/// for it; interactive fills place native arcs exactly at the spacing. So each candidate is
|
/// place native arcs exactly at the spacing. So each candidate is confirmed on
|
||||||
/// confirmed on the native contours flattened at <see cref="FineTolerance"/> and reported only when
|
/// <see cref="NativeContours"/>, as the layout check confirms it, and reported only when that gap
|
||||||
/// that gap is short of the spacing by more than the slack. Zero spacing enforces nothing, so
|
/// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays
|
||||||
/// 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. The sheet edge and clamps are not spacing-checked here. The pre-post review and the
|
||||||
|
/// desktop overlap label share this scan, so they cannot disagree.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class PartSpacingReview
|
internal static class PartSpacingReview
|
||||||
{
|
{
|
||||||
/// <summary>Chord tolerance of the confirming measurement. Circumscribed on both sides, it
|
/// <summary>One part to scan: <see cref="Id"/> is its position in the overlap check's input.
|
||||||
/// understates a gap by at most twice this, a tenth of <see cref="SpacingOutline.SpacingSlack"/>.</summary>
|
/// The profile loads inside the scan's error boundary; null means no material.</summary>
|
||||||
internal const double FineTolerance = SpacingOutline.SpacingSlack / 20;
|
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,
|
||||||
@@ -40,39 +45,89 @@ 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, 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],
|
||||||
|
$"'{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, 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,
|
||||||
|
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 FineRings[parts.Count];
|
var fine = new NativeContours[parts.Count];
|
||||||
var raw = new SpacingOutline[parts.Count];
|
var raw = new SpacingOutline[parts.Count];
|
||||||
var inflated = new SpacingOutline[parts.Count];
|
var inflated = new SpacingOutline[parts.Count];
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,33 +147,25 @@ 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;
|
||||||
Vector marker;
|
Vector marker;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
fine[i] ??= FineRings.Of(shapes[i], parts[i].Location);
|
fine[i] ??= NativeContours.Of(shapes[i], parts[i].Location);
|
||||||
fine[j] ??= FineRings.Of(shapes[j], parts[j].Location);
|
fine[j] ??= NativeContours.Of(shapes[j], parts[j].Location);
|
||||||
gap = Gap(fine[i], fine[j], spacing, out marker);
|
gap = fine[i].GapTo(fine[j], spacing, out marker);
|
||||||
}
|
}
|
||||||
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))
|
if (gap < spacing - SpacingOutline.SpacingSlack)
|
||||||
continue;
|
tooClose(first, second, gap, marker);
|
||||||
var (first, second) = partNumbers[i] < partNumbers[j] ? (i, j) : (j, i);
|
|
||||||
findings.Add(new(PostVerificationKind.PartSpacing, plateNumber, partNumbers[first],
|
|
||||||
partNumbers[second],
|
|
||||||
$"'{Name(parts[first])}' and '{Name(parts[second])}' are {gap:F3} apart, closer than the "
|
|
||||||
+ $"plate's part spacing ({spacing:F3}).")
|
|
||||||
{ Location = marker });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,86 +182,37 @@ 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>
|
||||||
|
private 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Closest boundary approach between two parts, including a part inside the other's
|
|
||||||
/// cutout; <paramref name="marker"/> is the middle of that gap. Only edges within
|
|
||||||
/// <paramref name="reach"/> of the other part's bounds can be closer than it, so the rest are
|
|
||||||
/// skipped. Infinity when no edges are that near.</summary>
|
|
||||||
private static double Gap(FineRings a, FineRings b, double reach, out Vector marker)
|
|
||||||
{
|
|
||||||
var best = double.PositiveInfinity;
|
|
||||||
var middle = a.Bounds.Center;
|
|
||||||
var perimeterA = Near(a.Perimeter, b.Bounds, reach);
|
|
||||||
var perimeterB = Near(b.Perimeter, a.Bounds, reach);
|
|
||||||
Measure(perimeterA, perimeterB);
|
|
||||||
foreach (var hole in a.Holes)
|
|
||||||
Measure(Near(hole, b.Bounds, reach), perimeterB);
|
|
||||||
foreach (var hole in b.Holes)
|
|
||||||
Measure(perimeterA, Near(hole, a.Bounds, reach));
|
|
||||||
marker = middle;
|
|
||||||
return best;
|
|
||||||
|
|
||||||
void Measure(List<Line> ringA, List<Line> ringB)
|
|
||||||
{
|
|
||||||
if (ringA.Count == 0 || ringB.Count == 0)
|
|
||||||
return;
|
|
||||||
var distance = Clearance.ClosestPoints(ringA, ringB, out var onA, out var onB);
|
|
||||||
if (distance < best)
|
|
||||||
{
|
|
||||||
best = distance;
|
|
||||||
middle = (onA + onB) * 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Line> Near(List<Line> lines, Box other, double reach) =>
|
|
||||||
lines.Where(line =>
|
|
||||||
System.Math.Min(line.StartPoint.X, line.EndPoint.X) <= other.Right + reach
|
|
||||||
&& System.Math.Max(line.StartPoint.X, line.EndPoint.X) >= other.Left - reach
|
|
||||||
&& System.Math.Min(line.StartPoint.Y, line.EndPoint.Y) <= other.Top + reach
|
|
||||||
&& System.Math.Max(line.StartPoint.Y, line.EndPoint.Y) >= other.Bottom - reach).ToList();
|
|
||||||
|
|
||||||
/// <summary>A part's native contours in world space, flattened at <see cref="FineTolerance"/>.
|
|
||||||
/// Circumscribed, so the measured gap never exceeds the true one by more than rounding.</summary>
|
|
||||||
private sealed class FineRings
|
|
||||||
{
|
|
||||||
public List<Line> Perimeter { get; private init; }
|
|
||||||
public List<List<Line>> Holes { get; private init; }
|
|
||||||
public Box Bounds { get; private init; }
|
|
||||||
|
|
||||||
public static FineRings Of(ShapeProfile profile, Vector location)
|
|
||||||
{
|
|
||||||
var perimeter = World(profile.Perimeter, location, out var bounds);
|
|
||||||
return new FineRings
|
|
||||||
{
|
|
||||||
Perimeter = perimeter.ToLines(),
|
|
||||||
Holes = profile.Cutouts.Select(cutout => World(cutout, location, out _).ToLines()).ToList(),
|
|
||||||
Bounds = bounds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Polygon World(Shape shape, Vector location, out Box bounds)
|
|
||||||
{
|
|
||||||
var polygon = ClipperBridge.Flatten(shape, FineTolerance, true);
|
|
||||||
polygon.Offset(location);
|
|
||||||
polygon.UpdateBounds();
|
|
||||||
bounds = polygon.BoundingBox;
|
|
||||||
return polygon;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
|
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
|
||||||
|
|
||||||
private static string Name(Part part) => part.BaseDrawing.Name;
|
private static string Name(Part part) => part.BaseDrawing.Name;
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Support;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-post review of part support (docs/support-tabs.md). Every part whose cutting parameters
|
||||||
|
/// ask for support tabs and that needs one (<see cref="SupportTabSettings.Analyze"/>: its plate's
|
||||||
|
/// support bed cannot hold it level or, without a bed, its width is in the no-bed range) must be
|
||||||
|
/// cut with a tab that keeps it level, however its program was made (planned, placed by hand or
|
||||||
|
/// locked); without a bed any tab will do. The tab is the gap the outside cut leaves between its end and its start. Reports
|
||||||
|
/// only; a part whose material or program cannot be read is reported as not checked.
|
||||||
|
/// </summary>
|
||||||
|
internal static class PartSupportReview
|
||||||
|
{
|
||||||
|
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
var bed = plate.SupportBed;
|
||||||
|
for (var index = 0; index < plate.Parts.Count; index++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var part = plate.Parts[index];
|
||||||
|
var parameters = part?.CuttingParameters ?? plate.CuttingParameters;
|
||||||
|
if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff || parameters?.SupportTabs != true
|
||||||
|
|| (bed == null && !parameters.HasNoBedTabRange()))
|
||||||
|
continue;
|
||||||
|
var partNumber = index + 1;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var support = SupportTabSettings.Analyze(parameters, bed, plate.Quadrant, SupportMaterial.FromPart(part));
|
||||||
|
if (support == null)
|
||||||
|
continue;
|
||||||
|
if (!support.IsComplete)
|
||||||
|
{
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null,
|
||||||
|
"Support check: " + support.Reason));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (support.IsSupported)
|
||||||
|
continue;
|
||||||
|
var tab = OutsideTab(ExecutionMotionReader.Read(part.Program, part.Location, null, token));
|
||||||
|
if (tab is { } point && support.IsSteadiedByTabAt(point))
|
||||||
|
continue;
|
||||||
|
findings.Add(new(PostVerificationKind.PartSupport, plateNumber, partNumber, null,
|
||||||
|
Message(support, tab))
|
||||||
|
{ Location = support.CenterOfGravity });
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException
|
||||||
|
or NotSupportedException or ArithmeticException)
|
||||||
|
{
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null,
|
||||||
|
"Support check: " + exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The middle of the gap the outside cut leaves open, or null when it closes: the outside cut
|
||||||
|
/// is the chain of cutting motions with the largest extent.
|
||||||
|
/// </summary>
|
||||||
|
internal static Vector? OutsideTab(OwnedExecution execution)
|
||||||
|
{
|
||||||
|
Vector? start = null, end = null, bestStart = null, bestEnd = null;
|
||||||
|
var minX = double.PositiveInfinity; var minY = double.PositiveInfinity;
|
||||||
|
var maxX = double.NegativeInfinity; var maxY = double.NegativeInfinity;
|
||||||
|
var bestArea = -1.0;
|
||||||
|
foreach (var motion in execution.Motions.Append(null))
|
||||||
|
{
|
||||||
|
if (motion is { Rapid: false, Layer: LayerType.Cut or LayerType.Display })
|
||||||
|
{
|
||||||
|
start ??= motion.Start ?? motion.End;
|
||||||
|
end = motion.End;
|
||||||
|
foreach (var p in new[] { motion.Start ?? motion.End, motion.End, motion.Curve?.Midpoint ?? motion.End })
|
||||||
|
{
|
||||||
|
minX = System.Math.Min(minX, p.X); minY = System.Math.Min(minY, p.Y);
|
||||||
|
maxX = System.Math.Max(maxX, p.X); maxY = System.Math.Max(maxY, p.Y);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Anything else ends a chain: a rapid, a lead, a scribe or a break cut.
|
||||||
|
if (start != null && (maxX - minX) * (maxY - minY) > bestArea)
|
||||||
|
{
|
||||||
|
bestArea = (maxX - minX) * (maxY - minY);
|
||||||
|
bestStart = start;
|
||||||
|
bestEnd = end;
|
||||||
|
}
|
||||||
|
start = end = null;
|
||||||
|
minX = minY = double.PositiveInfinity;
|
||||||
|
maxX = maxY = double.NegativeInfinity;
|
||||||
|
}
|
||||||
|
if (bestStart is not { } first || bestEnd is not { } last
|
||||||
|
|| first.DistanceTo(last) <= PostVerificationGeometry.Epsilon)
|
||||||
|
return null;
|
||||||
|
return (first + last) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Message(PartSupport support, Vector? tab)
|
||||||
|
{
|
||||||
|
// Without a bed, or for a part its tab alone holds, any tab steadies the part, so the only
|
||||||
|
// failure is no tab at all.
|
||||||
|
if (!support.HasBed || support.IsHeldByTab)
|
||||||
|
return (support.HasBed
|
||||||
|
? FormattableString.Invariant(
|
||||||
|
$"The part is {support.Length:0.####} long, short enough for its tab alone to hold, ")
|
||||||
|
: FormattableString.Invariant(
|
||||||
|
$"The part is {support.Width:0.####} wide, in the width range tabbed without a support bed, "))
|
||||||
|
+ "and it has no tab; it may tip once cut free."
|
||||||
|
+ (support.BestTabPoint is { } suggested
|
||||||
|
? FormattableString.Invariant($" Tab it near ({suggested.X:0.####}, {suggested.Y:0.####}).")
|
||||||
|
: "");
|
||||||
|
var held = support.Margin == double.NegativeInfinity
|
||||||
|
? "rests on no support contact"
|
||||||
|
: support.Margin < 0
|
||||||
|
? FormattableString.Invariant($"has its center of gravity {-support.Margin:0.####} outside its support contacts")
|
||||||
|
: FormattableString.Invariant($"has its center of gravity only {support.Margin:0.####} inside its support contacts");
|
||||||
|
var tabText = tab is { } point
|
||||||
|
? FormattableString.Invariant($"its tab at ({point.X:0.####}, {point.Y:0.####}) does not steady it")
|
||||||
|
: "it has no tab";
|
||||||
|
var best = support.BestTabPoint is { } bestPoint && support.TabCanSupport
|
||||||
|
? FormattableString.Invariant($" A tab near ({bestPoint.X:0.####}, {bestPoint.Y:0.####}) would.")
|
||||||
|
: " No single tab can steady it.";
|
||||||
|
return FormattableString.Invariant($"The part {held} (needs {support.RequiredMargin:0.####}) and {tabText}; ")
|
||||||
|
+ "it may tip once cut free." + best;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -33,7 +33,7 @@ public static class PlateOverlapAnalyzer
|
|||||||
var cutoffs = ClampCutDiagnostics.Find(plate);
|
var cutoffs = ClampCutDiagnostics.Find(plate);
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(),
|
return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(),
|
||||||
material.Clamps.ToList(), cutoffs.ToList());
|
material.Clamps.ToList(), cutoffs.ToList(), plate.PartSpacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -223,11 +223,16 @@ 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);
|
||||||
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -348,10 +353,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,16 @@ 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)
|
||||||
{
|
{
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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 +36,22 @@ 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>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 +113,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 +121,13 @@ 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>
|
/// <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 +169,14 @@ 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)
|
||||||
{
|
{
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
||||||
@@ -158,6 +185,9 @@ 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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,12 +138,16 @@ public static class PostVerificationAnalyzer
|
|||||||
if (part == null || !double.IsFinite(part.Rotation))
|
if (part == null || !double.IsFinite(part.Rotation))
|
||||||
throw new ArgumentException("Missing part or invalid rotation.");
|
throw new ArgumentException("Missing part or invalid rotation.");
|
||||||
var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions;
|
var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions;
|
||||||
|
// Executed reads stop at an applied program's display tail, so any display
|
||||||
|
// perimeter left is an unplanned blank whose outline a post would refuse.
|
||||||
|
if (moves.Any(move => move.Layer == LayerType.DisplayPerimeter))
|
||||||
|
Incomplete("This part has a display perimeter (a supplied blank's outline that is never cut) "
|
||||||
|
+ "but no cutting plan; posts refuse it. Plan cutting first (Plate > Plan Cutting..., or --plan-cutting) so only its cutouts are cut.");
|
||||||
if (expectsCuts && !moves.Any(move => !move.Rapid
|
if (expectsCuts && !moves.Any(move => !move.Rapid
|
||||||
&& 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)
|
||||||
{
|
{
|
||||||
@@ -219,6 +226,8 @@ 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 the support bed cannot hold level need a tab that keeps them level.
|
||||||
|
PartSupportReview.Add(plate, plateNumber, findings, cancellationToken);
|
||||||
}
|
}
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
return new PostVerificationReport(findings);
|
return new PostVerificationReport(findings);
|
||||||
|
|||||||
@@ -49,7 +49,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 +68,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);
|
||||||
@@ -78,7 +82,7 @@ internal static class PostVerificationGeometry
|
|||||||
|
|
||||||
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
|
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
|
||||||
// (including the empty extent's) fail this test too.
|
// (including the empty extent's) fail this test too.
|
||||||
private bool IsWellConditioned =>
|
internal bool IsWellConditioned =>
|
||||||
System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)),
|
System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)),
|
||||||
System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit;
|
System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit;
|
||||||
}
|
}
|
||||||
@@ -110,17 +114,56 @@ internal static class PostVerificationGeometry
|
|||||||
// If there are no contacts except possibly departure, all open-segment points
|
// If there are no contacts except possibly departure, all open-segment points
|
||||||
// have the same inside/outside status. A midpoint catches travel entirely inside
|
// have the same inside/outside status. A midpoint catches travel entirely inside
|
||||||
// and departure into the interior, without flagging start-only outward contact.
|
// and departure into the interior, without flagging start-only outward contact.
|
||||||
var midpoint = start + delta * 0.5;
|
return Inside(start + delta * 0.5, curves, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ray parity of <paramref name="point"/> against closed <paramref name="curves"/>, cast at
|
||||||
|
/// <see cref="RayY"/> so no vertex of them lies on the ray.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool Inside(Vector point, IReadOnlyList<Curve> curves, CancellationToken token)
|
||||||
|
{
|
||||||
|
var rayY = RayY(point, curves, token);
|
||||||
var inside = false;
|
var inside = false;
|
||||||
foreach (var curve in curves)
|
foreach (var curve in curves)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (curve.CrossesRay(midpoint))
|
if (curve.CrossesRay(point, rayY))
|
||||||
inside = !inside;
|
inside = !inside;
|
||||||
}
|
}
|
||||||
return inside;
|
return inside;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The height a parity ray from <paramref name="point"/> runs at: just above it, by about
|
||||||
|
/// <see cref="RaySeamTolerance"/>, and clear of every vertex height of <paramref name="curves"/>.
|
||||||
|
/// The two copies of a seam vertex can differ in the last bits; a ray running between them
|
||||||
|
/// counts one edge and not the other, so a point that happens to sit a tolerance below another
|
||||||
|
/// contour's vertex (text baselines a micro-inch apart) would read as inside it.
|
||||||
|
/// </summary>
|
||||||
|
internal static double RayY(Vector point, IReadOnlyList<Curve> curves, CancellationToken token)
|
||||||
|
{
|
||||||
|
const int steps = 8;
|
||||||
|
const double band = RaySeamTolerance / (4 * steps);
|
||||||
|
for (var step = 0; step < steps; step++)
|
||||||
|
{
|
||||||
|
var rayY = point.Y + RaySeamTolerance * (1 + (double)step / steps);
|
||||||
|
var clear = true;
|
||||||
|
foreach (var curve in curves)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (curve.HasVertexNear(rayY, band))
|
||||||
|
{
|
||||||
|
clear = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clear)
|
||||||
|
return rayY;
|
||||||
|
}
|
||||||
|
return point.Y + RaySeamTolerance;
|
||||||
|
}
|
||||||
|
|
||||||
internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end,
|
internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end,
|
||||||
IReadOnlyList<Curve> curves, CancellationToken token)
|
IReadOnlyList<Curve> curves, CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -279,6 +322,18 @@ internal static class PostVerificationGeometry
|
|||||||
/// <summary>Exact native distance from <paramref name="point"/> to this finite curve.</summary>
|
/// <summary>Exact native distance from <paramref name="point"/> to this finite curve.</summary>
|
||||||
internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point);
|
internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point);
|
||||||
|
|
||||||
|
/// <summary>The native point of this finite curve closest to <paramref name="point"/>.</summary>
|
||||||
|
internal Vector ClosestPoint(Vector point) => nativeEntity.Value.ClosestPointTo(point);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This curve from its start up to <paramref name="point"/>, which must be one of its own
|
||||||
|
/// interior points (see <see cref="ClosestPoint"/>); an arc keeps its center and radius.
|
||||||
|
/// </summary>
|
||||||
|
internal Curve Until(Vector point) => Center is { } center
|
||||||
|
? new Curve(Start, point, center, Radius,
|
||||||
|
System.Math.Sign(Sweep) * Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)))
|
||||||
|
: new Curve(Start, point, null, 0, 0);
|
||||||
|
|
||||||
internal bool Contains(Vector point) => Contains(point, Epsilon);
|
internal bool Contains(Vector point) => Contains(point, Epsilon);
|
||||||
|
|
||||||
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
|
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
|
||||||
@@ -296,33 +351,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
|
||||||
@@ -331,24 +406,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;
|
||||||
|
|
||||||
@@ -357,6 +433,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.
|
||||||
@@ -364,16 +483,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -399,11 +537,11 @@ internal static class PostVerificationGeometry
|
|||||||
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,
|
||||||
@@ -455,7 +593,20 @@ internal static class PostVerificationGeometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal bool CrossesRay(Vector point)
|
/// <summary>
|
||||||
|
/// Whether an endpoint, or the top or bottom of an arc's circle, lies within
|
||||||
|
/// <paramref name="band"/> of height <paramref name="y"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal bool HasVertexNear(double y, double band) =>
|
||||||
|
System.Math.Abs(Start.Y - y) <= band || System.Math.Abs(End.Y - y) <= band
|
||||||
|
|| Center is { } center && (System.Math.Abs(center.Y + Radius - y) <= band
|
||||||
|
|| System.Math.Abs(center.Y - Radius - y) <= band);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a ray running in +X from <paramref name="point"/>, at height
|
||||||
|
/// <paramref name="rayY"/> (see <see cref="RayY"/>), crosses this curve.
|
||||||
|
/// </summary>
|
||||||
|
internal bool CrossesRay(Vector point, double rayY)
|
||||||
{
|
{
|
||||||
// Ray parity needs both edges meeting at a shared vertex to agree which side of the ray
|
// Ray parity needs both edges meeting at a shared vertex to agree which side of the ray
|
||||||
// that vertex is on. Rings are only nominally closed: capture admits closure seams and
|
// that vertex is on. Rings are only nominally closed: capture admits closure seams and
|
||||||
@@ -466,7 +617,6 @@ internal static class PostVerificationGeometry
|
|||||||
// edges; real features (notches, tabs, arc spans) are far larger than the tolerance and
|
// edges; real features (notches, tabs, arc spans) are far larger than the tolerance and
|
||||||
// still count. Containment of a point that itself lies within the tolerance of a
|
// still count. Containment of a point that itself lies within the tolerance of a
|
||||||
// boundary edge stays ambiguous by design, exactly as it was before.
|
// boundary edge stays ambiguous by design, exactly as it was before.
|
||||||
var rayY = point.Y + RaySeamTolerance;
|
|
||||||
if (Center is not { } center)
|
if (Center is not { } center)
|
||||||
return (Start.Y > rayY) != (End.Y > rayY)
|
return (Start.Y > rayY) != (End.Y > rayY)
|
||||||
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
|
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
|
||||||
|
|||||||
@@ -34,7 +34,13 @@ public enum PostVerificationKind
|
|||||||
/// Two parts are closer than the plate's part spacing without sharing material, judged as
|
/// Two parts are closer than the plate's part spacing without sharing material, judged as
|
||||||
/// Auto Nest's layout check judges them.
|
/// Auto Nest's layout check judges them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
PartSpacing
|
PartSpacing,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A part its plate's support bed cannot hold level has no tab that keeps it level, so it may
|
||||||
|
/// tip once cut free (see docs/support-tabs.md).
|
||||||
|
/// </summary>
|
||||||
|
PartSupport
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
||||||
@@ -84,6 +90,8 @@ public sealed class PostVerificationReport
|
|||||||
Summary(PostVerificationKind.PierceZone, "Pierce zones");
|
Summary(PostVerificationKind.PierceZone, "Pierce zones");
|
||||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSpacing))
|
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSpacing))
|
||||||
Summary(PostVerificationKind.PartSpacing, "Part spacing");
|
Summary(PostVerificationKind.PartSpacing, "Part spacing");
|
||||||
|
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSupport))
|
||||||
|
Summary(PostVerificationKind.PartSupport, "Part support");
|
||||||
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(System.Math.Cos(angle), System.Math.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,148 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
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 ? System.Math.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;
|
||||||
@@ -151,6 +152,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.
|
||||||
|
|||||||
@@ -85,5 +85,28 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
return new Box(left, bottom, right - left, top - bottom);
|
return new Box(left, bottom, right - left, top - bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether <paramref name="inner"/> lies inside <paramref name="outer"/>, allowing
|
||||||
|
/// <see cref="Math.Tolerance.Epsilon"/> on every side.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsWithin(Box inner, Box outer)
|
||||||
|
{
|
||||||
|
var eps = Math.Tolerance.Epsilon;
|
||||||
|
return inner.Left >= outer.Left - eps
|
||||||
|
&& inner.Right <= outer.Right + eps
|
||||||
|
&& inner.Bottom >= outer.Bottom - eps
|
||||||
|
&& inner.Top <= outer.Top + eps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The smallest box covering both 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);
|
||||||
|
var right = System.Math.Max(a.Right, b.Right);
|
||||||
|
var top = System.Math.Max(a.Top, b.Top);
|
||||||
|
return new Box(left, bottom, right - left, top - bottom);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ namespace OpenNest.Geometry
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Polygon overlap test with hole subtraction, used by engine and validation per-pair
|
/// Polygon overlap test with hole subtraction, used by engine and validation per-pair
|
||||||
/// checks. It was written as the reference for a GPU kernel; that port is on hold in
|
/// checks. It was written as the reference for a GPU kernel; that port is on hold in
|
||||||
/// favour of Clipper2, starting with the material-overlap diagnostic. Engine callers
|
/// favor of Clipper2, starting with the material-overlap diagnostic. Engine callers
|
||||||
/// stay on this kernel until a Clipper2 near-contact differential and the layout gate
|
/// stay on this kernel until a Clipper2 near-contact differential and the layout gate
|
||||||
/// pass.
|
/// pass.
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ namespace OpenNest.Geometry
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Aligns <paramref name="newProgram"/> onto <paramref name="oldProgram"/>.
|
/// Aligns <paramref name="newProgram"/> onto <paramref name="oldProgram"/>.
|
||||||
/// Both programs are treated as read-only. Throws
|
/// Both programs are treated as read-only. Throws
|
||||||
/// <see cref="OperationCanceledException"/> only when cancelled before any
|
/// <see cref="OperationCanceledException"/> only when canceled before any
|
||||||
/// measurable work; work limits reached mid-run are reported as reasons on
|
/// measurable work; work limits reached mid-run are reported as reasons on
|
||||||
/// the result instead.
|
/// the result instead.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -504,7 +504,7 @@ namespace OpenNest.Geometry
|
|||||||
var newC = Centroid(@new.Outer.Samples);
|
var newC = Centroid(@new.Outer.Samples);
|
||||||
for (var i = 0; i < seeds.Count; i++)
|
for (var i = 0; i < seeds.Count; i++)
|
||||||
{
|
{
|
||||||
var rotated = Rotate(newC, seeds[i].Item1);
|
var rotated = newC.Rotate(seeds[i].Item1);
|
||||||
seeds[i] = (seeds[i].Item1, oldC - rotated);
|
seeds[i] = (seeds[i].Item1, oldC - rotated);
|
||||||
}
|
}
|
||||||
return seeds;
|
return seeds;
|
||||||
@@ -569,7 +569,7 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
var p = @new.Outer.Samples[i].Position;
|
var p = @new.Outer.Samples[i].Position;
|
||||||
var m = preMirror(p.X, p.Y);
|
var m = preMirror(p.X, p.Y);
|
||||||
positions[idx++] = Rotate(new Vector(m.X, m.Y), rotation) + translation;
|
positions[idx++] = new Vector(m.X, m.Y).Rotate(rotation) + translation;
|
||||||
}
|
}
|
||||||
if (!includeHoles)
|
if (!includeHoles)
|
||||||
return;
|
return;
|
||||||
@@ -578,7 +578,7 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
var p = @new.Holes[h].Samples[i].Position;
|
var p = @new.Holes[h].Samples[i].Position;
|
||||||
var m = preMirror(p.X, p.Y);
|
var m = preMirror(p.X, p.Y);
|
||||||
positions[idx++] = Rotate(new Vector(m.X, m.Y), rotation) + translation;
|
positions[idx++] = new Vector(m.X, m.Y).Rotate(rotation) + translation;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +650,7 @@ namespace OpenNest.Geometry
|
|||||||
return null; // nonfinite update
|
return null; // nonfinite update
|
||||||
|
|
||||||
rotation += dTheta;
|
rotation += dTheta;
|
||||||
translation = Rotate(translation, dTheta) + dT;
|
translation = translation.Rotate(dTheta) + dT;
|
||||||
|
|
||||||
var rms = System.Math.Sqrt(order.Average(i => residuals[i] * residuals[i]));
|
var rms = System.Math.Sqrt(order.Average(i => residuals[i] * residuals[i]));
|
||||||
|
|
||||||
@@ -790,7 +790,7 @@ namespace OpenNest.Geometry
|
|||||||
if (double.IsNaN(dTheta) || double.IsInfinity(dTheta))
|
if (double.IsNaN(dTheta) || double.IsInfinity(dTheta))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var r = Rotate(cq, dTheta);
|
var r = cq.Rotate(dTheta);
|
||||||
dTranslation = ct - r;
|
dTranslation = ct - r;
|
||||||
return !double.IsNaN(dTranslation.X) && !double.IsNaN(dTranslation.Y);
|
return !double.IsNaN(dTranslation.X) && !double.IsNaN(dTranslation.Y);
|
||||||
}
|
}
|
||||||
@@ -866,11 +866,11 @@ namespace OpenNest.Geometry
|
|||||||
var outerCenter = Centroid(old.Outer.Samples);
|
var outerCenter = Centroid(old.Outer.Samples);
|
||||||
foreach (var flip in new[] { System.Math.PI, System.Math.PI / 2, -System.Math.PI / 2 })
|
foreach (var flip in new[] { System.Math.PI, System.Math.PI / 2, -System.Math.PI / 2 })
|
||||||
{
|
{
|
||||||
var anchor = Rotate(-outerCenter, flip) + outerCenter;
|
var anchor = (-outerCenter).Rotate(flip) + outerCenter;
|
||||||
var probe = new Candidate
|
var probe = new Candidate
|
||||||
{
|
{
|
||||||
Rotation = NormalizeAngle(best.Rotation + flip),
|
Rotation = NormalizeAngle(best.Rotation + flip),
|
||||||
Translation = Rotate(best.Translation, flip) + anchor,
|
Translation = best.Translation.Rotate(flip) + anchor,
|
||||||
};
|
};
|
||||||
var refined = FitIcp(
|
var refined = FitIcp(
|
||||||
old,
|
old,
|
||||||
@@ -971,8 +971,8 @@ namespace OpenNest.Geometry
|
|||||||
var limit = options.SamplingSpacing * 0.5;
|
var limit = options.SamplingSpacing * 0.5;
|
||||||
foreach (var s in @new.Outer.Samples)
|
foreach (var s in @new.Outer.Samples)
|
||||||
{
|
{
|
||||||
var pa = Rotate(s.Position, a.Rotation) + a.Translation;
|
var pa = s.Position.Rotate(a.Rotation) + a.Translation;
|
||||||
var pb = Rotate(s.Position, best.Rotation) + best.Translation;
|
var pb = s.Position.Rotate(best.Rotation) + best.Translation;
|
||||||
if (pa.DistanceTo(pb) > limit)
|
if (pa.DistanceTo(pb) > limit)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -993,7 +993,7 @@ namespace OpenNest.Geometry
|
|||||||
var newTotal = 0;
|
var newTotal = 0;
|
||||||
foreach (var s in AllNewSamples(@new))
|
foreach (var s in AllNewSamples(@new))
|
||||||
{
|
{
|
||||||
var p = Rotate(s.Position, pose.Rotation) + pose.Translation;
|
var p = s.Position.Rotate(pose.Rotation) + pose.Translation;
|
||||||
newTotal++;
|
newTotal++;
|
||||||
if (TryClosestSegment(p, oldSegments, out var c) && p.DistanceTo(c) <= spanBound)
|
if (TryClosestSegment(p, oldSegments, out var c) && p.DistanceTo(c) <= spanBound)
|
||||||
newMatched++;
|
newMatched++;
|
||||||
@@ -1005,8 +1005,8 @@ namespace OpenNest.Geometry
|
|||||||
var posed = AllNewSegments(@new);
|
var posed = AllNewSegments(@new);
|
||||||
for (var i = 0; i < posed.Length; i++)
|
for (var i = 0; i < posed.Length; i++)
|
||||||
posedNewSegments[i] = new Line(
|
posedNewSegments[i] = new Line(
|
||||||
Rotate(posed[i].StartPoint, pose.Rotation) + pose.Translation,
|
posed[i].StartPoint.Rotate(pose.Rotation) + pose.Translation,
|
||||||
Rotate(posed[i].EndPoint, pose.Rotation) + pose.Translation
|
posed[i].EndPoint.Rotate(pose.Rotation) + pose.Translation
|
||||||
);
|
);
|
||||||
|
|
||||||
var oldMatched = 0;
|
var oldMatched = 0;
|
||||||
@@ -1034,7 +1034,7 @@ namespace OpenNest.Geometry
|
|||||||
var distances = new List<double>();
|
var distances = new List<double>();
|
||||||
foreach (var s in AllNewSamples(@new))
|
foreach (var s in AllNewSamples(@new))
|
||||||
{
|
{
|
||||||
var p = Rotate(s.Position, pose.Rotation) + pose.Translation;
|
var p = s.Position.Rotate(pose.Rotation) + pose.Translation;
|
||||||
if (TryClosestSegment(p, oldSegments, out var c))
|
if (TryClosestSegment(p, oldSegments, out var c))
|
||||||
distances.Add(p.DistanceTo(c));
|
distances.Add(p.DistanceTo(c));
|
||||||
}
|
}
|
||||||
@@ -1096,7 +1096,7 @@ namespace OpenNest.Geometry
|
|||||||
var path = new PathD(points.Length);
|
var path = new PathD(points.Length);
|
||||||
foreach (var p in points)
|
foreach (var p in points)
|
||||||
{
|
{
|
||||||
var q = Rotate(p, rotation) + translation;
|
var q = p.Rotate(rotation) + translation;
|
||||||
if (double.IsNaN(q.X) || double.IsNaN(q.Y))
|
if (double.IsNaN(q.X) || double.IsNaN(q.Y))
|
||||||
return null;
|
return null;
|
||||||
path.Add(new PointD(q.X, q.Y));
|
path.Add(new PointD(q.X, q.Y));
|
||||||
@@ -1137,13 +1137,6 @@ namespace OpenNest.Geometry
|
|||||||
return samples.Length > 0 ? sum * (1.0 / samples.Length) : new Vector();
|
return samples.Length > 0 ? sum * (1.0 / samples.Length) : new Vector();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector Rotate(Vector v, double angle)
|
|
||||||
{
|
|
||||||
var cos = System.Math.Cos(angle);
|
|
||||||
var sin = System.Math.Sin(angle);
|
|
||||||
return new Vector(v.X * cos - v.Y * sin, v.X * sin + v.Y * cos);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double NormalizeAngle(double angle)
|
private static double NormalizeAngle(double angle)
|
||||||
{
|
{
|
||||||
var twoPi = 2 * System.Math.PI;
|
var twoPi = 2 * System.Math.PI;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace OpenNest.Geometry
|
||||||
|
{
|
||||||
|
internal static class EntityEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>The start and end of a line or arc; false for any other entity.</summary>
|
||||||
|
public static bool TryGet(Entity entity, out Vector start, out Vector end)
|
||||||
|
{
|
||||||
|
switch (entity)
|
||||||
|
{
|
||||||
|
case Line line:
|
||||||
|
start = line.StartPoint;
|
||||||
|
end = line.EndPoint;
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case Arc arc:
|
||||||
|
start = arc.StartPoint();
|
||||||
|
end = arc.EndPoint();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
start = end = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
|
||||||
@@ -10,14 +11,14 @@ namespace OpenNest.Geometry
|
|||||||
public static void Optimize(IList<Arc> arcs) =>
|
public static void Optimize(IList<Arc> arcs) =>
|
||||||
MergePass(
|
MergePass(
|
||||||
arcs,
|
arcs,
|
||||||
(list, item, i) => list.GetCoradialArs(item, i),
|
(a, b) => a.IsCoradialTo(b),
|
||||||
(Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined)
|
(Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined)
|
||||||
);
|
);
|
||||||
|
|
||||||
public static void Optimize(IList<Line> lines) =>
|
public static void Optimize(IList<Line> lines) =>
|
||||||
MergePass(
|
MergePass(
|
||||||
lines,
|
lines,
|
||||||
(list, item, i) => list.GetCollinearLines(item, i),
|
(a, b) => a.IsCollinearTo(b),
|
||||||
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined)
|
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -57,11 +58,120 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces arcs of one center, radius and layer that together cover the whole circle with
|
||||||
|
/// a <see cref="Circle"/>. CAM exports often overshoot the seam of a hole and return along
|
||||||
|
/// a short chord; such a line, lying wholly on the overlap, goes with the arcs. Arcs that
|
||||||
|
/// leave a gap, or overlap by more than <see cref="MaxSeamOverlap"/>, are left alone.
|
||||||
|
/// </summary>
|
||||||
|
public static void JoinFullCircles(List<Entity> entities)
|
||||||
|
{
|
||||||
|
var groups = new List<List<Arc>>();
|
||||||
|
foreach (var arc in entities.OfType<Arc>())
|
||||||
|
{
|
||||||
|
if (!SpecialLayers.IsMaterial(arc.Layer))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var group = groups.Find(g => IsSameCircle(g[0], arc));
|
||||||
|
if (group == null)
|
||||||
|
groups.Add(new List<Arc> { arc });
|
||||||
|
else
|
||||||
|
group.Add(arc);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var longest = group.OrderByDescending(a => a.SweepAngle()).First();
|
||||||
|
var tolerance = Tolerance.ChainTolerance / longest.Radius;
|
||||||
|
var spans = group.Select(ToSpan).OrderBy(s => s.Start).ToList();
|
||||||
|
var overlap = spans.Sum(s => s.Sweep) - Angle.TwoPI;
|
||||||
|
|
||||||
|
if (group.Count < 2 || overlap > MaxSeamOverlap || !CoversCircle(spans, tolerance))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var chords = entities
|
||||||
|
.OfType<Line>()
|
||||||
|
.Where(line => IsSeamChord(line, longest, spans, tolerance))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var index = entities.FindIndex(e => e is Arc arc && group.Contains(arc));
|
||||||
|
entities.Insert(
|
||||||
|
index,
|
||||||
|
new Circle(longest.Center, longest.Radius)
|
||||||
|
{
|
||||||
|
Layer = longest.Layer,
|
||||||
|
Color = longest.Color,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
entities.RemoveAll(e => e is Arc arc && group.Contains(arc));
|
||||||
|
entities.RemoveAll(e => e is Line line && chords.Contains(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The most the arcs of a joined circle may overlap, in radians: 30 degrees.</summary>
|
||||||
|
public const double MaxSeamOverlap = System.Math.PI / 6;
|
||||||
|
|
||||||
|
private static bool IsSameCircle(Arc a, Arc b) =>
|
||||||
|
a.Layer?.Name == b.Layer?.Name
|
||||||
|
&& a.Center.DistanceTo(b.Center) <= Tolerance.ChainTolerance
|
||||||
|
&& System.Math.Abs(a.Radius - b.Radius) <= Tolerance.ChainTolerance;
|
||||||
|
|
||||||
|
private static (double Start, double Sweep) ToSpan(Arc arc) =>
|
||||||
|
(Angle.NormalizeRad(arc.IsReversed ? arc.EndAngle : arc.StartAngle), arc.SweepAngle());
|
||||||
|
|
||||||
|
// Walks the spans from angle zero, first crediting whatever part of them wraps past it.
|
||||||
|
private static bool CoversCircle(List<(double Start, double Sweep)> spans, double tolerance)
|
||||||
|
{
|
||||||
|
var reach = spans.Max(s => s.Start + s.Sweep - Angle.TwoPI);
|
||||||
|
foreach (var (start, sweep) in spans)
|
||||||
|
{
|
||||||
|
if (start > reach + tolerance)
|
||||||
|
return false;
|
||||||
|
reach = System.Math.Max(reach, start + sweep);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reach >= Angle.TwoPI - tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSeamChord(
|
||||||
|
Line line,
|
||||||
|
Arc circle,
|
||||||
|
List<(double Start, double Sweep)> spans,
|
||||||
|
double tolerance
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (line.Layer?.Name != circle.Layer?.Name)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var center = circle.Center;
|
||||||
|
bool OnCircle(Vector point) =>
|
||||||
|
System.Math.Abs(point.DistanceTo(center) - circle.Radius) <= Tolerance.ChainTolerance;
|
||||||
|
bool OnOverlap(Vector point)
|
||||||
|
{
|
||||||
|
var angle = System.Math.Atan2(point.Y - center.Y, point.X - center.X);
|
||||||
|
return spans.Count(s =>
|
||||||
|
{
|
||||||
|
var offset = Angle.NormalizeRad(angle - s.Start);
|
||||||
|
return offset <= s.Sweep + tolerance || offset >= Angle.TwoPI - tolerance;
|
||||||
|
}) >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
var middle = new Vector(
|
||||||
|
(line.StartPoint.X + line.EndPoint.X) / 2,
|
||||||
|
(line.StartPoint.Y + line.EndPoint.Y) / 2
|
||||||
|
);
|
||||||
|
return OnCircle(line.StartPoint)
|
||||||
|
&& OnCircle(line.EndPoint)
|
||||||
|
&& OnOverlap(line.StartPoint)
|
||||||
|
&& OnOverlap(line.EndPoint)
|
||||||
|
&& OnOverlap(middle);
|
||||||
|
}
|
||||||
|
|
||||||
private delegate bool TryJoin<T>(T a, T b, out T joined);
|
private delegate bool TryJoin<T>(T a, T b, out T joined);
|
||||||
|
|
||||||
private static void MergePass<T>(
|
private static void MergePass<T>(
|
||||||
IList<T> items,
|
IList<T> items,
|
||||||
Func<IList<T>, T, int, List<T>> findCandidates,
|
Func<T, T, bool> related,
|
||||||
TryJoin<T> tryJoin
|
TryJoin<T> tryJoin
|
||||||
)
|
)
|
||||||
where T : class
|
where T : class
|
||||||
@@ -69,7 +179,7 @@ namespace OpenNest.Geometry
|
|||||||
for (var i = 0; i < items.Count; ++i)
|
for (var i = 0; i < items.Count; ++i)
|
||||||
{
|
{
|
||||||
var item = items[i];
|
var item = items[i];
|
||||||
var candidates = findCandidates(items, item, i);
|
var candidates = FindRelated(items, item, i, related);
|
||||||
var index = 0;
|
var index = 0;
|
||||||
|
|
||||||
while (index < candidates.Count)
|
while (index < candidates.Count)
|
||||||
@@ -214,62 +324,41 @@ namespace OpenNest.Geometry
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Line> GetCollinearLines(
|
/// <summary>
|
||||||
this IList<Line> lines,
|
/// The items from <paramref name="startIndex"/> on, other than <paramref name="item"/>
|
||||||
Line line,
|
/// itself, that are <paramref name="related"/> to it.
|
||||||
int startIndex
|
/// </summary>
|
||||||
|
private static List<T> FindRelated<T>(
|
||||||
|
IList<T> items,
|
||||||
|
T item,
|
||||||
|
int startIndex,
|
||||||
|
Func<T, T, bool> related
|
||||||
)
|
)
|
||||||
|
where T : class
|
||||||
{
|
{
|
||||||
var collinearLines = new List<Line>();
|
var matches = new List<T>();
|
||||||
|
|
||||||
Parallel.For(
|
Parallel.For(
|
||||||
startIndex,
|
startIndex,
|
||||||
lines.Count,
|
items.Count,
|
||||||
index =>
|
index =>
|
||||||
{
|
{
|
||||||
var compareLine = lines[index];
|
var candidate = items[index];
|
||||||
|
|
||||||
if (Object.ReferenceEquals(line, compareLine))
|
if (Object.ReferenceEquals(item, candidate))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!line.IsCollinearTo(compareLine))
|
if (!related(item, candidate))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
lock (collinearLines)
|
lock (matches)
|
||||||
{
|
{
|
||||||
collinearLines.Add(compareLine);
|
matches.Add(candidate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return collinearLines;
|
return matches;
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Arc> GetCoradialArs(this IList<Arc> arcs, Arc arc, int startIndex)
|
|
||||||
{
|
|
||||||
var coradialArcs = new List<Arc>();
|
|
||||||
|
|
||||||
Parallel.For(
|
|
||||||
startIndex,
|
|
||||||
arcs.Count,
|
|
||||||
index =>
|
|
||||||
{
|
|
||||||
var compareArc = arcs[index];
|
|
||||||
|
|
||||||
if (Object.ReferenceEquals(arc, compareArc))
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!arc.IsCoradialTo(compareArc))
|
|
||||||
return;
|
|
||||||
|
|
||||||
lock (coradialArcs)
|
|
||||||
{
|
|
||||||
coradialArcs.Add(compareArc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return coradialArcs;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,13 +555,7 @@ public class GeometrySimplifier
|
|||||||
if (start > 0)
|
if (start > 0)
|
||||||
{
|
{
|
||||||
var prev = entities[start - 1];
|
var prev = entities[start - 1];
|
||||||
var prevEnd = prev switch
|
if (EntityEndpoints.TryGet(prev, out _, out var prevEnd) && prevEnd.DistanceTo(points[0]) < 1e-6)
|
||||||
{
|
|
||||||
Line l => l.EndPoint,
|
|
||||||
Arc a => a.EndPoint(),
|
|
||||||
_ => Vector.Invalid,
|
|
||||||
};
|
|
||||||
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
|
|
||||||
{
|
{
|
||||||
if (prev is Arc)
|
if (prev is Arc)
|
||||||
return new TangentEstimate(GetExitDirection(prev), true);
|
return new TangentEstimate(GetExitDirection(prev), true);
|
||||||
@@ -596,13 +590,7 @@ public class GeometrySimplifier
|
|||||||
if (k + 1 < entities.Count)
|
if (k + 1 < entities.Count)
|
||||||
{
|
{
|
||||||
var next = entities[k + 1];
|
var next = entities[k + 1];
|
||||||
var nextStart = next switch
|
if (EntityEndpoints.TryGet(next, out var nextStart, out _) && nextStart.DistanceTo(points[^1]) < 1e-6)
|
||||||
{
|
|
||||||
Line l => l.StartPoint,
|
|
||||||
Arc a => a.StartPoint(),
|
|
||||||
_ => Vector.Invalid,
|
|
||||||
};
|
|
||||||
if (nextStart.IsValid() && nextStart.DistanceTo(points[^1]) < 1e-6)
|
|
||||||
{
|
{
|
||||||
if (next is Arc nextArc)
|
if (next is Arc nextArc)
|
||||||
return new TangentEstimate(GetEntryDirection(nextArc), true);
|
return new TangentEstimate(GetEntryDirection(nextArc), true);
|
||||||
@@ -654,16 +642,20 @@ public class GeometrySimplifier
|
|||||||
private static Vector GetEntryDirection(Entity entity) =>
|
private static Vector GetEntryDirection(Entity entity) =>
|
||||||
entity switch
|
entity switch
|
||||||
{
|
{
|
||||||
Line line => new Vector(
|
Line line => LineDirection(line),
|
||||||
line.EndPoint.X - line.StartPoint.X,
|
Arc arc => ArcTangent(arc, arc.StartAngle),
|
||||||
line.EndPoint.Y - line.StartPoint.Y
|
|
||||||
),
|
|
||||||
Arc arc => arc.IsReversed
|
|
||||||
? new Vector(System.Math.Sin(arc.StartAngle), -System.Math.Cos(arc.StartAngle))
|
|
||||||
: new Vector(-System.Math.Sin(arc.StartAngle), System.Math.Cos(arc.StartAngle)),
|
|
||||||
_ => Vector.Invalid,
|
_ => Vector.Invalid,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static Vector LineDirection(Line line) =>
|
||||||
|
new(line.EndPoint.X - line.StartPoint.X, line.EndPoint.Y - line.StartPoint.Y);
|
||||||
|
|
||||||
|
/// <summary>The direction of travel along an arc at <paramref name="angle"/>.</summary>
|
||||||
|
private static Vector ArcTangent(Arc arc, double angle) =>
|
||||||
|
arc.IsReversed
|
||||||
|
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
|
||||||
|
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Computes the tangent direction at the last point of a fitted arc,
|
/// Computes the tangent direction at the last point of a fitted arc,
|
||||||
/// used to chain tangent continuity to the next arc.
|
/// used to chain tangent continuity to the next arc.
|
||||||
@@ -806,13 +798,8 @@ public class GeometrySimplifier
|
|||||||
private static Vector GetExitDirection(Entity entity) =>
|
private static Vector GetExitDirection(Entity entity) =>
|
||||||
entity switch
|
entity switch
|
||||||
{
|
{
|
||||||
Line line => new Vector(
|
Line line => LineDirection(line),
|
||||||
line.EndPoint.X - line.StartPoint.X,
|
Arc arc => ArcTangent(arc, arc.EndAngle),
|
||||||
line.EndPoint.Y - line.StartPoint.Y
|
|
||||||
),
|
|
||||||
Arc arc => arc.IsReversed
|
|
||||||
? new Vector(System.Math.Sin(arc.EndAngle), -System.Math.Cos(arc.EndAngle))
|
|
||||||
: new Vector(-System.Math.Sin(arc.EndAngle), System.Math.Cos(arc.EndAngle)),
|
|
||||||
_ => Vector.Invalid,
|
_ => Vector.Invalid,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,10 @@ namespace OpenNest.Geometry
|
|||||||
double t;
|
double t;
|
||||||
pts = new List<Vector>();
|
pts = new List<Vector>();
|
||||||
|
|
||||||
if (det.IsEqualTo(0))
|
// A small circle's whole discriminant is tiny: tangent also means the line stays at its rim.
|
||||||
|
var halfChord = det / (4 * a);
|
||||||
|
var depth = circle.Radius - System.Math.Sqrt(System.Math.Max(circle.Radius * circle.Radius - halfChord, 0));
|
||||||
|
if (det.IsEqualTo(0) && depth <= Tolerance.Epsilon)
|
||||||
{
|
{
|
||||||
t = -b / (2 * a);
|
t = -b / (2 * a);
|
||||||
var pt1 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y);
|
var pt1 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public static class KeepOutOverlap
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when the material (perimeter minus holes) shares positive area with the open
|
/// True when the material (perimeter minus holes) shares positive area with the open
|
||||||
/// rectangle: some boundary edge runs through its interior, or its interior lies wholly
|
/// rectangle: some boundary edge runs through its interior, or its interior lies wholly
|
||||||
/// inside the material (tested at its centre).
|
/// inside the material (tested at its center).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool MaterialEnters(Polygon perimeter, IReadOnlyList<Polygon> holes, Box box)
|
public static bool MaterialEnters(Polygon perimeter, IReadOnlyList<Polygon> holes, Box box)
|
||||||
{
|
{
|
||||||
@@ -80,12 +80,12 @@ public static class KeepOutOverlap
|
|||||||
if (RingEnters(hole, box))
|
if (RingEnters(hole, box))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
var centre = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2);
|
var center = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2);
|
||||||
if (!Contains(perimeter, centre))
|
if (!Contains(perimeter, center))
|
||||||
return false;
|
return false;
|
||||||
if (holes != null)
|
if (holes != null)
|
||||||
foreach (var hole in holes)
|
foreach (var hole in holes)
|
||||||
if (Contains(hole, centre))
|
if (Contains(hole, center))
|
||||||
return false;
|
return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 1142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user