mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-04 09:11:34 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
032854895d | ||
|
|
be5fef6875 | ||
|
|
9efeb4e5b6 | ||
|
|
3e35821ab1 | ||
|
|
c5b6e680cb | ||
|
|
31ee77914f | ||
|
|
3b5cf92af2 | ||
|
|
2939fe0802 | ||
|
|
072e96c08e | ||
|
|
53d46303cd | ||
|
|
6c4ebe49b3 | ||
|
|
5de628760f | ||
|
|
d7fe7fe33c | ||
|
|
fbc23d6b64 | ||
|
|
2721375e8b | ||
|
|
d133f6879b | ||
|
|
322a66c339 | ||
|
|
52e421dcbe | ||
|
|
6ee81a3501 | ||
|
|
a0001f04ac | ||
|
|
91089820ee | ||
|
|
a2966b9f86 | ||
|
|
5da1e35e88 | ||
|
|
72039a238b | ||
|
|
6771533e03 | ||
|
|
c243830641 | ||
|
|
a0d6c7212b | ||
|
|
8a282d53cf | ||
|
|
bfc36d166c | ||
|
|
ceddd8cf93 | ||
|
|
84a0fedac0 | ||
|
|
96db154d9e | ||
|
|
c122e64edd | ||
|
|
60d235c8ce | ||
|
|
81ec6a8125 | ||
|
|
b0cdbb3e81 | ||
|
|
54ad7ee84d | ||
|
|
d29f919aa2 | ||
|
|
e8c52a354d | ||
|
|
82d54aec87 | ||
|
|
b2cc796e4a | ||
|
|
e0bc97a719 | ||
|
|
af9886c355 | ||
|
|
3d728b000e | ||
|
|
1a46d9896f | ||
|
|
b703abe8dc | ||
|
|
f9b718e890 | ||
|
|
d4e622271a | ||
|
|
f597b06024 | ||
|
|
3ca23e9474 | ||
|
|
b1a03c1796 | ||
|
|
62e5410cdb | ||
|
|
12819198f9 | ||
|
|
763e2a1d4a | ||
|
|
0a0da50006 | ||
|
|
ac7ee41801 | ||
|
|
fd37cbffef | ||
|
|
c0cd7c6aff | ||
|
|
c8883bbd1e | ||
|
|
44372d129e | ||
|
|
4b2aba83bb | ||
|
|
be4886f876 | ||
|
|
e7499f14c2 | ||
|
|
10a4128187 | ||
|
|
4e92d0e8f3 | ||
|
|
277719a1d6 | ||
|
|
d57678ecee | ||
|
|
a89adc7911 | ||
|
|
8a5f16069d | ||
|
|
b28df6efa0 | ||
|
|
5e2606f679 | ||
|
|
eec6ed0125 | ||
|
|
9dd70ef779 | ||
|
|
8cc8d04b64 | ||
|
|
ec8fd6f8ed | ||
|
|
45f5ce6954 | ||
|
|
bbdd51fabc | ||
|
|
8076ddbd78 | ||
|
|
1d310b373e | ||
|
|
c027793077 | ||
|
|
658b3552c1 | ||
|
|
9d6d5fa4b2 | ||
|
|
a72e9eb900 | ||
|
|
e5d9e12df9 | ||
|
|
06ec8b1c43 | ||
|
|
f0d84e041c | ||
|
|
a5e219008e | ||
|
|
e271580f62 | ||
|
|
7bff9baa23 | ||
|
|
bdd6625713 | ||
|
|
24169a5f8f | ||
|
|
e231e44f72 | ||
|
|
082a50bbcd | ||
|
|
31525ba342 | ||
|
|
970d2442d5 | ||
|
|
01e2238258 | ||
|
|
59d4c8d727 | ||
|
|
817c4482eb | ||
|
|
561f5be06c | ||
|
|
63bcd89d35 | ||
|
|
8df4c73093 | ||
|
|
7dc8cbb22d | ||
|
|
9431006edd | ||
|
|
2dab923259 | ||
|
|
dd473b6d38 | ||
|
|
23d5362aa1 | ||
|
|
8fb95e57b5 | ||
|
|
55910d1f9d | ||
|
|
a28b543634 | ||
|
|
ad5f2a9041 | ||
|
|
45ab837c8c | ||
|
|
a392cc0863 | ||
|
|
e628345947 | ||
|
|
08d646413a | ||
|
|
ca693285bb | ||
|
|
05d5c56ac0 | ||
|
|
abced0a7ec | ||
|
|
e947c3ad86 | ||
|
|
3a8994c8fe | ||
|
|
4e3be68c11 | ||
|
|
4d55e11318 | ||
|
|
2a128c5a11 | ||
|
|
667beb4334 | ||
|
|
bb6bdc8213 | ||
|
|
b550640ef0 | ||
|
|
4028dafb26 | ||
|
|
7d9f900633 | ||
|
|
27665c0ded | ||
|
|
2c1a8eef42 | ||
|
|
47f9593a41 | ||
|
|
e76864b489 | ||
|
|
7a7b671408 | ||
|
|
d80002e832 | ||
|
|
508b7db0df | ||
|
|
4ca8b0debf | ||
|
|
889e33d179 | ||
|
|
1c2bf16b3e | ||
|
|
6ca9a1243c | ||
|
|
c313d9699e | ||
|
|
73a02cf309 | ||
|
|
cce2585d79 | ||
|
|
2db8f5f841 | ||
|
|
047174432f | ||
|
|
bb21ffdf35 | ||
|
|
7ec11993de | ||
|
|
16d4f706b2 | ||
|
|
42755c9422 | ||
|
|
38de7ab3a3 | ||
|
|
0719476618 | ||
|
|
afdf196754 | ||
|
|
35be95b843 | ||
|
|
4ae50c8a73 | ||
|
|
a0c620690e | ||
|
|
07a9739774 | ||
|
|
6a85593bd4 | ||
|
|
c798be9b95 | ||
|
|
fe216be2b4 | ||
|
|
a1d20bdeb5 | ||
|
|
41460481d6 | ||
|
|
a9396ba065 | ||
|
|
101d7b5a50 | ||
|
|
a0d063bb94 | ||
|
|
c21b5b006e | ||
|
|
55ab8ec4a5 | ||
|
|
662d3daf8c | ||
|
|
360c7136dd | ||
|
|
501d90b825 | ||
|
|
fa7a1f0fce | ||
|
|
672ed09074 | ||
|
|
e5dc67346d | ||
|
|
f22e839850 | ||
|
|
b8e8cc387d | ||
|
|
c6b6678a2f | ||
|
|
4179b53c97 | ||
|
|
c8419761e0 | ||
|
|
16bd92567c | ||
|
|
cd51f7cfda | ||
|
|
6f4e872c03 | ||
|
|
588ae5ed09 | ||
|
|
277ca36f42 | ||
|
|
8dded7f177 | ||
|
|
b65a9485c7 | ||
|
|
63915d6fb6 | ||
|
|
379e3fd433 | ||
|
|
f1468e6f57 | ||
|
|
0be9e2201a | ||
|
|
fcb61f3b3e | ||
|
|
87e0a70878 | ||
|
|
8776eb4734 | ||
|
|
fd2e9c2f56 | ||
|
|
3618b335cb | ||
|
|
7d9c64487d | ||
|
|
cc066b457e | ||
|
|
d2f814f1f9 | ||
|
|
69d8187adc | ||
|
|
4670577110 | ||
|
|
d505a50934 | ||
|
|
bc15c362e1 | ||
|
|
9b4c1f51d0 | ||
|
|
eba15791e7 | ||
|
|
7fe4de4b6d | ||
|
|
f4317bc52f | ||
|
|
207f126b9e | ||
|
|
0f1e5fb568 | ||
|
|
0ede9443de | ||
|
|
58b8467c39 | ||
|
|
eedff85904 | ||
|
|
cc16590881 | ||
|
|
8ad32e4864 | ||
|
|
7198f7a4f7 | ||
|
|
ee842c822f | ||
|
|
512b861739 |
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"vpk": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"commands": [
|
||||||
|
"vpk"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,14 @@
|
|||||||
!OpenNest.Data/**
|
!OpenNest.Data/**
|
||||||
!OpenNest.Core/
|
!OpenNest.Core/
|
||||||
!OpenNest.Core/**
|
!OpenNest.Core/**
|
||||||
|
!OpenNest.Server.Worker/
|
||||||
|
!OpenNest.Server.Worker/**
|
||||||
|
!OpenNest.Api/
|
||||||
|
!OpenNest.Api/**
|
||||||
|
!OpenNest.Engine/
|
||||||
|
!OpenNest.Engine/**
|
||||||
|
!OpenNest.IO/
|
||||||
|
!OpenNest.IO/**
|
||||||
**/bin/**
|
**/bin/**
|
||||||
**/obj/**
|
**/obj/**
|
||||||
**/data/**
|
**/data/**
|
||||||
|
|||||||
@@ -20,3 +20,6 @@
|
|||||||
*.PDF diff=astextplain
|
*.PDF diff=astextplain
|
||||||
*.rtf diff=astextplain
|
*.rtf diff=astextplain
|
||||||
*.RTF diff=astextplain
|
*.RTF diff=astextplain
|
||||||
|
|
||||||
|
# Container scripts run in Linux images.
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -19,9 +19,11 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
project:
|
project:
|
||||||
- OpenNest.Tests
|
- OpenNest.Tests
|
||||||
|
- OpenNest.Desktop.Tests
|
||||||
- OpenNest.Engine.Tests
|
- OpenNest.Engine.Tests
|
||||||
- OpenNest.IO.Tests
|
- OpenNest.IO.Tests
|
||||||
- OpenNest.Server.Tests
|
- OpenNest.Server.Tests
|
||||||
|
- OpenNest.FrontEnd.Tests
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
with:
|
with:
|
||||||
@@ -70,7 +72,7 @@ jobs:
|
|||||||
|
|
||||||
windows-desktop:
|
windows-desktop:
|
||||||
# WinForms tests need a Windows desktop runtime; the Linux job can only
|
# WinForms tests need a Windows desktop runtime; the Linux job can only
|
||||||
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop.
|
# cross-compile them. FrontEnd.Tests also run here, beside the desktop build.
|
||||||
runs-on: windows-2022
|
runs-on: windows-2022
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
name: Development build
|
||||||
|
|
||||||
|
# Every master push whose cross-platform tests pass becomes the rolling
|
||||||
|
# `dev-build` pre-release: X.Y.Z-dev.N, where X.Y.Z is the minor after the
|
||||||
|
# newest release tag and N the commit count. Installed copies take it only
|
||||||
|
# with Tools > Options > Include development builds.
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ['Cross-platform tests']
|
||||||
|
types: [completed]
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dev-build
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package:
|
||||||
|
if: >-
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event == 'push' &&
|
||||||
|
github.event.workflow_run.head_repository.full_name == github.repository
|
||||||
|
runs-on: windows-2022
|
||||||
|
timeout-minutes: 30
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
|
||||||
|
DOTNET_NOLOGO: '1'
|
||||||
|
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: Resolve development version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
$releases = git tag --list 'v*' | Where-Object { $_ -match '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } |
|
||||||
|
ForEach-Object { [version]$_.Substring(1) } | Sort-Object
|
||||||
|
if (-not $releases) { throw 'No vX.Y.Z release tag found.' }
|
||||||
|
$latest = @($releases)[-1]
|
||||||
|
$count = (git rev-list --count $env:SOURCE_SHA).Trim()
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Cannot count commits.' }
|
||||||
|
$version = "$($latest.Major).$($latest.Minor + 1).0-dev.$count"
|
||||||
|
Write-Host "Development version $version (after v$latest)"
|
||||||
|
"version=$version" >> $env:GITHUB_OUTPUT
|
||||||
|
- name: Publish and verify Windows package
|
||||||
|
run: ./scripts/Publish-Windows.ps1 -Version ${{ steps.version.outputs.version }}
|
||||||
|
- name: Upload development build
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: OpenNest-dev-build
|
||||||
|
path: |
|
||||||
|
artifacts/*.zip
|
||||||
|
artifacts/*.sha256
|
||||||
|
artifacts/velopack/OpenNest-win-Setup.exe
|
||||||
|
artifacts/velopack/OpenNest-win-Portable.zip
|
||||||
|
artifacts/velopack/*-full.nupkg
|
||||||
|
artifacts/velopack/releases.win.json
|
||||||
|
artifacts/velopack/RELEASES
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: package
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: read
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
VERSION: ${{ needs.package.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Skip if master has moved on
|
||||||
|
id: current
|
||||||
|
run: |
|
||||||
|
head=$(gh api "repos/$GH_REPO/commits/master" --jq .sha)
|
||||||
|
if [ "$head" != "$SOURCE_SHA" ]; then
|
||||||
|
echo "master is at $head; its own run publishes the newer build."
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- name: Download development build
|
||||||
|
if: steps.current.outputs.skip != 'true'
|
||||||
|
run: gh run download "$GITHUB_RUN_ID" -n OpenNest-dev-build -D dev
|
||||||
|
- name: Verify checksums
|
||||||
|
if: steps.current.outputs.skip != 'true'
|
||||||
|
working-directory: dev
|
||||||
|
run: |
|
||||||
|
sha256sum -c ./*.zip.sha256
|
||||||
|
(cd velopack && sha256sum -c "../OpenNest.v$VERSION.velopack.sha256")
|
||||||
|
- name: Replace the dev-build pre-release
|
||||||
|
if: steps.current.outputs.skip != 'true'
|
||||||
|
working-directory: dev
|
||||||
|
run: |
|
||||||
|
if gh release view dev-build >/dev/null 2>&1; then
|
||||||
|
gh release delete dev-build --cleanup-tag --yes
|
||||||
|
fi
|
||||||
|
cat > notes.md <<EOF
|
||||||
|
Automatic development build of master at $SOURCE_SHA.
|
||||||
|
|
||||||
|
It may contain unfinished features. Installed copies receive it only with
|
||||||
|
**Tools > Options > Include development builds**; full releases are unaffected.
|
||||||
|
EOF
|
||||||
|
gh release create dev-build ./*.zip ./*.sha256 velopack/* \
|
||||||
|
--target "$SOURCE_SHA" --prerelease --latest=false \
|
||||||
|
--title "OpenNest $VERSION (development build, ${SOURCE_SHA:0:8})" --notes-file notes.md
|
||||||
|
- name: Read back the pre-release
|
||||||
|
if: steps.current.outputs.skip != 'true'
|
||||||
|
run: |
|
||||||
|
gh release view dev-build --json isPrerelease,isDraft,tagName,assets \
|
||||||
|
--jq '"prerelease=\(.isPrerelease) draft=\(.isDraft) assets=\(.assets|length)"' | tee readback.txt
|
||||||
|
grep -qx 'prerelease=true draft=false assets=8' readback.txt
|
||||||
|
[ "$(gh api "repos/$GH_REPO/git/ref/tags/dev-build" --jq .object.sha)" = "$SOURCE_SHA" ]
|
||||||
@@ -6,6 +6,10 @@ on:
|
|||||||
- 'OpenNest.Server/**'
|
- 'OpenNest.Server/**'
|
||||||
- 'OpenNest.Data/**'
|
- 'OpenNest.Data/**'
|
||||||
- 'OpenNest.Core/**'
|
- 'OpenNest.Core/**'
|
||||||
|
- 'OpenNest.Server.Worker/**'
|
||||||
|
- 'OpenNest.Api/**'
|
||||||
|
- 'OpenNest.Engine/**'
|
||||||
|
- 'OpenNest.IO/**'
|
||||||
- 'OpenNest.Server.Tests/**'
|
- 'OpenNest.Server.Tests/**'
|
||||||
- 'scripts/Server.Tests/**'
|
- 'scripts/Server.Tests/**'
|
||||||
- 'scripts/Test-ServerContainer.sh'
|
- 'scripts/Test-ServerContainer.sh'
|
||||||
@@ -21,6 +25,10 @@ on:
|
|||||||
- 'OpenNest.Server/**'
|
- 'OpenNest.Server/**'
|
||||||
- 'OpenNest.Data/**'
|
- 'OpenNest.Data/**'
|
||||||
- 'OpenNest.Core/**'
|
- 'OpenNest.Core/**'
|
||||||
|
- 'OpenNest.Server.Worker/**'
|
||||||
|
- 'OpenNest.Api/**'
|
||||||
|
- 'OpenNest.Engine/**'
|
||||||
|
- 'OpenNest.IO/**'
|
||||||
- 'OpenNest.Server.Tests/**'
|
- 'OpenNest.Server.Tests/**'
|
||||||
- 'scripts/Server.Tests/**'
|
- 'scripts/Server.Tests/**'
|
||||||
- 'scripts/Test-ServerContainer.sh'
|
- 'scripts/Test-ServerContainer.sh'
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
# mini dump in TestResults, instead of holding the runner until the
|
# mini dump in TestResults, instead of holding the runner until the
|
||||||
# job timeout.
|
# job timeout.
|
||||||
run: |
|
run: |
|
||||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
foreach ($project in @('OpenNest.Tests', 'OpenNest.Desktop.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||||
dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||||
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
||||||
}
|
}
|
||||||
@@ -96,6 +96,11 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
artifacts/*.zip
|
artifacts/*.zip
|
||||||
artifacts/*.sha256
|
artifacts/*.sha256
|
||||||
|
artifacts/velopack/OpenNest-win-Setup.exe
|
||||||
|
artifacts/velopack/OpenNest-win-Portable.zip
|
||||||
|
artifacts/velopack/*-full.nupkg
|
||||||
|
artifacts/velopack/releases.win.json
|
||||||
|
artifacts/velopack/RELEASES
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
|
|||||||
@@ -225,3 +225,6 @@ OpenNest.Tests/test-config.json
|
|||||||
# Vendor programming manuals: keep reference copies outside source control.
|
# Vendor programming manuals: keep reference copies outside source control.
|
||||||
CINCINNATI LASER PROGRAMMING MANUAL.pdf
|
CINCINNATI LASER PROGRAMMING MANUAL.pdf
|
||||||
TF5200_programming_manual_en.pdf
|
TF5200_programming_manual_en.pdf
|
||||||
|
|
||||||
|
# Python bytecode
|
||||||
|
__pycache__/
|
||||||
@@ -22,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,9 +41,11 @@ 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` (in Database mode, the server's shared copy once it holds one). Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
|
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). WinForms `Properties.Settings` persist to `%APPDATA%\OpenNest\user-settings.json` through `AppDataSettingsProvider`, never inside the Velopack install folder; `OpenNest.WinForms.Tests` redirects them to a temp file, so a test run never writes the user's settings. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
|
||||||
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
|
- `OpenNest.Desktop` (net8.0): toolkit-neutral desktop logic shared by the WinForms app and `OpenNest.Avalonia`, the cross-platform desktop in progress. It must not reference WinForms/WindowsDesktop, `System.Drawing.Common` or Avalonia (`OpenNest.Desktop.Tests` guards this, including transitive packages); put toolkit-specific rendering, dialogs and settings behind adapters in the front ends.
|
||||||
|
- `OpenNest.Server`: nest storage HTTP service, kept off Engine and IO. `OpenNest.Server.Worker`: the server nesting worker, shipped in the same image.
|
||||||
|
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection and the Python sketch-search prototype (`sketch-search/README.md`). Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
|
||||||
|
|
||||||
## Geometry and ownership safeguards
|
## Geometry and ownership safeguards
|
||||||
|
|
||||||
@@ -48,28 +54,34 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
|
|||||||
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
|
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
|
||||||
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
|
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
|
||||||
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
|
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
|
||||||
|
- `Plate.Outline`/`NestPlateStock.Outline` (`SheetOutline`) make a sheet irregular; `Size` stays its bounding box. Only Irregular fills it (`PackBoundary` eroded by the largest edge spacing); every other engine and fill strategy must place nothing on it, validation must reject parts outside it or over its obstacles, and automatic cut-offs refuse it. See [irregular stock](docs/irregular-stock.md).
|
||||||
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
|
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
|
||||||
- A cutoff starts only at the origin-side edge: stock can be oversize, so the far edge is unknown. Only a head-locked overcut (`CutOff.HeadLockOvercut`, `HeadHeightLock` markers) may start its far-edge segment there; posts refuse it without lock codes.
|
- A cutoff starts only at the origin-side edge: stock can be oversize, so the far edge is unknown. Only a head-locked overcut (`CutOff.HeadLockOvercut`, `HeadHeightLock` markers) may start its far-edge segment there; posts refuse it without lock codes.
|
||||||
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
|
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
|
||||||
|
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. The one designed raise is the rapid out of a cutout's slug after its lead-out, marked at emission (see the cutting planner doc); no lead-out or an overcut keeps the head down. Every post must raise before a marked rapid or refuse the nest.
|
||||||
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
|
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
|
||||||
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
|
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
|
||||||
|
|
||||||
Read the relevant contract before changing its behavior:
|
Read the relevant contract before changing its behavior:
|
||||||
|
|
||||||
- [Nest file format](docs/nest-file-format.md)
|
- [Nest file format](docs/nest-file-format.md)
|
||||||
|
- [SVG import and export](docs/svg-import-export.md): SVG numbers are not drawing units; import never guesses a scale
|
||||||
|
- [Irregular stock](docs/irregular-stock.md): outlined sheets, what honors the outline and what refuses it
|
||||||
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
|
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
|
||||||
- [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
|
- [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in and the stop at a feature's center line) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
|
||||||
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
|
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
|
||||||
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
|
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
|
||||||
|
- [Dimensions](docs/dimensions.md): CAD Converter annotations that store placement only, are measured from the cut geometry when shown and never affect the program
|
||||||
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level
|
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level
|
||||||
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
|
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
|
||||||
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
|
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
|
||||||
- [Pre-post verification](docs/post-verification.md)
|
- [Pre-post verification](docs/post-verification.md)
|
||||||
- [Machine cutting defaults](docs/machine-cutting-defaults.md): the nest's machine supplies leads, kerf and cut-off settings for plates without their own parameters
|
- [Machine cutting defaults](docs/machine-cutting-defaults.md): the nest's machine supplies leads, kerf 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
|
- [Shared settings](docs/shared-settings.md): in Database mode machines, post settings, clamps and nest defaults come from the server once it holds them; offline uses the last copy read, read-only
|
||||||
|
- [Server nesting](docs/server-nesting.md): Database-mode Auto Nest queued to the server's worker container (`OPENNEST_ROLE=worker`), one job at a time with no time limit, streaming progress and the best plan so far over SSE; the desktop replays the recorded engine answers to validate and bind locally. My Server Jobs reopens results in later sessions, and each result is kept at most once (`If-Match` Keep). The HTTP server stays off Engine/IO
|
||||||
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
|
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
|
||||||
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
|
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
|
||||||
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
|
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
|
||||||
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
|
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, opt-in rolling `dev-build` pre-release from green master pushes, the nest server's `/updates` mirror first in Database mode (GitHub fallback), `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
|
||||||
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
|
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
|
||||||
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install.
|
- [Desktop help](docs/desktop-help.md): WebView2 help window, topic IDs and the pinned help release both packaging scripts install.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Remote;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A whole-job nest request as one upload for the nest server: a ZIP holding
|
||||||
|
/// <c>job.json</c> (<see cref="NestJobDocument"/>) and <c>drawings.nest</c>, a nest archive of
|
||||||
|
/// exactly the items' drawings in the order the document names them. Drawings travel as the
|
||||||
|
/// nest file format stores them, so the server nests what the client has, edits included.
|
||||||
|
/// </summary>
|
||||||
|
public static class NestJobPackage
|
||||||
|
{
|
||||||
|
public const string JobEntry = "job.json";
|
||||||
|
public const string DrawingsEntry = "drawings.nest";
|
||||||
|
|
||||||
|
public static byte[] Write(NestPipelineRequest request, Units units = Units.Inches)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ArgumentNullException.ThrowIfNull(request.Items);
|
||||||
|
var drawings = new List<Drawing>();
|
||||||
|
foreach (var item in request.Items)
|
||||||
|
{
|
||||||
|
if (item?.Drawing != null && !drawings.Any(d => ReferenceEquals(d, item.Drawing)))
|
||||||
|
drawings.Add(item.Drawing);
|
||||||
|
}
|
||||||
|
var document = NestJobDocument.From(request, drawings);
|
||||||
|
|
||||||
|
// A throwaway nest holds the drawings only; the caller's nest is never written.
|
||||||
|
var nest = new Nest { Units = units };
|
||||||
|
foreach (var drawing in drawings)
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
|
||||||
|
{
|
||||||
|
using (var writer = new StreamWriter(zip.CreateEntry(JobEntry).Open(), new UTF8Encoding(false)))
|
||||||
|
writer.Write(document.ToJson());
|
||||||
|
using var archive = zip.CreateEntry(DrawingsEntry, CompressionLevel.NoCompression).Open();
|
||||||
|
new NestWriter(nest).Write(archive);
|
||||||
|
}
|
||||||
|
return buffer.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The package's <c>job.json</c> alone, without reading its drawings: a desktop reviewing the
|
||||||
|
/// job in a later session binds it to its own drawings instead
|
||||||
|
/// (<see cref="RemoteNestSubmission.Reopen"/>). Throws <see cref="FormatException"/> when it is not a package.
|
||||||
|
/// </summary>
|
||||||
|
public static NestJobDocument ReadDocument(byte[] package)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(package);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var zip = new ZipArchive(new MemoryStream(package, writable: false), ZipArchiveMode.Read);
|
||||||
|
var jobEntry = zip.GetEntry(JobEntry) ?? throw new FormatException($"The nest job has no {JobEntry}.");
|
||||||
|
using var reader = new StreamReader(jobEntry.Open(), Encoding.UTF8);
|
||||||
|
return NestJobDocument.FromJson(reader.ReadToEnd());
|
||||||
|
}
|
||||||
|
catch (InvalidDataException ex)
|
||||||
|
{
|
||||||
|
throw new FormatException("The nest job is not a readable ZIP package.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads a package back into a request; throws <see cref="FormatException"/> when it is not one.</summary>
|
||||||
|
public static NestPipelineRequest Read(byte[] package)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(package);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var zip = new ZipArchive(new MemoryStream(package, writable: false), ZipArchiveMode.Read);
|
||||||
|
var jobEntry = zip.GetEntry(JobEntry) ?? throw new FormatException($"The nest job has no {JobEntry}.");
|
||||||
|
var drawingsEntry = zip.GetEntry(DrawingsEntry) ?? throw new FormatException($"The nest job has no {DrawingsEntry}.");
|
||||||
|
|
||||||
|
string json;
|
||||||
|
using (var reader = new StreamReader(jobEntry.Open(), Encoding.UTF8))
|
||||||
|
json = reader.ReadToEnd();
|
||||||
|
var document = NestJobDocument.FromJson(json);
|
||||||
|
|
||||||
|
// NestReader needs a seekable stream.
|
||||||
|
using var archive = new MemoryStream();
|
||||||
|
using (var entry = drawingsEntry.Open())
|
||||||
|
entry.CopyTo(archive);
|
||||||
|
archive.Position = 0;
|
||||||
|
var nest = new NestReader(archive).Read();
|
||||||
|
return document.ToRequest(nest.Drawings.ToList());
|
||||||
|
}
|
||||||
|
catch (InvalidDataException ex)
|
||||||
|
{
|
||||||
|
throw new FormatException("The nest job is not a readable ZIP package.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,6 @@ public class NestRequestPart
|
|||||||
public string DxfPath { get; init; }
|
public string DxfPath { get; init; }
|
||||||
public int Quantity { get; init; } = 1;
|
public int Quantity { get; init; } = 1;
|
||||||
public bool AllowRotation { get; init; } = true;
|
public bool AllowRotation { get; init; } = true;
|
||||||
|
/// <summary>Nesting order: higher numbers nest first.</summary>
|
||||||
public int Priority { get; init; } = 0;
|
public int Priority { get; init; } = 0;
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,10 @@ namespace OpenNest.Avalonia
|
|||||||
var args = desktop.Args ?? System.Array.Empty<string>();
|
var args = desktop.Args ?? System.Array.Empty<string>();
|
||||||
if (args.Length > 0)
|
if (args.Length > 0)
|
||||||
window.OpenNest(args[0]);
|
window.OpenNest(args[0]);
|
||||||
// --snapshot <png> [plate]: render the window to an image and exit, for checking the drawing.
|
// --auto-nest: run Auto Nest on the opened nest (with --snapshot, before rendering).
|
||||||
|
if (System.Array.IndexOf(args, "--auto-nest") >= 0)
|
||||||
|
window.AutoNestOnOpen();
|
||||||
|
// --snapshot <png> [plate]: render the window to an image (status line in <png>.txt) and exit.
|
||||||
var snapshot = System.Array.IndexOf(args, "--snapshot");
|
var snapshot = System.Array.IndexOf(args, "--snapshot");
|
||||||
if (snapshot >= 0 && snapshot + 1 < args.Length)
|
if (snapshot >= 0 && snapshot + 1 < args.Length)
|
||||||
window.SaveSnapshotAndClose(args[snapshot + 1],
|
window.SaveSnapshotAndClose(args[snapshot + 1],
|
||||||
|
|||||||
@@ -8,10 +8,13 @@
|
|||||||
<DockPanel>
|
<DockPanel>
|
||||||
<Menu DockPanel.Dock="Top">
|
<Menu DockPanel.Dock="Top">
|
||||||
<MenuItem Header="_File">
|
<MenuItem Header="_File">
|
||||||
<MenuItem Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
|
<MenuItem x:Name="OpenMenuItem" Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
|
||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem Header="E_xit" Click="OnExit" />
|
<MenuItem Header="E_xit" Click="OnExit" />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem Header="_Nest">
|
||||||
|
<MenuItem x:Name="AutoNestMenuItem" Header="_Auto Nest" Click="OnAutoNest" />
|
||||||
|
</MenuItem>
|
||||||
<MenuItem Header="_View">
|
<MenuItem Header="_View">
|
||||||
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
|
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
|
||||||
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
|
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
|
||||||
@@ -21,10 +24,11 @@
|
|||||||
|
|
||||||
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
|
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
<Button Content="Open" Click="OnOpen" />
|
<Button x:Name="OpenButton" Content="Open" Click="OnOpen" />
|
||||||
<Button Content="Fit" Click="OnFit" />
|
<Button Content="Fit" Click="OnFit" />
|
||||||
<Button Content="Previous" Click="OnPreviousPlate" />
|
<Button Content="Previous" Click="OnPreviousPlate" />
|
||||||
<Button Content="Next" Click="OnNextPlate" />
|
<Button Content="Next" Click="OnNextPlate" />
|
||||||
|
<Button x:Name="AutoNestButton" Content="Auto Nest" Click="OnAutoNest" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using OpenNest.Desktop;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Forms;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
namespace OpenNest.Avalonia
|
||||||
@@ -13,12 +19,22 @@ namespace OpenNest.Avalonia
|
|||||||
|
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
private Nest nest;
|
private Document document;
|
||||||
private string fileName;
|
private string fileName;
|
||||||
|
private string notice;
|
||||||
|
private CancellationTokenSource nesting;
|
||||||
|
private Task autoNest = Task.CompletedTask;
|
||||||
|
|
||||||
|
private Nest nest => document?.Nest;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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();
|
Canvas.ViewChanged += (_, _) => UpdateStatus();
|
||||||
KeyBindings.Add(new KeyBinding
|
KeyBindings.Add(new KeyBinding
|
||||||
{
|
{
|
||||||
@@ -31,9 +47,11 @@ namespace OpenNest.Avalonia
|
|||||||
|
|
||||||
public void OpenNest(string path)
|
public void OpenNest(string path)
|
||||||
{
|
{
|
||||||
|
if (nesting != null)
|
||||||
|
return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
nest = new NestReader(path).Read();
|
document = new Document { Nest = new NestReader(path).Read() };
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -43,30 +61,44 @@ namespace OpenNest.Avalonia
|
|||||||
|
|
||||||
fileName = Path.GetFileName(path);
|
fileName = Path.GetFileName(path);
|
||||||
Title = $"{fileName} - OpenNest (Avalonia prototype)";
|
Title = $"{fileName} - OpenNest (Avalonia prototype)";
|
||||||
|
notice = null;
|
||||||
Canvas.ClearCache();
|
Canvas.ClearCache();
|
||||||
|
RefreshPlateList(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshPlateList(int selectIndex)
|
||||||
|
{
|
||||||
PlateList.ItemsSource = nest.Plates
|
PlateList.ItemsSource = nest.Plates
|
||||||
.Select((plate, i) => $"Plate {i + 1} {plate.Size.Length:0.###} x {plate.Size.Width:0.###} ({plate.Parts.Count} parts)")
|
.Select((plate, i) => $"Plate {i + 1} {plate.Size.Length:0.###} x {plate.Size.Width:0.###} ({plate.Parts.Count} parts)")
|
||||||
.ToList();
|
.ToList();
|
||||||
PlateList.SelectedIndex = nest.Plates.Count > 0 ? 0 : -1;
|
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)
|
public void SaveSnapshotAndClose(string path, int plateNumber)
|
||||||
{
|
{
|
||||||
Opened += async (_, _) =>
|
Opened += async (_, _) =>
|
||||||
{
|
{
|
||||||
if (nest?.Plates.Count > 0)
|
if (nest?.Plates.Count > 0)
|
||||||
PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1);
|
PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1);
|
||||||
|
await autoNest;
|
||||||
await System.Threading.Tasks.Task.Delay(500);
|
await System.Threading.Tasks.Task.Delay(500);
|
||||||
var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height);
|
var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height);
|
||||||
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
|
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
|
||||||
bitmap.Render(this);
|
bitmap.Render(this);
|
||||||
bitmap.Save(path);
|
bitmap.Save(path);
|
||||||
|
// The status line beside the image, so headless checks can read it.
|
||||||
|
File.WriteAllText(path + ".txt", Status.Text);
|
||||||
Close();
|
Close();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnOpen(object sender, RoutedEventArgs e)
|
private async void OnOpen(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (nesting != null)
|
||||||
|
return;
|
||||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
{
|
{
|
||||||
Title = "Open Nest",
|
Title = "Open Nest",
|
||||||
@@ -80,6 +112,88 @@ namespace OpenNest.Avalonia
|
|||||||
|
|
||||||
private void OnExit(object sender, RoutedEventArgs e) => Close();
|
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 OnFit(object sender, RoutedEventArgs e) => Canvas.ZoomToFit();
|
||||||
|
|
||||||
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
|
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
|
||||||
@@ -120,6 +234,8 @@ namespace OpenNest.Avalonia
|
|||||||
var part = Canvas.HoveredPart ?? Canvas.SelectedPart;
|
var part = Canvas.HoveredPart ?? Canvas.SelectedPart;
|
||||||
if (part != null)
|
if (part != null)
|
||||||
text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}";
|
text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}";
|
||||||
|
if (notice != null)
|
||||||
|
text += $" | {notice}";
|
||||||
Status.Text = text;
|
Status.Text = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||||
|
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.csproj" />
|
||||||
<PackageReference Include="Avalonia" Version="11.3.0" />
|
<PackageReference Include="Avalonia" Version="11.3.0" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ namespace OpenNest.Benchmark
|
|||||||
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
|
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
|
||||||
public double SalvageRate { get; init; }
|
public double SalvageRate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Manifest "offcutCut": which edge strips count as a sheet's offcut;
|
||||||
|
/// null for all.</summary>
|
||||||
|
public CutOffAxis? OffcutCut { get; init; }
|
||||||
|
|
||||||
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
|
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
|
||||||
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
|
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
|
||||||
public List<DrawingRequest> Requests { get; init; }
|
public List<DrawingRequest> Requests { get; init; }
|
||||||
@@ -90,10 +94,11 @@ namespace OpenNest.Benchmark
|
|||||||
parts,
|
parts,
|
||||||
stock,
|
stock,
|
||||||
new NestJobOptions(
|
new NestJobOptions(
|
||||||
"Fill",
|
"Lattice",
|
||||||
maxPlates,
|
maxPlates,
|
||||||
salvageRate ?? SalvageRate,
|
salvageRate ?? SalvageRate,
|
||||||
minimumSalvageDimension ?? 0
|
minimumSalvageDimension ?? 0,
|
||||||
|
offcutCut: OffcutCut
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
|||||||
using OpenNest.Engine.BestFit;
|
using OpenNest.Engine.BestFit;
|
||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.Engine.Jobs.Adapters;
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Engine.NestingEngines;
|
||||||
|
|
||||||
namespace OpenNest.Benchmark
|
namespace OpenNest.Benchmark
|
||||||
{
|
{
|
||||||
@@ -28,6 +29,15 @@ namespace OpenNest.Benchmark
|
|||||||
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
||||||
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
/// <summary>How long past a --time-limit the harness waits before abandoning a solve.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan TimeLimitGrace = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
/// <summary>The longest --time-limit: the harness timeout above it must still fit a
|
||||||
|
/// cancellation timer.</summary>
|
||||||
|
internal static readonly TimeSpan MaxTimeLimit =
|
||||||
|
DeadlineNestingEngine.MaxLimit - TimeLimitGrace;
|
||||||
|
|
||||||
public static List<JobResult> Run(
|
public static List<JobResult> Run(
|
||||||
List<BenchmarkJob> jobs,
|
List<BenchmarkJob> jobs,
|
||||||
IReadOnlyList<NestingEngineInfo> engines,
|
IReadOnlyList<NestingEngineInfo> engines,
|
||||||
@@ -35,7 +45,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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -214,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;
|
||||||
@@ -234,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);
|
||||||
|
|
||||||
@@ -356,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);
|
||||||
@@ -368,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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+102
-20
@@ -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)
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
@@ -981,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)
|
||||||
@@ -990,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
|
||||||
@@ -1037,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;
|
||||||
@@ -1059,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
var y = ySign * horizontalDistance.Value;
|
var y = ySign * horizontalDistance.Value;
|
||||||
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
|
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
|
||||||
var clamp = clamps.FirstOrDefault(zone =>
|
var clamp = clamps.FirstOrDefault(zone =>
|
||||||
y >= zone.Bounds.Bottom - plate.EdgeSpacing.Bottom && y <= zone.Bounds.Top + plate.EdgeSpacing.Bottom);
|
y >= zone.Bounds.Bottom - plate.ClampClearance && y <= zone.Bounds.Top + plate.ClampClearance);
|
||||||
if (limited || clamp != null)
|
if (limited || clamp != null)
|
||||||
{
|
{
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
@@ -371,7 +371,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
|
|
||||||
// A full-span line, or one with the limits this plan gives its own vertical lines.
|
// A full-span line, or one with the limits this plan gives its own vertical lines.
|
||||||
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
|
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
|
||||||
horizontalY.HasValue &&
|
horizontalY.HasValue && cut.Gaps.Count == 0 &&
|
||||||
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
|
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
|
||||||
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
|
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
|
||||||
|
|
||||||
@@ -460,7 +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);
|
||||||
@@ -473,7 +473,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
var candidate = existing == null
|
var candidate = existing == null
|
||||||
? NewVertical(x)
|
? NewVertical(x)
|
||||||
: new CutOff(existing.Position, existing.Axis)
|
: new CutOff(existing.Position, existing.Axis)
|
||||||
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
|
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit, Gaps = existing.Gaps };
|
||||||
candidate.Regenerate(plate, settings, cache);
|
candidate.Regenerate(plate, settings, cache);
|
||||||
var program = candidate.Drawing.Program;
|
var program = candidate.Drawing.Program;
|
||||||
var usable = HasUsableSegments(program);
|
var usable = HasUsableSegments(program);
|
||||||
@@ -522,7 +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,
|
||||||
@@ -544,10 +544,12 @@ public static class AutomaticCutOffPlanner
|
|||||||
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
|
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
|
||||||
"Sheet length and width must be positive and finite.", nameof(plate));
|
"Sheet length and width must be positive and finite.", nameof(plate));
|
||||||
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
|
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
|
||||||
|
// Strips are planned across a rectangle's known span; an outlined sheet gets manual cut-offs only.
|
||||||
|
Require(plate.Outline == null, "Automatic cut-offs need a rectangular sheet; this plate has an outline.", nameof(plate));
|
||||||
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
|
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
|
||||||
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
|
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
|
||||||
Nonnegative(plate.EdgeSpacing.Bottom)),
|
Nonnegative(plate.ClampClearance)),
|
||||||
"Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate));
|
"Clamp zones and clamp clearance must be finite and valid.", nameof(plate));
|
||||||
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
||||||
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
|
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
|
||||||
Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
|
Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
|
||||||
@@ -652,7 +654,9 @@ public static class AutomaticCutOffPlanner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A gapped line never runs full span, whatever its limits.
|
||||||
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||||
|
cut.Gaps.Count == 0 &&
|
||||||
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
|
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
|
||||||
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
|
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
|
||||||
|
|
||||||
@@ -673,6 +677,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||||
|
cut.Gaps.Count == 0 &&
|
||||||
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
|
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
|
||||||
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
|
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ internal enum AutomaticEntryKind
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Which automatic entries a perimeter tabbed for support offers (see
|
/// Which automatic entries a perimeter tabbed for support offers (see
|
||||||
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalogue.
|
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalog.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal enum SupportEntries
|
internal enum SupportEntries
|
||||||
{
|
{
|
||||||
@@ -70,9 +70,11 @@ internal enum SupportEntries
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// One automatic start candidate: the owned contour choice plus its preference kind and a
|
/// One automatic start candidate: the owned contour choice plus its preference kind and a
|
||||||
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
|
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
|
||||||
/// owner; nothing here exposes or mutates the underlying shape.
|
/// owner; nothing here exposes or mutates the underlying shape. <paramref name="CornerReach"/>
|
||||||
|
/// is how far a corner fillet's joint lies from the sharp corner it rounds (0 otherwise), so
|
||||||
|
/// ranking can count it on both of that corner's sides.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
|
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
|
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using OpenNest.Geometry;
|
|||||||
namespace OpenNest.CNC.CuttingPlanning;
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure deterministic ordering of the automatic entry catalogue toward the next cut:
|
/// Pure deterministic ordering of the automatic entry catalog toward the next cut:
|
||||||
/// circles rank by outgoing distance; other contours use facing sides, tier and travel,
|
/// circles rank by outgoing distance; other contours use facing sides, tier and travel,
|
||||||
/// then a stable geometric key. It adds no
|
/// then a stable geometric key. It adds no
|
||||||
/// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility
|
/// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility
|
||||||
@@ -62,12 +62,12 @@ internal static class ContourEntryRanking
|
|||||||
if (p.Y > maxY) maxY = p.Y;
|
if (p.Y > maxY) maxY = p.Y;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Facing sides from the target relative to the centre, matching the source plan:
|
// Facing sides from the target relative to the center, matching the source plan:
|
||||||
// horizontal right when the target is right of centre else left; vertical likewise.
|
// horizontal right when the target is right of center else left; vertical likewise.
|
||||||
var centreX = minX + (maxX - minX) * 0.5;
|
var centerX = minX + (maxX - minX) * 0.5;
|
||||||
var centreY = minY + (maxY - minY) * 0.5;
|
var centerY = minY + (maxY - minY) * 0.5;
|
||||||
var facingRight = target != null && target.Value.X > centreX;
|
var facingRight = target != null && target.Value.X > centerX;
|
||||||
var facingTop = target != null && target.Value.Y > centreY;
|
var facingTop = target != null && target.Value.Y > centerY;
|
||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
.Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival)))
|
.Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival)))
|
||||||
@@ -107,16 +107,18 @@ internal static class ContourEntryRanking
|
|||||||
if (target != null)
|
if (target != null)
|
||||||
{
|
{
|
||||||
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
|
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
|
||||||
// sides within tolerance); count how many of them are facing sides.
|
// sides within tolerance, a fillet's joint within its reach of the corner it
|
||||||
|
// rounds); count how many of them are facing sides.
|
||||||
var left = p.X - minX;
|
var left = p.X - minX;
|
||||||
var right = maxX - p.X;
|
var right = maxX - p.X;
|
||||||
var bottom = p.Y - minY;
|
var bottom = p.Y - minY;
|
||||||
var top = maxY - p.Y;
|
var top = maxY - p.Y;
|
||||||
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
|
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
|
||||||
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
|
var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
|
||||||
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
|
if (facingRight && right <= near) facing++;
|
||||||
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
|
if (!facingRight && left <= near) facing++;
|
||||||
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++;
|
if (facingTop && top <= near) facing++;
|
||||||
|
if (!facingTop && bottom <= near) facing++;
|
||||||
}
|
}
|
||||||
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
|
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
|
||||||
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
|
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public enum CuttingCommitStatus
|
|||||||
Applied,
|
Applied,
|
||||||
Stale,
|
Stale,
|
||||||
InvalidInput,
|
InvalidInput,
|
||||||
Cancelled,
|
Canceled,
|
||||||
Failed
|
Failed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ internal static class CuttingPlanCommit
|
|||||||
Action<Plate, Part> beforeInstall)
|
Action<Plate, Part> beforeInstall)
|
||||||
{
|
{
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
|
||||||
var scope = plans?.ToArray();
|
var scope = plans?.ToArray();
|
||||||
if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
|
if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
|
||||||
return Invalid(null, "A nonempty set of captured plate plans is required.");
|
return Invalid(null, "A nonempty set of captured plate plans is required.");
|
||||||
@@ -126,7 +126,7 @@ internal static class CuttingPlanCommit
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
|
||||||
}
|
}
|
||||||
if (difference != null)
|
if (difference != null)
|
||||||
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
|
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
|
||||||
@@ -200,7 +200,7 @@ internal static class CuttingPlanCommit
|
|||||||
|
|
||||||
// Last cancellation point. The boundary below has no await, search or geometry work.
|
// Last cancellation point. The boundary below has no await, search or geometry work.
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
|
||||||
|
|
||||||
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States,
|
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States,
|
||||||
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
|
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ public static class ExecutionMotionReader
|
|||||||
var moves = new List<ExecutionMotion>();
|
var moves = new List<ExecutionMotion>();
|
||||||
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||||
var budget = 1000000;
|
var budget = 1000000;
|
||||||
Walk(program, origin, previous);
|
Walk(program, origin, previous, false, null);
|
||||||
return new OwnedExecution(moves);
|
return new OwnedExecution(moves);
|
||||||
|
|
||||||
Vector Walk(Program current, Vector frame, Vector? arrival)
|
// raiseArrival: a marked call raises the head for the rapid that arrives at its first motion.
|
||||||
|
// callCode: the part's own code that called this sub-program, or null for the part's own codes.
|
||||||
|
Vector Walk(Program current, Vector frame, Vector? arrival, bool raiseArrival, int? callCode)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
PostVerificationGeometry.Validate(frame);
|
PostVerificationGeometry.Validate(frame);
|
||||||
@@ -41,8 +43,10 @@ public static class ExecutionMotionReader
|
|||||||
var pos = frame;
|
var pos = frame;
|
||||||
var first = true;
|
var first = true;
|
||||||
var countBefore = moves.Count;
|
var countBefore = moves.Count;
|
||||||
foreach (var code in current.Codes)
|
var raised = HeadRaise.RaisedMask(current.Codes);
|
||||||
|
for (var codeIndex = 0; codeIndex < current.Codes.Count; codeIndex++)
|
||||||
{
|
{
|
||||||
|
var code = current.Codes[codeIndex];
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (--budget < 0)
|
if (--budget < 0)
|
||||||
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
||||||
@@ -70,7 +74,8 @@ public static class ExecutionMotionReader
|
|||||||
throw new ArgumentException("Subprogram rotation is not finite.");
|
throw new ArgumentException("Subprogram rotation is not finite.");
|
||||||
// Call rotation is baked into the shared program by its setter. Do not
|
// Call rotation is baked into the shared program by its setter. Do not
|
||||||
// rotate again; offsets are frame-relative even in incremental mode.
|
// rotate again; offsets are frame-relative even in incremental mode.
|
||||||
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
|
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos,
|
||||||
|
raised[codeIndex] || first && raiseArrival, callCode ?? codeIndex);
|
||||||
first = false;
|
first = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -91,7 +96,7 @@ public static class ExecutionMotionReader
|
|||||||
PostVerificationGeometry.Validate(end);
|
PostVerificationGeometry.Validate(end);
|
||||||
var rapid = motion is RapidMove;
|
var rapid = motion is RapidMove;
|
||||||
if (first && !rapid)
|
if (first && !rapid)
|
||||||
moves.Add(new(arrival, frame, true, LayerType.Display, null));
|
moves.Add(new(arrival, frame, true, LayerType.Display, null, raiseArrival, callCode: callCode));
|
||||||
var start = first && rapid ? arrival : pos;
|
var start = first && rapid ? arrival : pos;
|
||||||
var layer = motion switch
|
var layer = motion switch
|
||||||
{
|
{
|
||||||
@@ -106,7 +111,8 @@ public static class ExecutionMotionReader
|
|||||||
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
|
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
|
||||||
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
|
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
|
||||||
motion is ArcMove { Rotation: RotationType.CW });
|
motion is ArcMove { Rotation: RotationType.CW });
|
||||||
moves.Add(new(start, end, rapid, layer, curve));
|
moves.Add(new(start, end, rapid, layer, curve, raised[codeIndex] || first && rapid && raiseArrival,
|
||||||
|
ReferenceEquals(current, program) ? codeIndex : null, callCode));
|
||||||
pos = end;
|
pos = end;
|
||||||
first = false;
|
first = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ internal static class IncrementalClosureDrift
|
|||||||
var endPoint = last.EndPoint + correction;
|
var endPoint = last.EndPoint + correction;
|
||||||
if (last is ArcMove arc)
|
if (last is ArcMove arc)
|
||||||
{
|
{
|
||||||
// Keep the arc's centre (relative to its start) equidistant from its moved end.
|
// Keep the arc's center (relative to its start) equidistant from its moved end.
|
||||||
var chord = endPoint.DistanceTo(new Vector());
|
var chord = endPoint.DistanceTo(new Vector());
|
||||||
if (chord <= Tolerance.Epsilon)
|
if (chord <= Tolerance.Epsilon)
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -121,11 +121,16 @@ public sealed class LeadMaterialSnapshot
|
|||||||
? "A display perimeter must enclose every other contour."
|
? "A display perimeter must enclose every other contour."
|
||||||
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
||||||
}
|
}
|
||||||
|
// A ring encloses a point only inside the union of its curves' extents (each at least
|
||||||
|
// the curve's own reach), so a part with thousands of holes asks parity only where it can hold.
|
||||||
|
var ringExtents = rings.Select(ring => ring.Aggregate(PostVerificationGeometry.Extent.None,
|
||||||
|
(extent, curve) => extent.Union(curve.Extent))).ToArray();
|
||||||
|
bool Encloses(int ring, Vector point) => ringExtents[ring].Contains(point) && Inside(point, rings[ring], token);
|
||||||
var outer = -1;
|
var outer = -1;
|
||||||
for (var i = 0; i < rings.Count; i++)
|
for (var i = 0; i < rings.Count; i++)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token)))
|
if (Enumerable.Range(0, rings.Count).All(j => i == j || Encloses(i, rings[j][0].Start)))
|
||||||
{
|
{
|
||||||
if (outer >= 0)
|
if (outer >= 0)
|
||||||
throw new ArgumentException("Material perimeter is ambiguous.");
|
throw new ArgumentException("Material perimeter is ambiguous.");
|
||||||
@@ -142,10 +147,11 @@ public sealed class LeadMaterialSnapshot
|
|||||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||||
if (ringDisplay[outer] && rings.Count == 1)
|
if (ringDisplay[outer] && rings.Count == 1)
|
||||||
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
||||||
var holes = rings.Where((_, i) => i != outer).ToArray();
|
var holeRings = Enumerable.Range(0, rings.Count).Where(i => i != outer).ToArray();
|
||||||
for (var i = 0; i < holes.Length; i++)
|
var holes = holeRings.Select(i => rings[i]).ToArray();
|
||||||
for (var j = i + 1; j < holes.Length; j++)
|
for (var i = 0; i < holeRings.Length; i++)
|
||||||
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
|
for (var j = i + 1; j < holeRings.Length; j++)
|
||||||
|
if (Encloses(holeRings[j], holes[i][0].Start) || Encloses(holeRings[i], holes[j][0].Start))
|
||||||
throw new ArgumentException("Nested cutouts are not supported material.");
|
throw new ArgumentException("Nested cutouts are not supported material.");
|
||||||
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
|
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
|
||||||
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
|
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
|
||||||
|
|||||||
@@ -294,13 +294,25 @@ public static class LeadPathValidator
|
|||||||
if (--budget < 0)
|
if (--budget < 0)
|
||||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||||
var contacts = curve.Contacts(boundary, out var overlap);
|
var contacts = curve.Contacts(boundary, out var overlap);
|
||||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|
if (overlap || contacts.Any(p => permittedJoint is not { } joint || !AtJoint(p, joint, boundary)))
|
||||||
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
|
|
||||||
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
|
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
|
||||||
}
|
}
|
||||||
// With all other boundary contacts excluded, the connected open path
|
// With all other boundary contacts excluded, the connected open path
|
||||||
// has constant material membership. Use the native arc midpoint, not
|
// has constant material membership. Use the native arc midpoint, not
|
||||||
// the chord midpoint or endpoints (which can both lie in scrap).
|
// the chord midpoint or endpoints (which can both lie in scrap).
|
||||||
return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
|
return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
|
||||||
|
|
||||||
|
// A lead tangent to the boundary it joins (an arc lead-in meeting a straight edge) has a
|
||||||
|
// double root at the joint that rounding splits into two contacts a few 1e-8 apart; one
|
||||||
|
// within JointTolerance of a joint the boundary itself passes through is still the joint.
|
||||||
|
static bool AtJoint(Vector point, Vector joint, PostVerificationGeometry.Curve boundary)
|
||||||
|
{
|
||||||
|
var distance = point.DistanceTo(joint);
|
||||||
|
return distance <= PostVerificationGeometry.Epsilon
|
||||||
|
|| distance <= JointTolerance && boundary.Contains(joint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// As SlideContact's: far below any kerf, far above a tangent root's rounding drift.
|
||||||
|
private const double JointTolerance = 1e-6;
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingPlanning;
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
@@ -56,17 +55,4 @@ internal static class NestedPartContainment
|
|||||||
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
|
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
|
||||||
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter && m.Curve != null)
|
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter && m.Curve != null)
|
||||||
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
|
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
|
||||||
|
|
||||||
/// <summary>Whether a host's bounds admit an inner part's bounds as a containment candidate.</summary>
|
|
||||||
internal static bool Within(Box inner, Box outer) =>
|
|
||||||
inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
|
|
||||||
&& inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
|
|
||||||
|
|
||||||
/// <summary>The union of two boxes.</summary>
|
|
||||||
internal static Box Union(Box a, Box b)
|
|
||||||
{
|
|
||||||
var left = System.Math.Min(a.Left, b.Left);
|
|
||||||
var bottom = System.Math.Min(a.Bottom, b.Bottom);
|
|
||||||
return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ internal static class NestedPartReview
|
|||||||
continue;
|
continue;
|
||||||
var program = FixedLeadReview.Clean(part);
|
var program = FixedLeadReview.Clean(part);
|
||||||
cleanBounds[index] = NestedPartContainment.CleanBounds(program, part.Location, token);
|
cleanBounds[index] = NestedPartContainment.CleanBounds(program, part.Location, token);
|
||||||
hostBounds[index] = NestedPartContainment.Union(part.BoundingBox, cleanBounds[index]);
|
hostBounds[index] = BoundingBox.Union(part.BoundingBox, cleanBounds[index]);
|
||||||
clean[index] = program;
|
clean[index] = program;
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (IsInvalid(exception))
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
@@ -52,7 +52,7 @@ internal static class NestedPartReview
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (inner == host || clean[inner] == null || clean[host] == null
|
if (inner == host || clean[inner] == null || clean[host] == null
|
||||||
|| !NestedPartContainment.Within(cleanBounds[inner], hostBounds[host]))
|
|| !BoundingBox.IsWithin(cleanBounds[inner], hostBounds[host]))
|
||||||
continue;
|
continue;
|
||||||
var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token,
|
var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token,
|
||||||
out var ambiguity);
|
out var ambiguity);
|
||||||
|
|||||||
@@ -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,7 @@ public sealed class PlateCuttingState
|
|||||||
}
|
}
|
||||||
var cutOffs = plate.CutOffs.Select(c => c == null
|
var cutOffs = plate.CutOffs.Select(c => c == null
|
||||||
? throw new ArgumentException("Plate contains a missing cutoff definition.")
|
? throw new ArgumentException("Plate contains a missing cutoff definition.")
|
||||||
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit,
|
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit, c.Gaps,
|
||||||
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
|
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
|
||||||
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
||||||
|
|
||||||
@@ -157,6 +157,8 @@ public sealed class PlateCuttingState
|
|||||||
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|
||||||
|| !Bits(cutOff.Position.Y, record.Position.Y)
|
|| !Bits(cutOff.Position.Y, record.Position.Y)
|
||||||
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)
|
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)
|
||||||
|
// Gaps are replaced, never edited in place, so the reference decides.
|
||||||
|
|| !ReferenceEquals(cutOff.Gaps, record.Gaps)
|
||||||
|| !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
|
|| !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
|
||||||
return $"Cutoff {i + 1} definition changed.";
|
return $"Cutoff {i + 1} definition changed.";
|
||||||
}
|
}
|
||||||
@@ -206,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 HeadLockOvercut, bool FarEdgeFirst);
|
double? StartLimit, double? EndLimit, IReadOnlyList<CutOffGap> Gaps, double HeadLockOvercut,
|
||||||
|
bool FarEdgeFirst);
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,8 @@ public sealed class PreparedContours
|
|||||||
// Closed cutouts with tab spans, in program order. Like break cuts they are not choices:
|
// Closed cutouts with tab spans, in program order. Like break cuts they are not choices:
|
||||||
// every full or prefix emission cuts their pieces after the break cuts.
|
// every full or prefix emission cuts their pieces after the break cuts.
|
||||||
private readonly List<Shape> tabbedCutouts;
|
private readonly List<Shape> tabbedCutouts;
|
||||||
|
// Pierce-zone resolutions by contour, shared with every copy: the contours are the same by ordinal.
|
||||||
|
private readonly PierceZoneMemo pierceZones;
|
||||||
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and
|
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and
|
||||||
// its own owned settings (the pin's lead-in, lead-out and tab).
|
// its own owned settings (the pin's lead-in, lead-out and tab).
|
||||||
private readonly ContourChoice[] pinned;
|
private readonly ContourChoice[] pinned;
|
||||||
@@ -61,8 +63,9 @@ public sealed class PreparedContours
|
|||||||
}
|
}
|
||||||
|
|
||||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
||||||
CuttingParameters parameters, Shape displayOutline)
|
CuttingParameters parameters, Shape displayOutline, PierceZoneMemo pierceZones = null)
|
||||||
{
|
{
|
||||||
|
this.pierceZones = pierceZones ?? new PierceZoneMemo();
|
||||||
this.displayOutline = displayOutline;
|
this.displayOutline = displayOutline;
|
||||||
this.shapes = shapes;
|
this.shapes = shapes;
|
||||||
this.scribes = scribes;
|
this.scribes = scribes;
|
||||||
@@ -75,7 +78,7 @@ 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.displayOutline, unpinned.pierceZones)
|
||||||
{
|
{
|
||||||
contourParameters = new CuttingParameters[shapes.Length];
|
contourParameters = new CuttingParameters[shapes.Length];
|
||||||
var rejected = new List<CutPinResolution>();
|
var rejected = new List<CutPinResolution>();
|
||||||
@@ -113,7 +116,7 @@ public sealed class PreparedContours
|
|||||||
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review.
|
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review.
|
||||||
private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
|
private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
|
||||||
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
|
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
|
||||||
source.displayOutline)
|
source.displayOutline, source.pierceZones)
|
||||||
{
|
{
|
||||||
for (var contour = 0; contour < Count; contour++)
|
for (var contour = 0; contour < Count; contour++)
|
||||||
{
|
{
|
||||||
@@ -171,6 +174,9 @@ public sealed class PreparedContours
|
|||||||
|
|
||||||
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
|
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
|
||||||
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
|
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
|
||||||
|
|
||||||
|
/// <summary>The pierce-zone resolutions these contours' emissions share; for tests.</summary>
|
||||||
|
internal PierceZoneMemo PierceZones => pierceZones;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
|
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
|
||||||
/// is a display perimeter, so every contour below it is a hole.
|
/// is a display perimeter, so every contour below it is a hole.
|
||||||
@@ -180,7 +186,7 @@ public sealed class PreparedContours
|
|||||||
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
|
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
|
||||||
public bool HasCutPerimeter => hasCutPerimeter;
|
public bool HasCutPerimeter => hasCutPerimeter;
|
||||||
|
|
||||||
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
|
/// <summary>Resolved pins this preparation could not honor: a tab on an internal contour.</summary>
|
||||||
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
||||||
|
|
||||||
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
||||||
@@ -465,7 +471,9 @@ public sealed class PreparedContours
|
|||||||
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
|
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
|
||||||
return Array.Empty<ContourChoice>();
|
return Array.Empty<ContourChoice>();
|
||||||
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
|
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
|
||||||
var found = ErodedPierce.Find(shape, ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
|
var depth = System.Math.Max(ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
|
||||||
|
ApplicableLeadInLength(contourOrdinal));
|
||||||
|
var found = ErodedPierce.Find(shape, depth,
|
||||||
targets.Select(t => t.Point).ToArray(), max);
|
targets.Select(t => t.Point).ToArray(), max);
|
||||||
var entries = new List<ContourChoice>();
|
var entries = new List<ContourChoice>();
|
||||||
for (var i = 0; i < found.Pierces.Count; i++)
|
for (var i = 0; i < found.Pierces.Count; i++)
|
||||||
@@ -510,7 +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
|
||||||
@@ -526,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 SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
|
return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The complete uncapped automatic start catalogue: the preferred points of
|
/// The complete uncapped automatic start catalog: the preferred points of
|
||||||
/// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc
|
/// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc
|
||||||
/// midpoints, near-convex-corner points on straight edges, the eight compass points of a
|
/// midpoints, near-convex-corner points on straight edges, the eight compass points of a
|
||||||
/// whole circle, and the exact target-facing closest point toward
|
/// whole circle, and the exact target-facing closest point toward
|
||||||
@@ -539,7 +547,7 @@ public sealed class PreparedContours
|
|||||||
/// A pure-circle contour therefore yields compass points instead of refusing. Every
|
/// A pure-circle contour therefore yields compass points instead of refusing. Every
|
||||||
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
|
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
|
||||||
/// preferred tier; fallbacks never replace a preferred point at the same geometry. A
|
/// preferred tier; fallbacks never replace a preferred point at the same geometry. A
|
||||||
/// pinned contour's catalogue is its pinned entry alone: nothing may relocate it.
|
/// pinned contour's catalog is its pinned entry alone: nothing may relocate it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
||||||
int contourOrdinal,
|
int contourOrdinal,
|
||||||
@@ -560,7 +568,7 @@ public sealed class PreparedContours
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
|
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
|
||||||
/// its automatic catalogue then keeps to steadying points unless asked for a fallback.
|
/// its automatic catalog then keeps to steadying points unless asked for a fallback.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
|
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
|
||||||
|
|
||||||
@@ -601,8 +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>
|
||||||
@@ -647,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();
|
||||||
@@ -761,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)
|
||||||
{
|
{
|
||||||
@@ -783,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);
|
||||||
@@ -792,29 +802,65 @@ public sealed class PreparedContours
|
|||||||
switch (corner.Kind)
|
switch (corner.Kind)
|
||||||
{
|
{
|
||||||
case ContourCuttingStrategy.CornerKind.Convex:
|
case ContourCuttingStrategy.CornerKind.Convex:
|
||||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
|
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
|
||||||
break;
|
break;
|
||||||
case ContourCuttingStrategy.CornerKind.Smooth
|
case ContourCuttingStrategy.CornerKind.Smooth
|
||||||
when entity is Line && Next(i) is Arc:
|
when entity is Line && Next(i) is Arc:
|
||||||
// A line leaving into an arc: the tangent joint. The reverse travel
|
// A line leaving into an arc: the tangent joint. The reverse travel
|
||||||
// order classifies the same joint from the arc, matched below.
|
// order classifies the same joint from the arc, matched below.
|
||||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
found.Add(Joint(i, vertex, (i + 1) % count));
|
||||||
break;
|
break;
|
||||||
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
||||||
// of a plain straight edge are not preferred automatic starts here.
|
// of a plain straight edge are not preferred automatic starts here.
|
||||||
case ContourCuttingStrategy.CornerKind.Smooth
|
case ContourCuttingStrategy.CornerKind.Smooth
|
||||||
when entity is Arc && Next(i) is Line:
|
when entity is Arc && Next(i) is Line:
|
||||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
found.Add(Joint(i, vertex, i));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entity is Line line)
|
if (entity is Line line)
|
||||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
|
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
|
|
||||||
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
||||||
|
|
||||||
|
(int, Vector, AutomaticEntryKind, double) Joint(int entityOrdinal, Vector vertex, int arcOrdinal) =>
|
||||||
|
FilletReach(shape, arcOrdinal) is { } reach
|
||||||
|
? (entityOrdinal, vertex, AutomaticEntryKind.ConvexCorner, reach)
|
||||||
|
: (entityOrdinal, vertex, AutomaticEntryKind.TangentJoint, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How far the joints of the arc at <paramref name="arcOrdinal"/> lie from the sharp corner it
|
||||||
|
/// rounds, when it is a corner fillet: tangent to straight edges at both ends, turning less
|
||||||
|
/// than a half circle the convex way, and reaching back along each edge no further than the
|
||||||
|
/// edge is long. A fillet's joints start the cut at the corner, where the mismatch when the
|
||||||
|
/// cut closes is least seen, instead of on a straight edge. Null for any other arc.
|
||||||
|
/// </summary>
|
||||||
|
private static double? FilletReach(Shape shape, int arcOrdinal)
|
||||||
|
{
|
||||||
|
var count = shape.Entities.Count;
|
||||||
|
if (shape.Entities[arcOrdinal] is not Arc arc
|
||||||
|
|| shape.Entities[(arcOrdinal + count - 1) % count] is not Line before
|
||||||
|
|| shape.Entities[(arcOrdinal + 1) % count] is not Line after)
|
||||||
|
return null;
|
||||||
|
var sweep = arc.SweepAngle();
|
||||||
|
if (sweep >= System.Math.PI - OpenNest.Math.Tolerance.Epsilon)
|
||||||
|
return null;
|
||||||
|
if (!IsSmooth(before.EndPoint, before) || !IsSmooth(arc.EndPoint(), arc))
|
||||||
|
return null;
|
||||||
|
var winding = ContourCuttingStrategy.DetermineWinding(shape);
|
||||||
|
if (ContourCuttingStrategy.ClassifyTurn((before.EndPoint - before.StartPoint).Normalize(),
|
||||||
|
(after.EndPoint - after.StartPoint).Normalize(), winding) != ContourCuttingStrategy.CornerKind.Convex)
|
||||||
|
return null;
|
||||||
|
var reach = arc.Radius * System.Math.Tan(sweep / 2);
|
||||||
|
return reach <= System.Math.Min(before.Length, after.Length) ? reach : null;
|
||||||
|
|
||||||
|
bool IsSmooth(Vector vertex, Entity entity) =>
|
||||||
|
ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)
|
||||||
|
&& corner.Kind == ContourCuttingStrategy.CornerKind.Smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
||||||
@@ -842,7 +888,7 @@ public sealed class PreparedContours
|
|||||||
// reads only the chosen contour and the contour count, so only that contour is cloned.
|
// reads only the chosen contour and the contour count, so only that contour is cloned.
|
||||||
var owned = new Shape[shapes.Length];
|
var owned = new Shape[shapes.Length];
|
||||||
owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
|
owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
|
||||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
|
return new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitCandidateIsolated(
|
||||||
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal);
|
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,7 +901,7 @@ public sealed class PreparedContours
|
|||||||
|
|
||||||
// Only a complete emission carries a display perimeter's tail: prefixes are search probes.
|
// Only a complete emission carries a display perimeter's tail: prefixes are search probes.
|
||||||
private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) =>
|
private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) =>
|
||||||
new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
|
new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitPrepared(
|
||||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
|
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
|
||||||
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
|
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
|
||||||
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal,
|
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal,
|
||||||
@@ -925,20 +971,20 @@ 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>
|
/// <summary>The bounding box of contour <paramref name="contourOrdinal"/>, local. A routing hint only.</summary>
|
||||||
@@ -960,7 +1006,7 @@ public sealed class PreparedContours
|
|||||||
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
|
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
|
||||||
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
|
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
|
||||||
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
|
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
|
||||||
/// stored centre sits beside the chord's midpoint) is never cut.
|
/// stored center sits beside the chord's midpoint) is never cut.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
|
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ 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
|
// How the last analysed part ended: the contours it numbered, and whether nothing it left
|
||||||
// (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
|
// (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
|
||||||
// motions appended to it are judged. See Continue.
|
// motions appended to it are judged. See Continue.
|
||||||
@@ -22,14 +25,20 @@ public sealed class ReleasedContourState
|
|||||||
|
|
||||||
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
|
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
|
||||||
/// may pierce directly on a contour and checks travel without requiring a lead.</param>
|
/// may pierce directly on a contour and checks travel without requiring a lead.</param>
|
||||||
public ReleasedContourState(bool reportMissingLeadIns)
|
public ReleasedContourState(bool reportMissingLeadIns) : this(reportMissingLeadIns, null) { }
|
||||||
|
|
||||||
|
/// <param name="tabMayNotHold">Part numbers whose support tab may not hold them level (no
|
||||||
|
/// single tab can steady them): the longest open cut a part leaves, its tabbed
|
||||||
|
/// perimeter, becomes an obstacle when the part ends, closed across its tab gap.</param>
|
||||||
|
public ReleasedContourState(bool reportMissingLeadIns, Func<int, bool> tabMayNotHold)
|
||||||
{
|
{
|
||||||
this.reportMissingLeadIns = 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,
|
partContours = partContours,
|
||||||
partClosed = partClosed,
|
partClosed = partClosed,
|
||||||
@@ -54,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)
|
||||||
{
|
{
|
||||||
@@ -67,8 +78,10 @@ public sealed class ReleasedContourState
|
|||||||
moves[0] = moves[0].WithStart(arrival);
|
moves[0] = moves[0].WithStart(arrival);
|
||||||
var findings = new List<PostVerificationFinding>();
|
var findings = new List<PostVerificationFinding>();
|
||||||
var fixedFindings = new List<PostVerificationFinding>();
|
var fixedFindings = new List<PostVerificationFinding>();
|
||||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
|
var raisedFindings = new List<PostVerificationFinding>();
|
||||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
|
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings,
|
||||||
|
raisedFindings: raisedFindings);
|
||||||
|
return (findings.AsReadOnly(), fixedFindings.AsReadOnly(), raisedFindings.AsReadOnly());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -97,7 +110,7 @@ public sealed class ReleasedContourState
|
|||||||
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
||||||
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
|
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
|
||||||
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
|
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
|
||||||
bool proveOpenCuts = false, int contoursBefore = 0)
|
int contoursBefore = 0, List<PostVerificationFinding> raisedFindings = null)
|
||||||
{
|
{
|
||||||
var current = new CutGroup();
|
var current = new CutGroup();
|
||||||
var unfinished = new List<CutGroup>();
|
var unfinished = new List<CutGroup>();
|
||||||
@@ -113,11 +126,19 @@ public sealed class ReleasedContourState
|
|||||||
var contourNumber = contoursBefore;
|
var contourNumber = contoursBefore;
|
||||||
var inBreakCut = false;
|
var inBreakCut = false;
|
||||||
var reportedTab = false;
|
var reportedTab = false;
|
||||||
|
// The contour last closed, and the one a lead-out has just left it into: a cutout's
|
||||||
|
// lead-out ends in its slug, so the rapid after it must leave over that cutout's own cut.
|
||||||
|
CutGroup lastClosed = null;
|
||||||
|
Obstacle lastObstacle = null;
|
||||||
|
Obstacle exited = null;
|
||||||
|
var exitPoint = Vector.Zero;
|
||||||
for (var index = 0; index < moves.Count; index++)
|
for (var index = 0; index < moves.Count; index++)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var move = moves[index];
|
var move = moves[index];
|
||||||
var owned = isFixed?.Invoke(index) == true;
|
var owned = isFixed?.Invoke(index) == true;
|
||||||
|
if (!move.Rapid && move.Layer != LayerType.Leadout)
|
||||||
|
exited = null;
|
||||||
if (!move.Rapid && move.Layer == LayerType.Break)
|
if (!move.Rapid && move.Layer == LayerType.Break)
|
||||||
{
|
{
|
||||||
// A break cut is intentionally open and retained by its uncut tabs: it is never a
|
// A break cut is intentionally open and retained by its uncut tabs: it is never a
|
||||||
@@ -148,8 +169,15 @@ public sealed class ReleasedContourState
|
|||||||
loose.Leads.AddRange(leadIns);
|
loose.Leads.AddRange(leadIns);
|
||||||
leadIns.Clear();
|
leadIns.Clear();
|
||||||
previous = null;
|
previous = null;
|
||||||
|
var leaving = exited;
|
||||||
|
exited = null;
|
||||||
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
|
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
|
||||||
continue;
|
continue;
|
||||||
|
// A raised rapid out of a slug its cutout's lead-out ended in is the designed way
|
||||||
|
// out: it may cross that cutout once, and passes over the rest with the head up.
|
||||||
|
if (move.HeadRaised && leaving != null && start.DistanceTo(exitPoint) <= PostVerificationGeometry.Epsilon
|
||||||
|
&& LeavesOnce(leaving, start, move.End, token))
|
||||||
|
continue;
|
||||||
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
|
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
|
||||||
System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X),
|
System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X),
|
||||||
System.Math.Max(start.Y, move.End.Y));
|
System.Math.Max(start.Y, move.End.Y));
|
||||||
@@ -166,16 +194,24 @@ public sealed class ReleasedContourState
|
|||||||
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
|
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
|
||||||
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
|
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
|
||||||
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
|
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
|
||||||
if (PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
|
if (!PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
|
||||||
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
continue;
|
||||||
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
|
||||||
$"of part {obstacle.Part}.")
|
obstacle.Part, (move.HeadRaised ? "Head-raised" : "Direct") + " XY rapid crosses or touches "
|
||||||
{
|
+ (obstacle.TabMayNotHold
|
||||||
Location = (start + move.End) * 0.5,
|
? $"the tabbed outside of part {obstacle.Part}, which its tab may not hold level."
|
||||||
RapidStart = start,
|
: $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}."))
|
||||||
RapidEnd = move.End,
|
{
|
||||||
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
Location = (start + move.End) * 0.5,
|
||||||
}, owned);
|
RapidStart = start,
|
||||||
|
RapidEnd = move.End,
|
||||||
|
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
||||||
|
};
|
||||||
|
// The head clears a cut part on a raised rapid; the crossing is the raise's reason.
|
||||||
|
if (move.HeadRaised)
|
||||||
|
raisedFindings?.Add(crossing);
|
||||||
|
else
|
||||||
|
Add(crossing, owned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (move.Layer == LayerType.Leadin)
|
else if (move.Layer == LayerType.Leadin)
|
||||||
@@ -196,6 +232,11 @@ public sealed class ReleasedContourState
|
|||||||
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
|
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
|
||||||
"Rapid safety for that contour requires manual review."), owned);
|
"Rapid safety for that contour requires manual review."), owned);
|
||||||
(previous ?? loose).Leads.Add(move.Curve);
|
(previous ?? loose).Leads.Add(move.Curve);
|
||||||
|
if (previous != null && ReferenceEquals(previous, lastClosed))
|
||||||
|
{
|
||||||
|
exited = lastObstacle;
|
||||||
|
exitPoint = move.Curve.End;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Finish();
|
Finish();
|
||||||
hasLead = false;
|
hasLead = false;
|
||||||
@@ -251,14 +292,13 @@ public sealed class ReleasedContourState
|
|||||||
Finish();
|
Finish();
|
||||||
loose.Leads.AddRange(leadIns);
|
loose.Leads.AddRange(leadIns);
|
||||||
// Open cuts that provably enclose nothing retain all their material, however many
|
// Open cuts that provably enclose nothing retain all their material, however many
|
||||||
// pieces a tabbed cutout was cut in. Otherwise they need manual review. Planning proves
|
// pieces a tabbed cutout was cut in, and so does a tabbed perimeter whose lead-out stays
|
||||||
// them too (its own tabbed cutouts are cut in pieces without lead-outs), but only
|
// out of its tab: planning and pre-post review prove them alike. Otherwise they need
|
||||||
// pre-post review lets the proof clear a lead-out: planning keeps refusing to certify
|
// manual review.
|
||||||
// its own tabs followed by a lead-out.
|
|
||||||
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.
|
||||||
@@ -267,6 +307,16 @@ 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;
|
partContours = contourNumber;
|
||||||
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
|
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
|
||||||
&& !inBreakCut && loose.Leads.Count == 0;
|
&& !inBreakCut && loose.Leads.Count == 0;
|
||||||
@@ -294,8 +344,10 @@ public sealed class ReleasedContourState
|
|||||||
// no flag or tab configuration is used as evidence of retention.
|
// no flag or tab configuration is used as evidence of retention.
|
||||||
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
|
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
|
||||||
{
|
{
|
||||||
obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd));
|
lastObstacle = new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd);
|
||||||
|
obstacles.Add(lastObstacle);
|
||||||
closed.Add(current);
|
closed.Add(current);
|
||||||
|
lastClosed = current;
|
||||||
}
|
}
|
||||||
else if (!cutoff)
|
else if (!cutoff)
|
||||||
unfinished.Add(current);
|
unfinished.Add(current);
|
||||||
@@ -303,6 +355,16 @@ public sealed class ReleasedContourState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the rapid from <paramref name="start"/> inside <paramref name="cutout"/> to
|
||||||
|
/// <paramref name="end"/> outside it meets its cut just once, on the way out: a rapid that
|
||||||
|
/// leaves a re-entrant cutout and passes back over it crosses the cut where the slug may lie.
|
||||||
|
/// </summary>
|
||||||
|
private static bool LeavesOnce(Obstacle cutout, Vector start, Vector end, CancellationToken token) =>
|
||||||
|
PostVerificationGeometry.Inside(start, cutout.Curves, token)
|
||||||
|
&& !PostVerificationGeometry.Inside(end, cutout.Curves, token)
|
||||||
|
&& PostVerificationGeometry.ContactPoints(start, end, cutout.Curves, token).Count == 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The closing piece of a contour cut on past its own start along its first cut, as a source
|
/// The closing piece of a contour cut on past its own start along its first cut, as a source
|
||||||
/// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
|
/// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
|
||||||
@@ -333,7 +395,7 @@ public sealed class ReleasedContourState
|
|||||||
/// contour with its own leads already encloses whatever it releases and counts as one node.
|
/// contour with its own leads already encloses whatever it releases and counts as one node.
|
||||||
/// Open cuts may touch any cut only at their own ends, and must form no loop through those
|
/// Open cuts may touch any cut only at their own ends, and must form no loop through those
|
||||||
/// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and
|
/// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and
|
||||||
/// tab strength are not modelled, exactly as for a single open contour.
|
/// tab strength are not modeled, exactly as for a single open contour.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const double JointTolerance = 1e-5;
|
private const double JointTolerance = 1e-5;
|
||||||
|
|
||||||
@@ -493,5 +555,8 @@ public sealed class ReleasedContourState
|
|||||||
{
|
{
|
||||||
internal PostVerificationGeometry.Extent Extent { get; } =
|
internal PostVerificationGeometry.Extent Extent { get; } =
|
||||||
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
|
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
|
||||||
|
|
||||||
|
/// <summary>A tabbed perimeter closed across its tab gap, for a part its tab may not hold level.</summary>
|
||||||
|
internal bool TabMayNotHold { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,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);
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -306,15 +312,25 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var shared = Parameters;
|
var shared = Parameters;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var inSlug = false;
|
||||||
foreach (var choice in choices)
|
foreach (var choice in choices)
|
||||||
{
|
{
|
||||||
// A contour's own settings apply to its emission alone; scribes and every
|
// A contour's own settings apply to its emission alone; scribes and every
|
||||||
// other contour keep the shared settings.
|
// other contour keep the shared settings.
|
||||||
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
|
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
|
||||||
var shape = shapes[choice.ContourOrdinal];
|
var shape = shapes[choice.ContourOrdinal];
|
||||||
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
var arrival = result.Codes.Count;
|
||||||
|
var leftInSlug = EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||||
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
|
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
|
||||||
exactCirclePrograms: true, scrapPierce: choice.Pierce);
|
exactCirclePrograms: true, scrapPierce: choice.Pierce,
|
||||||
|
stopAtCenterLine: contourParameters?[choice.ContourOrdinal] == null,
|
||||||
|
memoContour: choice.ContourOrdinal);
|
||||||
|
// A cutout's lead-out ends in its slug, which may drop or tip once cut free, so
|
||||||
|
// the rapid out over the cut is made head up. An overcut or no lead-out leaves
|
||||||
|
// from the cut itself and stays head down.
|
||||||
|
if (inSlug && arrival < result.Codes.Count && result.Codes[arrival] is RapidMove or SubProgramCall)
|
||||||
|
result.Codes.Insert(arrival, new Comment(HeadRaise.Marker));
|
||||||
|
inSlug = leftInSlug;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -433,14 +449,17 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y);
|
private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y);
|
||||||
private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
|
private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
|
||||||
|
|
||||||
private void EmitContour(
|
/// <returns>Whether the contour is a cutout left by a lead-out, which ends in its slug.</returns>
|
||||||
|
private bool EmitContour(
|
||||||
Program program,
|
Program program,
|
||||||
Shape shape,
|
Shape shape,
|
||||||
Vector point,
|
Vector point,
|
||||||
Entity entity,
|
Entity entity,
|
||||||
ContourType? forceType = null,
|
ContourType? forceType = null,
|
||||||
bool exactCirclePrograms = false,
|
bool exactCirclePrograms = false,
|
||||||
Vector? scrapPierce = null
|
Vector? scrapPierce = null,
|
||||||
|
bool stopAtCenterLine = false,
|
||||||
|
int? memoContour = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var contourType = forceType ?? DetectContourType(shape);
|
var contourType = forceType ?? DetectContourType(shape);
|
||||||
@@ -472,9 +491,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
leadIn = StraightLeadIn(point, holePierce, out normal);
|
leadIn = StraightLeadIn(point, holePierce, out normal);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
if (stopAtCenterLine)
|
||||||
|
leadIn = StopAtCenterLine(shape, point, leadIn, normal, winding);
|
||||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
||||||
Parameters.GetEffectivePierceClearance());
|
Parameters.GetEffectivePierceClearance());
|
||||||
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius(), winding);
|
leadIn = PierceZone(memoContour, shape, point, leadIn, normal, winding);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build hole sub-program relative to (0,0)
|
// Build hole sub-program relative to (0,0)
|
||||||
@@ -492,7 +513,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
|
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
|
||||||
|
|
||||||
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
|
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
|
||||||
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding));
|
var holeLeadOut = leadOut.Generate(relativePoint, normal, winding);
|
||||||
|
subPgm.Codes.AddRange(holeLeadOut);
|
||||||
subPgm.Mode = Mode.Incremental;
|
subPgm.Mode = Mode.Incremental;
|
||||||
|
|
||||||
// Deduplicate: check if an identical sub-program already exists
|
// Deduplicate: check if an identical sub-program already exists
|
||||||
@@ -511,11 +533,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return holeLeadOut.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var turn = LeadTurn(contourType, winding);
|
var turn = LeadTurn(contourType, winding);
|
||||||
double leadInNormal;
|
double leadInNormal;
|
||||||
|
var configuredLeadIn = leadIn;
|
||||||
// A planner-placed scrap pierce replaces the configured lead-in with a straight one.
|
// A planner-placed scrap pierce replaces the configured lead-in with a straight one.
|
||||||
if (scrapPierce is { } pierce)
|
if (scrapPierce is { } pierce)
|
||||||
leadIn = StraightLeadIn(point, pierce, out leadInNormal);
|
leadIn = StraightLeadIn(point, pierce, out leadInNormal);
|
||||||
@@ -524,9 +547,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
||||||
Parameters.GetEffectivePierceClearance(), out leadInNormal);
|
Parameters.GetEffectivePierceClearance(), out leadInNormal);
|
||||||
if (contourType == ContourType.Internal)
|
if (contourType == ContourType.Internal)
|
||||||
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius(), turn);
|
{
|
||||||
|
if (stopAtCenterLine)
|
||||||
|
leadIn = StopAtCenterLine(shape, point, leadIn, leadInNormal, turn);
|
||||||
|
leadIn = PierceZone(memoContour, shape, point, leadIn, leadInNormal, turn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
|
|
||||||
|
|
||||||
var reindexedShape = shape.ReindexAt(point, entity);
|
var reindexedShape = shape.ReindexAt(point, entity);
|
||||||
|
|
||||||
@@ -539,6 +565,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var leadOutNormal = normal;
|
var leadOutNormal = normal;
|
||||||
if (tabbed)
|
if (tabbed)
|
||||||
{
|
{
|
||||||
|
var first = reindexedShape.Entities.Count > 0 ? reindexedShape.Entities[0] : null;
|
||||||
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
||||||
|
|
||||||
// Leave from where the trimmed cut actually ends, on that entity's normal:
|
// Leave from where the trimmed cut actually ends, on that entity's normal:
|
||||||
@@ -548,6 +575,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var last = reindexedShape.Entities[^1];
|
var last = reindexedShape.Entities[^1];
|
||||||
leadOutPoint = EntityEndPoint(last);
|
leadOutPoint = EntityEndPoint(last);
|
||||||
leadOutNormal = ComputeNormal(leadOutPoint, last, contourType, winding);
|
leadOutNormal = ComputeNormal(leadOutPoint, last, contourType, winding);
|
||||||
|
|
||||||
|
// Straight leads either side of a tab that wraps a corner share its bisector,
|
||||||
|
// as they share the edge normal on a straight edge.
|
||||||
|
if (ReferenceEquals(leadIn, configuredLeadIn) && leadIn is LineLeadIn lineIn
|
||||||
|
&& leadOut is LineLeadOut lineOut && first is Line or Arc
|
||||||
|
&& TabCornerNormal(shape, point, TravelTangent(first, point), leadInNormal, lineIn,
|
||||||
|
leadOutPoint, TravelTangent(last, leadOutPoint), leadOutNormal, lineOut, winding,
|
||||||
|
turn, Parameters.GetEffectivePierceClearance()) is { } tabNormal)
|
||||||
|
{
|
||||||
|
leadInNormal = tabNormal;
|
||||||
|
leadOutNormal = tabNormal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -556,8 +595,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
Parameters.GetEffectivePierceClearance(), out leadOutNormal);
|
Parameters.GetEffectivePierceClearance(), out leadOutNormal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
|
||||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||||
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, turn));
|
var leadOutCodes = leadOut.Generate(leadOutPoint, leadOutNormal, turn);
|
||||||
|
program.Codes.AddRange(leadOutCodes);
|
||||||
|
return contourType != ContourType.External && leadOutCodes.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -867,10 +909,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)
|
||||||
@@ -899,6 +949,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
|
||||||
@@ -1050,16 +1140,28 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Keeps a hole's lead-in pierce <paramref name="pierceRadius"/> clear of the hole wall.
|
/// Keeps a hole's lead-in pierce <paramref name="pierceRadius"/> clear of the hole wall.
|
||||||
/// A lead-in whose pierce already keeps it, or that pierces on the contour itself, is
|
/// A lead-in whose pierce already keeps it, or that pierces on the contour itself, is
|
||||||
/// returned as given. Otherwise the pierce moves onto the line from
|
/// returned as given. Otherwise the pierce moves straight in from the wall to the hole's
|
||||||
/// <paramref name="point"/> toward the hole's pole of inaccessibility (the centre of a
|
/// center line, or the lead's own length in when that comes first (see
|
||||||
/// round hole), as close to the contour as the zone allows, and the lead becomes a
|
/// <see cref="StopAtCenterLine"/>). A lead that cannot keep the zone there moves onto the
|
||||||
/// straight line from there, generated with <paramref name="normal"/>. A hole too small
|
/// line from <paramref name="point"/> toward the nearest point that keeps it (see
|
||||||
|
/// <see cref="ErodedPierce"/>) or else the hole's pole of inaccessibility (the center of a
|
||||||
|
/// round hole), as close to the contour as the zone allows. Either way the lead becomes
|
||||||
|
/// a straight line from there, generated with <paramref name="normal"/>. A hole too small
|
||||||
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and
|
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and
|
||||||
/// pre-post review still report the shortfall. Reads only the hole's own geometry, so it
|
/// pre-post review still report the shortfall. Reads only the hole's own geometry, so it
|
||||||
/// knows nothing of parts nested inside the hole. Shared by program generation and the
|
/// knows nothing of parts nested inside the hole. Shared by program generation and the
|
||||||
/// manual placement preview. <paramref name="turn"/> is the hole's
|
/// manual placement preview. <paramref name="turn"/> is the hole's
|
||||||
/// <see cref="LeadTurn"/>, which places a curved lead's pierce.
|
/// <see cref="LeadTurn"/>, which places a curved lead's pierce.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
// The memoized resolution for a contour the preparation emits by ordinal.
|
||||||
|
private LeadIn PierceZone(int? contour, Shape hole, Vector point, LeadIn leadIn, double normal, RotationType turn)
|
||||||
|
{
|
||||||
|
var radius = Parameters.GetPierceZoneRadius();
|
||||||
|
return PierceZones != null && contour is { } ordinal
|
||||||
|
? PierceZones.Resolve(ordinal, hole, point, leadIn, normal, radius, turn)
|
||||||
|
: ResolvePierceZone(hole, point, leadIn, normal, radius, turn);
|
||||||
|
}
|
||||||
|
|
||||||
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal,
|
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal,
|
||||||
double pierceRadius, RotationType turn)
|
double pierceRadius, RotationType turn)
|
||||||
{
|
{
|
||||||
@@ -1071,29 +1173,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, turn)))
|
var configured = leadIn.GetPiercePoint(point, normal, turn);
|
||||||
|
if (Clear(configured))
|
||||||
return leadIn;
|
return leadIn;
|
||||||
|
|
||||||
|
var depth = CenterLineDepth(hole, point, normal, configured.DistanceTo(point));
|
||||||
|
if (depth > Math.Tolerance.Epsilon && Clear(point + Inward(normal) * depth))
|
||||||
|
return new LineLeadIn { Length = depth, ApproachAngle = 90 };
|
||||||
|
|
||||||
var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
|
var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
|
||||||
System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells);
|
System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells);
|
||||||
if (!Inside(pole))
|
if (!Inside(pole))
|
||||||
return leadIn;
|
return leadIn;
|
||||||
|
|
||||||
// Clearance grows monotonically toward the pole of a convex hole, so the bisection
|
// The pole of a letter can be a stroke away: head for the stroke beside the entry, on
|
||||||
|
// its center line when the lead reaches that far.
|
||||||
|
var reach = configured.DistanceTo(point);
|
||||||
|
var toward = pole;
|
||||||
|
if (circle == null && ErodedPierce.Find(hole, System.Math.Max(pierceRadius, reach), new[] { point }, 1)
|
||||||
|
.Pierces is [var eroded] && Clear(eroded))
|
||||||
|
toward = eroded;
|
||||||
|
|
||||||
|
// Clearance grows monotonically along this line in a convex hole, so the bisection
|
||||||
// finds the shortest clear lead; in other holes it still ends on a clear point.
|
// finds the shortest clear lead; in other holes it still ends on a clear point.
|
||||||
var pierce = pole;
|
var pierce = toward;
|
||||||
if (Clear(pole))
|
if (Clear(toward) && (reach < pierceRadius || toward == pole))
|
||||||
{
|
{
|
||||||
double low = 0, high = 1;
|
double low = 0, high = 1;
|
||||||
for (var i = 0; i < 40; i++)
|
for (var i = 0; i < 40; i++)
|
||||||
{
|
{
|
||||||
var middle = (low + high) / 2;
|
var middle = (low + high) / 2;
|
||||||
if (Clear(point + (pole - point) * middle))
|
if (Clear(point + (toward - point) * middle))
|
||||||
high = middle;
|
high = middle;
|
||||||
else
|
else
|
||||||
low = middle;
|
low = middle;
|
||||||
}
|
}
|
||||||
pierce = point + (pole - point) * high;
|
pierce = point + (toward - point) * high;
|
||||||
}
|
}
|
||||||
|
|
||||||
var length = pierce.DistanceTo(point);
|
var length = pierce.DistanceTo(point);
|
||||||
@@ -1114,6 +1229,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.
|
||||||
@@ -1127,6 +1333,11 @@ 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
|
||||||
@@ -1147,6 +1358,22 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize)
|
private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize)
|
||||||
{
|
{
|
||||||
|
// A round perimeter is one circle entered at `center`: cut it as an arc from the entry
|
||||||
|
// that stops the tab's chord short of it, travelling the circle's own way.
|
||||||
|
if (shape.Entities is [Circle round] && round.Radius > tabSize / 2)
|
||||||
|
{
|
||||||
|
var entry = round.Center.AngleTo(center);
|
||||||
|
var gap = 2 * 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);
|
||||||
|
|
||||||
@@ -1323,7 +1550,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>
|
||||||
|
|||||||
@@ -81,9 +81,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
/// With <see cref="SupportTabs"/> on a plate with a support bed, the longest part (the
|
/// With <see cref="SupportTabs"/> on a plate with a support bed, the longest part (the
|
||||||
/// long side of its outline's minimum-area bounding rectangle) its tab alone holds: such a
|
/// long side of its outline's minimum-area bounding rectangle) its tab alone holds: such a
|
||||||
/// part the bed cannot hold level is tabbed, and any tab clear of inside corners steadies
|
/// part the bed cannot hold level is tabbed, and any tab clear of inside corners steadies
|
||||||
/// it. 0 turns the rule off.
|
/// it. 0 turns the rule off. New settings start at 6; settings saved without it read as 0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double TabHeldMaxLength { get; set; }
|
public double TabHeldMaxLength { get; set; } = 6;
|
||||||
|
|
||||||
/// <summary>Whether a part <paramref name="length"/> long is held by its tab alone (see <see cref="TabHeldMaxLength"/>).</summary>
|
/// <summary>Whether a part <paramref name="length"/> long is held by its tab alone (see <see cref="TabHeldMaxLength"/>).</summary>
|
||||||
public bool TabHoldsAt(double length) =>
|
public bool TabHoldsAt(double length) =>
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The pierce search for cutouts too small for their configured lead-in (text, slots, small
|
/// The pierce search for cutouts too small for their configured lead-in (text, slots, small
|
||||||
/// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
|
/// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
|
||||||
/// The erosion goes as deep as the pierce clearance when the cutout has room for it, else
|
/// The erosion goes as deep as the caller asks when the cutout has room for it, else almost
|
||||||
/// almost to its pole of inaccessibility. Each target, an entry the planner ranked, gets the
|
/// to its pole of inaccessibility; a round hole is pierced at its center. Each target, an entry the planner ranked, gets the
|
||||||
/// nearest point of the deepest erosion that still reaches the wall beside it, stepping down to
|
/// nearest point of the deepest erosion that still reaches the wall beside it, stepping down to
|
||||||
/// half that depth: a letter stroke is pierced on its own centre line next to the entry, and
|
/// half that depth: a letter stroke is pierced on its own center line next to the entry, and
|
||||||
/// only a target with no stroke beside it falls back to the deepest place. The lead then runs
|
/// only a target with no stroke beside it falls back to the deepest place. The lead then runs
|
||||||
/// straight from the pierce to the wall.
|
/// straight from the pierce to the wall.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -30,7 +30,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
|
/// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
|
||||||
/// not fit: deep enough to stay near the centre line, with room for every place the
|
/// not fit: deep enough to stay near the center line, with room for every place the
|
||||||
/// cutout is about as wide as its widest.
|
/// cutout is about as wide as its widest.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const double DepthFraction = 0.9;
|
public const double DepthFraction = 0.9;
|
||||||
@@ -47,10 +47,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
/// too small to erode is pierced at its pole for every target. Nothing for an open or
|
/// too small to erode is pierced at its pole for every target. Nothing for an open or
|
||||||
/// degenerate contour.
|
/// degenerate contour.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ErodedPierces Find(Shape hole, double clearance, IReadOnlyList<Vector> targets, int max)
|
public static ErodedPierces Find(Shape hole, double depth, IReadOnlyList<Vector> targets, int max)
|
||||||
{
|
{
|
||||||
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|
||||||
|| !double.IsFinite(clearance))
|
|| !double.IsFinite(depth))
|
||||||
return ErodedPierces.None;
|
return ErodedPierces.None;
|
||||||
|
|
||||||
// The inscribed radius, from the true wall: the deepest any erosion can go.
|
// The inscribed radius, from the true wall: the deepest any erosion can go.
|
||||||
@@ -64,10 +64,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
|
var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
|
||||||
if (!(inscribed > 0))
|
if (!(inscribed > 0))
|
||||||
return ErodedPierces.None;
|
return ErodedPierces.None;
|
||||||
|
if (hole.Entities.Count == 1 && hole.Entities[0] is Circle circle)
|
||||||
|
return new(targets.Take(max).Select(_ => circle.Center).ToArray(), circle.Radius);
|
||||||
|
|
||||||
// Inscribed flattening can stand off a concave arc, and round joins cut their arcs,
|
// Inscribed flattening can stand off a concave arc, and round joins cut their arcs,
|
||||||
// each by the tolerance; Clipper rounds to its precision. Erode past both.
|
// each by the tolerance; Clipper rounds to its precision. Erode past both.
|
||||||
var depth = System.Math.Min(System.Math.Max(clearance, 0), inscribed * DepthFraction);
|
depth = System.Math.Min(System.Math.Max(depth, 0), inscribed * DepthFraction);
|
||||||
var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision));
|
var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision));
|
||||||
var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
|
var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
|
||||||
if (depth + margin >= inscribed - tolerance)
|
if (depth + margin >= inscribed - tolerance)
|
||||||
@@ -84,7 +86,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
|
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
|
||||||
|
|
||||||
// Each target gets the deepest level that reaches the wall beside it: a stroke
|
// Each target gets the deepest level that reaches the wall beside it: a stroke
|
||||||
// narrower than the cutout's widest place is still pierced on its own centre line, near
|
// narrower than the cutout's widest place is still pierced on its own center line, near
|
||||||
// the entry, rather than only at the widest place.
|
// the entry, rather than only at the widest place.
|
||||||
var pierces = new List<Vector>();
|
var pierces = new List<Vector>();
|
||||||
var shallowest = depth;
|
var shallowest = depth;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,31 +73,10 @@ namespace OpenNest.CNC
|
|||||||
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked
|
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked
|
||||||
/// overcut needs the post's lock and unlock codes, and its markers must be well formed.
|
/// overcut needs the post's lock and unlock codes, and its markers must be well formed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void EnsurePostable(Nest nest, bool hasCodes, string postName)
|
public static void EnsurePostable(Nest nest, bool hasCodes, string postName) =>
|
||||||
{
|
PostMarkers.EnsurePostable(nest, hasCodes, IsMarked, program => LockedMask(program.Codes),
|
||||||
ArgumentNullException.ThrowIfNull(nest);
|
name => $"{name} overcuts past the sheet edge with the head height locked, but the {postName} post "
|
||||||
foreach (var plate in nest.Plates)
|
+ "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.");
|
||||||
foreach (var part in plate.Parts)
|
|
||||||
{
|
|
||||||
if (part?.Program == null || !IsMarked(part.Program))
|
|
||||||
continue;
|
|
||||||
var name = DisplayPerimeterTail.PartName(part);
|
|
||||||
if (!hasCodes)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"{name} overcuts past the sheet edge with the head height locked, but the {postName} post "
|
|
||||||
+ "has no head-height lock and unlock codes. Enter them in the post's configuration, "
|
|
||||||
+ "or delete the cut-off and add it again with the head-height lock off.");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
LockedMask(part.Program.Codes);
|
|
||||||
}
|
|
||||||
catch (ArgumentException ex)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A rapid the head must make raised because it passes over parts already cut, which may have
|
||||||
|
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing, and always
|
||||||
|
/// marks the rapid out of a cutout's slug after its lead-out (see docs/cutting-planner.md): a
|
||||||
|
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>, or before the
|
||||||
|
/// <see cref="SubProgramCall"/> whose arrival rapid it is, since a sub-program may be shared and
|
||||||
|
/// is never marked itself. Rapid checks accept a crossing on a marked rapid. Posts write their machine's head raise before every marked rapid,
|
||||||
|
/// and refuse to post a marked program when they have none, since the rapid is unsafe head down.
|
||||||
|
/// See docs/cutting-planner.md.
|
||||||
|
/// </summary>
|
||||||
|
public static class HeadRaise
|
||||||
|
{
|
||||||
|
/// <summary>The comment immediately before a rapid made with the head raised.</summary>
|
||||||
|
public const string Marker = "HEAD RAISE";
|
||||||
|
|
||||||
|
public static bool IsMarker(ICode code) => code is Comment { Value: Marker };
|
||||||
|
|
||||||
|
/// <summary>Whether <paramref name="program"/>'s own codes hold a marker.</summary>
|
||||||
|
public static bool IsMarked(Program program)
|
||||||
|
{
|
||||||
|
if (program?.Codes == null)
|
||||||
|
return false;
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
if (IsMarker(code))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For each of <paramref name="codes"/>, whether it is a rapid made with the head raised, or a
|
||||||
|
/// sub-program call whose arrival rapid is. A marker must be followed directly by a rapid or
|
||||||
|
/// a call; anything else is a malformed program (<see cref="ArgumentException"/>), since a
|
||||||
|
/// post could raise before the wrong move.
|
||||||
|
/// </summary>
|
||||||
|
public static bool[] RaisedMask(IReadOnlyList<ICode> codes)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(codes);
|
||||||
|
var mask = new bool[codes.Count];
|
||||||
|
for (var i = 0; i < codes.Count; i++)
|
||||||
|
{
|
||||||
|
if (!IsMarker(codes[i]))
|
||||||
|
continue;
|
||||||
|
if (i + 1 >= codes.Count || codes[i + 1] is not (RapidMove or SubProgramCall))
|
||||||
|
throw new ArgumentException("A head-raise marker is not followed by a rapid.");
|
||||||
|
mask[i + 1] = true;
|
||||||
|
}
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy of <paramref name="program"/> with a marker before each of its own codes at
|
||||||
|
/// <paramref name="rapids"/>, which must be rapids or sub-program calls not marked already.
|
||||||
|
/// </summary>
|
||||||
|
public static Program Mark(Program program, IReadOnlyCollection<int> rapids)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(program);
|
||||||
|
ArgumentNullException.ThrowIfNull(rapids);
|
||||||
|
var marked = RaisedMask(program.Codes);
|
||||||
|
var copy = (Program)program.Clone();
|
||||||
|
for (var i = copy.Codes.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (!rapids.Contains(i))
|
||||||
|
continue;
|
||||||
|
if (copy.Codes[i] is not (RapidMove or SubProgramCall) || marked[i])
|
||||||
|
throw new ArgumentException("Only an unmarked rapid or sub-program call can be marked for a head raise.");
|
||||||
|
copy.Codes.Insert(i, new Comment(Marker));
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refuses a nest a post cannot write, before any output is created: a marked rapid needs
|
||||||
|
/// the post's head-raise code, and its markers must be well formed.
|
||||||
|
/// </summary>
|
||||||
|
public static void EnsurePostable(Nest nest, bool hasCode, string postName) =>
|
||||||
|
PostMarkers.EnsurePostable(nest, hasCode, IsMarked, program => RaisedMask(program.Codes),
|
||||||
|
name => $"{name} has rapids that must raise the head over parts already cut, but the {postName} "
|
||||||
|
+ "post has no head-raise code. Enter it in the post's configuration before posting.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+201
-22
@@ -11,9 +11,13 @@ namespace OpenNest
|
|||||||
Vertical,
|
Vertical,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A stretch of a cut-off's line, along its axis, left uncut.</summary>
|
||||||
|
public readonly record struct CutOffGap(double Start, double End);
|
||||||
|
|
||||||
public class CutOff
|
public class CutOff
|
||||||
{
|
{
|
||||||
private const double OffsetTolerance = 0.001;
|
private const double OffsetTolerance = 0.001;
|
||||||
|
private IReadOnlyList<CutOffGap> gaps = System.Array.Empty<CutOffGap>();
|
||||||
|
|
||||||
public Vector Position { get; set; }
|
public Vector Position { get; set; }
|
||||||
public CutOffAxis Axis { get; set; }
|
public CutOffAxis Axis { get; set; }
|
||||||
@@ -21,6 +25,66 @@ namespace OpenNest
|
|||||||
public double? EndLimit { get; set; }
|
public double? EndLimit { get; set; }
|
||||||
public Drawing Drawing { get; private set; }
|
public Drawing Drawing { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stretches between the limits left uncut, so the line splits into pieces as it does
|
||||||
|
/// around a part; a trim of a middle piece makes one from crossing line to crossing line
|
||||||
|
/// (<see cref="CutOffTrim"/>). Kept sorted along the axis, with touching gaps merged and
|
||||||
|
/// empty or nonfinite ones dropped.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CutOffGap> Gaps
|
||||||
|
{
|
||||||
|
get => gaps;
|
||||||
|
set => gaps = Normalize(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The nominal line from <paramref name="start"/> to <paramref name="end"/> with its gaps
|
||||||
|
/// taken out, in ascending order: the pieces a consumer of the limits treats as cut.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<(double Start, double End)> Pieces(double start, double end) =>
|
||||||
|
Pieces(start, end, gaps);
|
||||||
|
|
||||||
|
internal static IReadOnlyList<(double Start, double End)> Pieces(double start, double end,
|
||||||
|
IReadOnlyList<CutOffGap> gaps)
|
||||||
|
{
|
||||||
|
var (low, high) = (System.Math.Min(start, end), System.Math.Max(start, end));
|
||||||
|
var pieces = new List<(double Start, double End)>();
|
||||||
|
if (gaps.Count == 0)
|
||||||
|
{
|
||||||
|
pieces.Add((low, high));
|
||||||
|
return pieces;
|
||||||
|
}
|
||||||
|
var current = low;
|
||||||
|
foreach (var gap in gaps)
|
||||||
|
{
|
||||||
|
if (gap.End <= current || gap.Start >= high)
|
||||||
|
continue;
|
||||||
|
if (gap.Start > current)
|
||||||
|
pieces.Add((current, gap.Start));
|
||||||
|
current = System.Math.Max(current, gap.End);
|
||||||
|
}
|
||||||
|
if (current < high)
|
||||||
|
pieces.Add((current, high));
|
||||||
|
return pieces;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<CutOffGap> Normalize(IEnumerable<CutOffGap> value)
|
||||||
|
{
|
||||||
|
var sorted = (value ?? Enumerable.Empty<CutOffGap>())
|
||||||
|
.Where(g => double.IsFinite(g.Start) && double.IsFinite(g.End) && g.End > g.Start)
|
||||||
|
.OrderBy(g => g.Start)
|
||||||
|
.ToList();
|
||||||
|
var merged = new List<CutOffGap>();
|
||||||
|
foreach (var gap in sorted)
|
||||||
|
{
|
||||||
|
if (merged.Count > 0 && gap.Start <= merged[^1].End + Math.Tolerance.Epsilon)
|
||||||
|
merged[^1] = merged[^1] with { End = System.Math.Max(merged[^1].End, gap.End) };
|
||||||
|
else
|
||||||
|
merged.Add(gap);
|
||||||
|
}
|
||||||
|
return merged.Count == 0 ? System.Array.Empty<CutOffGap>() : merged.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the
|
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the
|
||||||
/// segment reaching that edge cuts with the head height locked; zero for none. Stock can
|
/// segment reaching that edge cuts with the head height locked; zero for none. Stock can
|
||||||
@@ -189,21 +253,82 @@ namespace OpenNest
|
|||||||
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts
|
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts
|
||||||
/// one continuous straight line on the definition's axis across the whole sheet, so it
|
/// one continuous straight line on the definition's axis across the whole sheet, so it
|
||||||
/// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
|
/// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
|
||||||
/// line on the sheet edge, a stale or unrecognised program) does not separate the sheet.
|
/// line on the sheet edge, a stale or unrecognized program) does not separate the sheet.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet)
|
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet) =>
|
||||||
|
ContinuousCut(part, definition, sheet) is { } cut
|
||||||
|
&& cut.Low <= (definition.Axis == CutOffAxis.Vertical ? sheet.Bottom : sheet.Left) + Math.Tolerance.Epsilon
|
||||||
|
&& cut.High >= (definition.Axis == CutOffAxis.Vertical ? sheet.Top : sheet.Right) - Math.Tolerance.Epsilon;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cut-off parts among <paramref name="parts"/> that split off a piece of the sheet,
|
||||||
|
/// each with the round it was found in. Round 0 holds the lines that
|
||||||
|
/// <see cref="SeparatesSheet"/> across the whole sheet; a line of a later round is one
|
||||||
|
/// continuous cut from a sheet edge or an earlier round's line to another, such as the
|
||||||
|
/// last strip line stopping at a remnant's horizontal separator. It frees a piece together
|
||||||
|
/// with the lines it stops on, so it is cut after every other part and before those lines
|
||||||
|
/// (higher rounds first), keeping the remnant's mass on the sheet until the end.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyDictionary<Part, int> Separators(IEnumerable<Part> parts, IEnumerable<CutOff> definitions, Box sheet)
|
||||||
|
{
|
||||||
|
var rounds = new Dictionary<Part, int>(ReferenceEqualityComparer.Instance);
|
||||||
|
if (parts == null || definitions == null || sheet == null)
|
||||||
|
return rounds;
|
||||||
|
var byDrawing = new Dictionary<Drawing, CutOff>(ReferenceEqualityComparer.Instance);
|
||||||
|
foreach (var definition in definitions)
|
||||||
|
if (definition?.Drawing != null)
|
||||||
|
byDrawing[definition.Drawing] = definition;
|
||||||
|
var cuts = new List<(Part Part, bool Vertical, double Line, double Low, double High)>();
|
||||||
|
foreach (var part in parts)
|
||||||
|
if (part?.BaseDrawing is { IsCutOff: true } drawing && byDrawing.TryGetValue(drawing, out var definition)
|
||||||
|
&& ContinuousCut(part, definition, sheet) is { } cut)
|
||||||
|
{
|
||||||
|
var vertical = definition.Axis == CutOffAxis.Vertical;
|
||||||
|
cuts.Add((part, vertical, vertical ? definition.Position.X : definition.Position.Y, cut.Low, cut.High));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var round = 0; ; round++)
|
||||||
|
{
|
||||||
|
var found = cuts.Where(cut => !rounds.ContainsKey(cut.Part) && Bounded(cut)).ToList();
|
||||||
|
if (found.Count == 0)
|
||||||
|
return rounds;
|
||||||
|
foreach (var cut in found)
|
||||||
|
rounds[cut.Part] = round;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cut runs between two boundaries: sheet edges, or lines found in an earlier round
|
||||||
|
// that cross this one where they are cut. The stretch between them is cut, as the cut
|
||||||
|
// is continuous.
|
||||||
|
bool Bounded((Part Part, bool Vertical, double Line, double Low, double High) cut)
|
||||||
|
{
|
||||||
|
var reached = cuts.Where(other => other.Vertical != cut.Vertical && rounds.ContainsKey(other.Part)
|
||||||
|
&& other.Low <= cut.Line + Math.Tolerance.Epsilon && other.High >= cut.Line - Math.Tolerance.Epsilon)
|
||||||
|
.Select(other => other.Line)
|
||||||
|
.Append(cut.Vertical ? sheet.Bottom : sheet.Left)
|
||||||
|
.Append(cut.Vertical ? sheet.Top : sheet.Right)
|
||||||
|
.Where(b => b >= cut.Low - Math.Tolerance.Epsilon && b <= cut.High + Math.Tolerance.Epsilon)
|
||||||
|
.ToList();
|
||||||
|
return reached.Count > 1 && reached.Max() - reached.Min() > Math.Tolerance.Epsilon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The cut-off parts on <paramref name="plate"/> that split off a piece of it, by round.</summary>
|
||||||
|
public static IReadOnlyDictionary<Part, int> Separators(Plate plate) =>
|
||||||
|
Separators(plate.Parts, plate.CutOffs, plate.BoundingBox(includeParts: false));
|
||||||
|
|
||||||
|
// The extent along the line of a part that cuts one continuous straight line on its
|
||||||
|
// definition's line strictly inside the sheet, or null for anything else.
|
||||||
|
private static (double Low, double High)? ContinuousCut(Part part, CutOff definition, Box sheet)
|
||||||
{
|
{
|
||||||
if (part?.Program == null || definition == null || sheet == null
|
if (part?.Program == null || definition == null || sheet == null
|
||||||
|| part.Program.Mode != Mode.Absolute)
|
|| part.Program.Mode != Mode.Absolute)
|
||||||
return false;
|
return null;
|
||||||
var vertical = definition.Axis == CutOffAxis.Vertical;
|
var vertical = definition.Axis == CutOffAxis.Vertical;
|
||||||
var line = vertical ? definition.Position.X : definition.Position.Y;
|
var line = vertical ? definition.Position.X : definition.Position.Y;
|
||||||
var acrossMin = vertical ? sheet.Left : sheet.Bottom;
|
var acrossMin = vertical ? sheet.Left : sheet.Bottom;
|
||||||
var acrossMax = vertical ? sheet.Right : sheet.Top;
|
var acrossMax = vertical ? sheet.Right : sheet.Top;
|
||||||
var alongMin = vertical ? sheet.Bottom : sheet.Left;
|
|
||||||
var alongMax = vertical ? sheet.Top : sheet.Right;
|
|
||||||
if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon))
|
if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon))
|
||||||
return false;
|
return null;
|
||||||
|
|
||||||
Vector? position = null;
|
Vector? position = null;
|
||||||
double? low = null, high = null;
|
double? low = null, high = null;
|
||||||
@@ -221,13 +346,13 @@ namespace OpenNest
|
|||||||
// A second pierce leaves an uncut gap; a head-locked rapid returns along
|
// A second pierce leaves an uncut gap; a head-locked rapid returns along
|
||||||
// the overcut to where the cut toward the origin begins.
|
// the overcut to where the cut toward the origin begins.
|
||||||
if (cutting && !locked)
|
if (cutting && !locked)
|
||||||
return false;
|
return null;
|
||||||
position = rapid.EndPoint + part.Location;
|
position = rapid.EndPoint + part.Location;
|
||||||
break;
|
break;
|
||||||
case LinearMove move when position != null:
|
case LinearMove move when position != null:
|
||||||
var end = move.EndPoint + part.Location;
|
var end = move.EndPoint + part.Location;
|
||||||
if (!OnLine(position.Value) || !OnLine(end))
|
if (!OnLine(position.Value) || !OnLine(end))
|
||||||
return false;
|
return null;
|
||||||
foreach (var value in new[] { Along(position.Value), Along(end) })
|
foreach (var value in new[] { Along(position.Value), Along(end) })
|
||||||
{
|
{
|
||||||
low = low == null ? value : System.Math.Min(low.Value, value);
|
low = low == null ? value : System.Math.Min(low.Value, value);
|
||||||
@@ -237,10 +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;
|
||||||
@@ -262,7 +387,8 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
var bounds = plate.BoundingBox(includeParts: false);
|
var bounds = plate.BoundingBox(includeParts: false);
|
||||||
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
|
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
|
||||||
var overtravel = LockedFarEdge(plate) is { High: true } ? 0 : settings.Overtravel;
|
var lockedFarEdge = LockedFarEdge(plate);
|
||||||
|
var overtravel = lockedFarEdge is { High: true } ? 0 : settings.Overtravel;
|
||||||
|
|
||||||
double lineStart,
|
double lineStart,
|
||||||
lineEnd,
|
lineEnd,
|
||||||
@@ -281,6 +407,13 @@ namespace OpenNest
|
|||||||
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
|
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An outlined sheet is cut only where the line crosses material: each crossing is a
|
||||||
|
// span of its own, started at the material's edge and overtraveled past the far
|
||||||
|
// one, within the user's limits.
|
||||||
|
var spans = plate.Outline == null
|
||||||
|
? new List<(double Start, double End)> { (lineStart, lineEnd) }
|
||||||
|
: MaterialSpans(plate, cutPosition, lineStart, lineEnd, settings.Overtravel, lockedFarEdge);
|
||||||
|
|
||||||
var exclusions = new List<(double Start, double End)>();
|
var exclusions = new List<(double Start, double End)>();
|
||||||
|
|
||||||
foreach (var part in plate.Parts)
|
foreach (var part in plate.Parts)
|
||||||
@@ -313,6 +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)>();
|
||||||
@@ -325,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(
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ namespace OpenNest
|
|||||||
/// One placed cutting segment of a cut-off: the part that cuts it, where its cut runs along
|
/// One placed cutting segment of a cut-off: the part that cuts it, where its cut runs along
|
||||||
/// the cut-off's axis, and the span of the nominal line it stands for, from the end of the
|
/// the cut-off's axis, and the span of the nominal line it stands for, from the end of the
|
||||||
/// segment before it (or the line's start) to the start of the one after it (or the line's
|
/// segment before it (or the line's start) to the start of the one after it (or the line's
|
||||||
/// end). The parts the nominal line passes through lie in the gaps between segments, so each
|
/// end), stopping short of a <see cref="CutOff.Gaps">gap</see> between them. The parts the
|
||||||
/// is inside the span of the segment on either side of it; a segment is cut before exactly
|
/// nominal line passes through lie in the gaps between segments, so each is inside the span
|
||||||
/// those parts.
|
/// of the segment on either side of it; a segment is cut before exactly those parts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd);
|
public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd);
|
||||||
|
|
||||||
@@ -55,6 +55,14 @@ namespace OpenNest
|
|||||||
var (part, start, end) = measured[i];
|
var (part, start, end) = measured[i];
|
||||||
var spanStart = i == 0 ? System.Math.Min(low, start) : measured[i - 1].End;
|
var spanStart = i == 0 ? System.Math.Min(low, start) : measured[i - 1].End;
|
||||||
var spanEnd = i == measured.Count - 1 ? System.Math.Max(high, end) : measured[i + 1].Start;
|
var spanEnd = i == measured.Count - 1 ? System.Math.Max(high, end) : measured[i + 1].Start;
|
||||||
|
// A gap between two segments belongs to neither: each span stops at its edge.
|
||||||
|
foreach (var gap in definition.Gaps)
|
||||||
|
{
|
||||||
|
if (i > 0 && gap.End <= start + Math.Tolerance.Epsilon && gap.End > spanStart)
|
||||||
|
spanStart = gap.End;
|
||||||
|
if (i < measured.Count - 1 && gap.Start >= end - Math.Tolerance.Epsilon && gap.Start < spanEnd)
|
||||||
|
spanEnd = gap.Start;
|
||||||
|
}
|
||||||
result.Add(new CutOffSegment(part, start, end, spanStart, spanEnd));
|
result.Add(new CutOffSegment(part, start, end, spanStart, spanEnd));
|
||||||
}
|
}
|
||||||
result.AddRange(whole);
|
result.AddRange(whole);
|
||||||
|
|||||||
@@ -25,5 +25,7 @@ namespace OpenNest
|
|||||||
|
|
||||||
/// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary>
|
/// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary>
|
||||||
public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0;
|
public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0;
|
||||||
|
|
||||||
|
public CutOffSettings Copy() => (CutOffSettings)MemberwiseClone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The limits and gaps one cut-off takes when a <see cref="CutOffEdit"/> is applied; null
|
||||||
|
/// <paramref name="Gaps"/> keeps the cut-off's gaps.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CutOffLimitChange(CutOff CutOff, double? StartLimit, double? EndLimit,
|
||||||
|
IReadOnlyList<CutOffGap> Gaps = null)
|
||||||
|
{
|
||||||
|
/// <summary>The gaps the cut-off has once the change is applied.</summary>
|
||||||
|
public IReadOnlyList<CutOffGap> NewGaps => Gaps ?? CutOff.Gaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A worked-out trim, extend or swap: the new limits and gaps of each cut-off it changes, or
|
||||||
|
/// why it was refused. Nothing changes until <see cref="Apply"/>; the caller then regenerates
|
||||||
|
/// the plate's cut-offs and places their segments in the cutting order.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CutOffEdit
|
||||||
|
{
|
||||||
|
private CutOffEdit(IReadOnlyList<CutOffLimitChange> changes, string refusal, string notice)
|
||||||
|
{
|
||||||
|
Changes = changes;
|
||||||
|
Refusal = refusal;
|
||||||
|
Notice = notice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<CutOffLimitChange> Changes { get; }
|
||||||
|
|
||||||
|
/// <summary>Why the edit cannot be made; null when it can.</summary>
|
||||||
|
public string Refusal { get; }
|
||||||
|
|
||||||
|
/// <summary>Something the operator should know once it is made, such as a head-locked overcut turning off.</summary>
|
||||||
|
public string Notice { get; }
|
||||||
|
|
||||||
|
public bool Succeeded => Refusal == null;
|
||||||
|
|
||||||
|
internal static CutOffEdit Refused(string reason) => new(System.Array.Empty<CutOffLimitChange>(), reason, null);
|
||||||
|
|
||||||
|
internal static CutOffEdit Of(IReadOnlyList<CutOffLimitChange> changes, string notice) => new(changes, null, notice);
|
||||||
|
|
||||||
|
public void Apply()
|
||||||
|
{
|
||||||
|
if (!Succeeded)
|
||||||
|
throw new System.InvalidOperationException(Refusal);
|
||||||
|
foreach (var change in Changes)
|
||||||
|
{
|
||||||
|
change.CutOff.StartLimit = change.StartLimit;
|
||||||
|
change.CutOff.EndLimit = change.EndLimit;
|
||||||
|
if (change.Gaps != null)
|
||||||
|
change.CutOff.Gaps = change.Gaps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The perpendicular lines that meet a cut-off as a drag of it starts: their limits or gap
|
||||||
|
/// edges sit on its center, where a trim or extend left them. <see cref="MoveTo"/> takes those
|
||||||
|
/// stops along, so each line is extended or trimmed to keep meeting it. Every move works from
|
||||||
|
/// the lines as they were at the start, so a stop held back partway is restored on the way
|
||||||
|
/// back. A stop never passes the next one on its line: it stays a minimum segment short of it,
|
||||||
|
/// or of the sheet edge.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CutOffJoints
|
||||||
|
{
|
||||||
|
private readonly List<(CutOff CutOff, double? StartLimit, double? EndLimit, IReadOnlyList<CutOffGap> Gaps)> joined;
|
||||||
|
private readonly double from;
|
||||||
|
private readonly double sheetLow;
|
||||||
|
private readonly double sheetHigh;
|
||||||
|
private readonly double minimum;
|
||||||
|
|
||||||
|
internal CutOffJoints(IEnumerable<CutOff> lines, double from, double sheetLow, double sheetHigh, double minimum)
|
||||||
|
{
|
||||||
|
joined = lines.Select(c => (c, c.StartLimit, c.EndLimit, c.Gaps)).ToList();
|
||||||
|
this.from = from;
|
||||||
|
this.sheetLow = sheetLow;
|
||||||
|
this.sheetHigh = sheetHigh;
|
||||||
|
this.minimum = minimum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The lines that follow the cut-off.</summary>
|
||||||
|
public IReadOnlyList<CutOff> Lines => joined.Select(j => j.CutOff).ToList();
|
||||||
|
|
||||||
|
/// <summary>Moves the joined stops to <paramref name="line"/>, the cut-off's new center.</summary>
|
||||||
|
public void MoveTo(double line)
|
||||||
|
{
|
||||||
|
foreach (var (cutOff, startLimit, endLimit, gaps) in joined)
|
||||||
|
{
|
||||||
|
var stops = gaps.SelectMany(g => new[] { g.Start, g.End })
|
||||||
|
.Append(startLimit ?? sheetLow)
|
||||||
|
.Append(endLimit ?? sheetHigh)
|
||||||
|
.ToList();
|
||||||
|
var below = stops.Where(s => s < from - Tolerance.Epsilon).DefaultIfEmpty(sheetLow).Max();
|
||||||
|
var above = stops.Where(s => s > from + Tolerance.Epsilon).DefaultIfEmpty(sheetHigh).Min();
|
||||||
|
var at = System.Math.Clamp(line,
|
||||||
|
System.Math.Min(below + minimum, from), System.Math.Max(above - minimum, from));
|
||||||
|
|
||||||
|
cutOff.StartLimit = startLimit is double start ? Follow(start, at) : null;
|
||||||
|
cutOff.EndLimit = endLimit is double end ? Follow(end, at) : null;
|
||||||
|
cutOff.Gaps = gaps.Select(g => new CutOffGap(Follow(g.Start, at), Follow(g.End, at))).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double Follow(double stop, double at) =>
|
||||||
|
System.Math.Abs(stop - from) <= Tolerance.Epsilon ? at : stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trims and extends cut-offs against the cut-offs that cross them, by changing only their
|
||||||
|
/// start and end limits and their gaps (see docs/automatic-scrap-cutoffs.md, "Trim and
|
||||||
|
/// extend"). A trim stops on the crossing line's center, inside its kerf; trimming a piece
|
||||||
|
/// between two crossing lines leaves a gap from one center to the other. An extend closes the
|
||||||
|
/// gap at the picked end, or runs the line's end to the next crossing line beyond it, or to
|
||||||
|
/// the sheet edge, which clears the limit.
|
||||||
|
/// </summary>
|
||||||
|
public static class CutOffTrim
|
||||||
|
{
|
||||||
|
public const string NotOnPlate = "That cut-off is not on this plate.";
|
||||||
|
public const string NothingToTrimTo = "No cut-off crosses this line to trim it to; delete it instead.";
|
||||||
|
public const string InGap = "That stretch of the line is already uncut.";
|
||||||
|
public const string TooShort = "Trimming here would leave a line shorter than the minimum segment length.";
|
||||||
|
public const string AtSheetEdge = "This end already runs to the sheet edge.";
|
||||||
|
public const string NotAJunction = "These cut-offs do not meet with one running through and the other stopping at it.";
|
||||||
|
public const string GapAtJunction = "A gap in one of these lines meets the junction; extend to close it before swapping.";
|
||||||
|
public const string OvercutOff = "The head-locked overcut is off while this line stops short of the far edge.";
|
||||||
|
public const string OvercutOn = "The head-locked overcut is back on at the far edge.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the piece of <paramref name="target"/> picked at <paramref name="pick"/>, between
|
||||||
|
/// the crossing cut-offs on either side of it, or a gap or the line's end where there is no
|
||||||
|
/// crossing on that side. An end piece moves that end's limit; a piece between two crossing
|
||||||
|
/// lines becomes a gap, merged with any gap it touches.
|
||||||
|
/// </summary>
|
||||||
|
public static CutOffEdit Trim(Plate plate, CutOff target, Vector pick, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
if (!plate.CutOffs.Contains(target))
|
||||||
|
return CutOffEdit.Refused(NotOnPlate);
|
||||||
|
var (start, end) = NominalSpan(plate, target);
|
||||||
|
var at = System.Math.Clamp(Along(target, pick), start, end);
|
||||||
|
var pieces = target.Pieces(start, end);
|
||||||
|
var piece = pieces.Where(p => at >= p.Start - Tolerance.Epsilon && at <= p.End + Tolerance.Epsilon)
|
||||||
|
.Cast<(double Start, double End)?>().FirstOrDefault();
|
||||||
|
if (piece is not var (pieceStart, pieceEnd))
|
||||||
|
return CutOffEdit.Refused(InGap);
|
||||||
|
|
||||||
|
var crossings = Crossings(plate, target)
|
||||||
|
.Where(c => c > pieceStart + Tolerance.Epsilon && c < pieceEnd - Tolerance.Epsilon)
|
||||||
|
.ToList();
|
||||||
|
var low = crossings.Where(c => c <= at).DefaultIfEmpty(pieceStart).Max();
|
||||||
|
var high = crossings.Where(c => c > at).DefaultIfEmpty(pieceEnd).Min();
|
||||||
|
if (pieces.Count == 1 && low == start && high == end)
|
||||||
|
return CutOffEdit.Refused(NothingToTrimTo);
|
||||||
|
|
||||||
|
var remaining = new List<(double Start, double End)>();
|
||||||
|
foreach (var (from, to) in pieces)
|
||||||
|
{
|
||||||
|
if (from < low)
|
||||||
|
remaining.Add((from, System.Math.Min(to, low)));
|
||||||
|
if (to > high)
|
||||||
|
remaining.Add((System.Math.Max(from, high), to));
|
||||||
|
}
|
||||||
|
var minimum = System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon);
|
||||||
|
if (remaining.Count == 0
|
||||||
|
|| remaining.Any(p => (p.End == low || p.Start == high) && p.End - p.Start < minimum))
|
||||||
|
return CutOffEdit.Refused(TooShort);
|
||||||
|
return Single(plate, Change(target, start, end, remaining));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lengthens <paramref name="target"/> at the end of the piece nearer <paramref name="pick"/>:
|
||||||
|
/// a gap there closes; the line's own end runs to the next crossing cut-off beyond it, or to
|
||||||
|
/// the sheet edge when there is none. A cut-off sitting at the current end is skipped, so
|
||||||
|
/// picking again extends one more step. A pick inside a gap closes that gap.
|
||||||
|
/// </summary>
|
||||||
|
public static CutOffEdit Extend(Plate plate, CutOff target, Vector pick)
|
||||||
|
{
|
||||||
|
if (!plate.CutOffs.Contains(target))
|
||||||
|
return CutOffEdit.Refused(NotOnPlate);
|
||||||
|
var (start, end) = NominalSpan(plate, target);
|
||||||
|
var at = Along(target, pick);
|
||||||
|
var gaps = target.Gaps;
|
||||||
|
var inside = gaps.Where(g => at > g.Start && at < g.End).ToList();
|
||||||
|
if (inside.Count == 0)
|
||||||
|
{
|
||||||
|
var pieces = target.Pieces(start, end);
|
||||||
|
var piece = pieces.OrderBy(p => Distance(at, p)).First();
|
||||||
|
var high = System.Math.Abs(at - piece.End) <= System.Math.Abs(at - piece.Start);
|
||||||
|
var edge = high ? piece.End : piece.Start;
|
||||||
|
inside = gaps.Where(g => System.Math.Abs((high ? g.Start : g.End) - edge) <= Tolerance.Epsilon).ToList();
|
||||||
|
if (inside.Count == 0)
|
||||||
|
{
|
||||||
|
return ExtendEnd(plate, target, high, out var limit)
|
||||||
|
? Single(plate, new CutOffLimitChange(target,
|
||||||
|
high ? target.StartLimit : limit, high ? limit : target.EndLimit))
|
||||||
|
: CutOffEdit.Refused(AtSheetEdge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Single(plate, new CutOffLimitChange(target, target.StartLimit, target.EndLimit,
|
||||||
|
gaps.Except(inside).ToList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops a new <paramref name="target"/> at the first crossing cut-off from its start edge,
|
||||||
|
/// the origin side unless <paramref name="settings"/> cut toward the origin. It stays full
|
||||||
|
/// length, with no changes, when nothing crosses it or the line left would be shorter than
|
||||||
|
/// the minimum segment length. <paramref name="target"/> need not be on the plate yet.
|
||||||
|
/// </summary>
|
||||||
|
public static CutOffEdit StopAtFirstCrossing(Plate plate, CutOff target, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
var (start, end) = NominalSpan(plate, target);
|
||||||
|
var crossings = Crossings(plate, target)
|
||||||
|
.Where(c => c > start + Tolerance.Epsilon && c < end - Tolerance.Epsilon)
|
||||||
|
.ToList();
|
||||||
|
if (crossings.Count == 0)
|
||||||
|
return CutOffEdit.Of(System.Array.Empty<CutOffLimitChange>(), null);
|
||||||
|
|
||||||
|
var towardOrigin = settings?.CutDirection == CutDirection.TowardOrigin;
|
||||||
|
var startsHigh = towardOrigin == CutOff.FarEdgeIsHigh(target.Axis, plate.Quadrant);
|
||||||
|
var stop = startsHigh ? crossings.Max() : crossings.Min();
|
||||||
|
var remaining = startsHigh ? end - stop : stop - start;
|
||||||
|
if (remaining < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
|
||||||
|
return CutOffEdit.Of(System.Array.Empty<CutOffLimitChange>(), null);
|
||||||
|
return startsHigh
|
||||||
|
? Single(plate, new CutOffLimitChange(target, stop, target.EndLimit))
|
||||||
|
: Single(plate, new CutOffLimitChange(target, target.StartLimit, stop));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The lines that meet <paramref name="moved"/> and follow it when it moves: perpendicular
|
||||||
|
/// cut-offs with a limit or gap edge on its center, where its own cut pieces reach them. A
|
||||||
|
/// line that only ends level with it, past its end or in one of its gaps, stays put.
|
||||||
|
/// </summary>
|
||||||
|
public static CutOffJoints Joints(Plate plate, CutOff moved, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
var at = Line(moved);
|
||||||
|
var (movedStart, movedEnd) = NominalSpan(plate, moved);
|
||||||
|
var pieces = moved.Pieces(movedStart, movedEnd);
|
||||||
|
var lines = plate.CutOffs
|
||||||
|
.Where(other => other != moved && other.Axis != moved.Axis)
|
||||||
|
.Where(other => pieces.Any(p => Within(Line(other), p.Start, p.End)))
|
||||||
|
.Where(other => Stops(other).Any(stop => System.Math.Abs(stop - at) <= Tolerance.Epsilon));
|
||||||
|
|
||||||
|
var sheet = plate.BoundingBox(includeParts: false);
|
||||||
|
var horizontal = moved.Axis == CutOffAxis.Vertical;
|
||||||
|
return new CutOffJoints(lines, at, horizontal ? sheet.Left : sheet.Bottom,
|
||||||
|
horizontal ? sheet.Right : sheet.Top,
|
||||||
|
System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the cut-off's own limits and gaps stop it, not counting the sheet edges.
|
||||||
|
private static IEnumerable<double> Stops(CutOff cutOff)
|
||||||
|
{
|
||||||
|
if (cutOff.StartLimit is double start)
|
||||||
|
yield return start;
|
||||||
|
if (cutOff.EndLimit is double end)
|
||||||
|
yield return end;
|
||||||
|
foreach (var gap in cutOff.Gaps)
|
||||||
|
{
|
||||||
|
yield return gap.Start;
|
||||||
|
yield return gap.End;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Swaps which of two meeting cut-offs runs through: the one that stopped at the junction
|
||||||
|
/// extends past it (as <see cref="Extend"/> does), and the one that ran through is trimmed
|
||||||
|
/// there, losing the side away from the plate's origin and any gaps on it. Only the
|
||||||
|
/// origin-side sheet edge is certain, so the trimmed line keeps starting there. Refused when
|
||||||
|
/// a gap in either line meets the junction.
|
||||||
|
/// </summary>
|
||||||
|
public static CutOffEdit Swap(Plate plate, CutOff first, CutOff second, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
if (!plate.CutOffs.Contains(first) || !plate.CutOffs.Contains(second))
|
||||||
|
return CutOffEdit.Refused(NotOnPlate);
|
||||||
|
if (first != second && first.Axis != second.Axis
|
||||||
|
&& (TouchesGap(first, Line(second)) || TouchesGap(second, Line(first))))
|
||||||
|
return CutOffEdit.Refused(GapAtJunction);
|
||||||
|
if (Junction(plate, first, second) is not var (through, stopper, point))
|
||||||
|
return CutOffEdit.Refused(NotAJunction);
|
||||||
|
|
||||||
|
var (start, end) = NominalSpan(plate, through);
|
||||||
|
var farHigh = CutOff.FarEdgeIsHigh(through.Axis, plate.Quadrant);
|
||||||
|
var kept = through.Pieces(start, end)
|
||||||
|
.Select(p => farHigh ? (p.Start, System.Math.Min(p.End, point)) : (System.Math.Max(p.Start, point), p.End))
|
||||||
|
.Where(p => p.Item2 > p.Item1)
|
||||||
|
.ToList();
|
||||||
|
var adjacent = kept.FirstOrDefault(p => (farHigh ? p.Item2 : p.Item1) == point);
|
||||||
|
if (kept.Count == 0
|
||||||
|
|| adjacent.Item2 - adjacent.Item1 < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
|
||||||
|
return CutOffEdit.Refused(TooShort);
|
||||||
|
var throughChange = farHigh
|
||||||
|
? new CutOffLimitChange(through, through.StartLimit, point, GapsBetween(kept))
|
||||||
|
: new CutOffLimitChange(through, point, through.EndLimit, GapsBetween(kept));
|
||||||
|
|
||||||
|
var (stopStart, stopEnd) = NominalSpan(plate, stopper);
|
||||||
|
var stopperPoint = Line(through);
|
||||||
|
var high = System.Math.Abs(stopperPoint - stopEnd) <= System.Math.Abs(stopperPoint - stopStart);
|
||||||
|
if (!ExtendEnd(plate, stopper, high, out var limit))
|
||||||
|
return CutOffEdit.Refused(NotAJunction);
|
||||||
|
var stopperChange = high
|
||||||
|
? new CutOffLimitChange(stopper, stopper.StartLimit, limit)
|
||||||
|
: new CutOffLimitChange(stopper, limit, stopper.EndLimit);
|
||||||
|
|
||||||
|
var notices = new[] { Notice(plate, throughChange), Notice(plate, stopperChange) }
|
||||||
|
.Where(n => n != null).Distinct().ToList();
|
||||||
|
return CutOffEdit.Of(new[] { stopperChange, throughChange },
|
||||||
|
notices.Count == 0 ? null : string.Join(" ", notices));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The two cut-offs meeting within <paramref name="tolerance"/> of <paramref name="point"/>,
|
||||||
|
/// one running through the junction and the other stopping at it; null when there is none.
|
||||||
|
/// </summary>
|
||||||
|
public static (CutOff Through, CutOff Stopper)? JunctionAt(Plate plate, Vector point, double tolerance)
|
||||||
|
{
|
||||||
|
if (plate?.CutOffs == null)
|
||||||
|
return null;
|
||||||
|
(CutOff, CutOff)? best = null;
|
||||||
|
var bestDistance = double.MaxValue;
|
||||||
|
foreach (var vertical in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical))
|
||||||
|
foreach (var horizontal in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Horizontal))
|
||||||
|
{
|
||||||
|
if (Junction(plate, vertical, horizontal) is not var (through, stopper, _))
|
||||||
|
continue;
|
||||||
|
var distance = new Vector(vertical.Position.X, horizontal.Position.Y).DistanceTo(point);
|
||||||
|
if (distance <= tolerance && distance < bestDistance)
|
||||||
|
{
|
||||||
|
best = (through, stopper);
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where <paramref name="cutOff"/>'s line runs along its axis: its limits, or the sheet
|
||||||
|
/// edges where it has none. Overtravel is not part of the nominal line, and neither does it
|
||||||
|
/// take out the gaps (see <see cref="CutOff.Pieces(double, double)"/>).
|
||||||
|
/// </summary>
|
||||||
|
public static (double Start, double End) NominalSpan(Plate plate, CutOff cutOff)
|
||||||
|
{
|
||||||
|
var sheet = plate.BoundingBox(includeParts: false);
|
||||||
|
var vertical = cutOff.Axis == CutOffAxis.Vertical;
|
||||||
|
return (cutOff.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
|
||||||
|
cutOff.EndLimit ?? (vertical ? sheet.Top : sheet.Right));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The stretches of the nominal line a change removes and adds, along the axis, in ascending
|
||||||
|
/// order: what a preview marks.
|
||||||
|
/// </summary>
|
||||||
|
public static (IReadOnlyList<(double Start, double End)> Removed, IReadOnlyList<(double Start, double End)> Added)
|
||||||
|
Difference(Plate plate, CutOffLimitChange change)
|
||||||
|
{
|
||||||
|
var cutOff = change.CutOff;
|
||||||
|
var sheet = plate.BoundingBox(includeParts: false);
|
||||||
|
var vertical = cutOff.Axis == CutOffAxis.Vertical;
|
||||||
|
var before = cutOff.Pieces(NominalSpan(plate, cutOff).Start, NominalSpan(plate, cutOff).End);
|
||||||
|
var after = CutOff.Pieces(change.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
|
||||||
|
change.EndLimit ?? (vertical ? sheet.Top : sheet.Right), change.NewGaps);
|
||||||
|
return (Subtract(before, after), Subtract(after, before));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<(double Start, double End)> Subtract(
|
||||||
|
IReadOnlyList<(double Start, double End)> from, IReadOnlyList<(double Start, double End)> remove)
|
||||||
|
{
|
||||||
|
var result = new List<(double Start, double End)>();
|
||||||
|
foreach (var piece in from)
|
||||||
|
{
|
||||||
|
var current = piece.Start;
|
||||||
|
foreach (var (start, end) in remove.Where(r => r.End > piece.Start && r.Start < piece.End))
|
||||||
|
{
|
||||||
|
if (start > current + Tolerance.Epsilon)
|
||||||
|
result.Add((current, start));
|
||||||
|
current = System.Math.Max(current, end);
|
||||||
|
}
|
||||||
|
if (piece.End > current + Tolerance.Epsilon)
|
||||||
|
result.Add((current, piece.End));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (CutOff Through, CutOff Stopper, double Point)? Junction(Plate plate, CutOff first, CutOff second)
|
||||||
|
{
|
||||||
|
if (first == second || first.Axis == second.Axis)
|
||||||
|
return null;
|
||||||
|
var firstAt = Line(second);
|
||||||
|
var secondAt = Line(first);
|
||||||
|
if (TouchesGap(first, firstAt) || TouchesGap(second, secondAt))
|
||||||
|
return null;
|
||||||
|
var (firstStart, firstEnd) = NominalSpan(plate, first);
|
||||||
|
var (secondStart, secondEnd) = NominalSpan(plate, second);
|
||||||
|
if (!Within(firstAt, firstStart, firstEnd) || !Within(secondAt, secondStart, secondEnd))
|
||||||
|
return null;
|
||||||
|
var firstStops = AtEnd(firstAt, firstStart, firstEnd);
|
||||||
|
var secondStops = AtEnd(secondAt, secondStart, secondEnd);
|
||||||
|
if (firstStops == secondStops)
|
||||||
|
return null;
|
||||||
|
return firstStops ? (second, first, secondAt) : (first, second, firstAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether one of the cut-off's gaps contains or ends at the value along its axis.
|
||||||
|
private static bool TouchesGap(CutOff cutOff, double along) =>
|
||||||
|
cutOff.Gaps.Any(g => Within(along, g.Start, g.End));
|
||||||
|
|
||||||
|
// The limit an end takes when extended: the next crossing line beyond it, or null for the
|
||||||
|
// sheet edge. False when that end already runs to the sheet edge.
|
||||||
|
private static bool ExtendEnd(Plate plate, CutOff target, bool high, out double? limit)
|
||||||
|
{
|
||||||
|
limit = null;
|
||||||
|
if ((high ? target.EndLimit : target.StartLimit) == null)
|
||||||
|
return false;
|
||||||
|
var (start, end) = NominalSpan(plate, target);
|
||||||
|
var crossings = Crossings(plate, target);
|
||||||
|
if (high)
|
||||||
|
{
|
||||||
|
var next = crossings.Where(c => c > end + Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Min();
|
||||||
|
limit = double.IsNaN(next) ? null : next;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var next = crossings.Where(c => c < start - Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Max();
|
||||||
|
limit = double.IsNaN(next) ? null : next;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the perpendicular cut-offs whose cut pieces reach this one cross it, along its axis.
|
||||||
|
private static List<double> Crossings(Plate plate, CutOff target)
|
||||||
|
{
|
||||||
|
var line = Line(target);
|
||||||
|
return plate.CutOffs
|
||||||
|
.Where(other => other != target && other.Axis != target.Axis)
|
||||||
|
.Where(other =>
|
||||||
|
{
|
||||||
|
var (start, end) = NominalSpan(plate, other);
|
||||||
|
return other.Pieces(start, end).Any(p => Within(line, p.Start, p.End));
|
||||||
|
})
|
||||||
|
.Select(Line)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The change that leaves exactly the given pieces of the nominal line from start to end: a
|
||||||
|
// limit stays null while its end stays at the sheet edge.
|
||||||
|
private static CutOffLimitChange Change(CutOff target, double start, double end,
|
||||||
|
IReadOnlyList<(double Start, double End)> pieces)
|
||||||
|
{
|
||||||
|
var first = pieces[0].Start;
|
||||||
|
var last = pieces[^1].End;
|
||||||
|
return new CutOffLimitChange(target,
|
||||||
|
target.StartLimit == null && first == start ? null : first,
|
||||||
|
target.EndLimit == null && last == end ? null : last,
|
||||||
|
GapsBetween(pieces));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CutOffGap> GapsBetween(IReadOnlyList<(double Start, double End)> pieces)
|
||||||
|
{
|
||||||
|
var gaps = new List<CutOffGap>();
|
||||||
|
for (var i = 1; i < pieces.Count; i++)
|
||||||
|
if (pieces[i].Start > pieces[i - 1].End)
|
||||||
|
gaps.Add(new CutOffGap(pieces[i - 1].End, pieces[i].Start));
|
||||||
|
return gaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CutOffEdit Single(Plate plate, CutOffLimitChange change) =>
|
||||||
|
CutOffEdit.Of(new[] { change }, Notice(plate, change));
|
||||||
|
|
||||||
|
private static string Notice(Plate plate, CutOffLimitChange change)
|
||||||
|
{
|
||||||
|
var cutOff = change.CutOff;
|
||||||
|
if (!(cutOff.HeadLockOvercut > 0))
|
||||||
|
return null;
|
||||||
|
var high = CutOff.FarEdgeIsHigh(cutOff.Axis, plate.Quadrant);
|
||||||
|
var before = (high ? cutOff.EndLimit : cutOff.StartLimit) == null;
|
||||||
|
var after = (high ? change.EndLimit : change.StartLimit) == null;
|
||||||
|
return before == after ? null : after ? OvercutOn : OvercutOff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cut-off's own coordinate across its axis: X for a vertical line, Y for a horizontal one.
|
||||||
|
private static double Line(CutOff cutOff) =>
|
||||||
|
cutOff.Axis == CutOffAxis.Vertical ? cutOff.Position.X : cutOff.Position.Y;
|
||||||
|
|
||||||
|
private static double Along(CutOff cutOff, Vector point) =>
|
||||||
|
cutOff.Axis == CutOffAxis.Vertical ? point.Y : point.X;
|
||||||
|
|
||||||
|
private static double Distance(double value, (double Start, double End) piece) =>
|
||||||
|
value < piece.Start ? piece.Start - value : value > piece.End ? value - piece.End : 0;
|
||||||
|
|
||||||
|
private static bool Within(double value, double start, double end) =>
|
||||||
|
value >= start - Tolerance.Epsilon && value <= end + Tolerance.Epsilon;
|
||||||
|
|
||||||
|
private static bool AtEnd(double value, double start, double end) =>
|
||||||
|
System.Math.Abs(value - start) <= Tolerance.Epsilon || System.Math.Abs(value - end) <= Tolerance.Epsilon;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,15 +33,21 @@ public static class ClampCutDiagnostics
|
|||||||
var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
|
var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
|
||||||
var low = farHigh ? 0 : overcut;
|
var low = farHigh ? 0 : overcut;
|
||||||
var high = farHigh ? overcut : 0;
|
var high = farHigh ? overcut : 0;
|
||||||
var start = cut.Axis == CutOffAxis.Vertical
|
var vertical = cut.Axis == CutOffAxis.Vertical;
|
||||||
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom - low)
|
var from = cut.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left) - low;
|
||||||
: new Vector(cut.StartLimit ?? sheet.Left - low, cut.Position.Y);
|
var to = cut.EndLimit ?? (vertical ? sheet.Top : sheet.Right) + high;
|
||||||
var end = cut.Axis == CutOffAxis.Vertical
|
// A clamp wholly inside a gap meets no cut.
|
||||||
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top + high)
|
|
||||||
: new Vector(cut.EndLimit ?? sheet.Right + high, cut.Position.Y);
|
|
||||||
foreach (var zone in zones)
|
foreach (var zone in zones)
|
||||||
if (Intersects(start, end, zone.Bounds, out var location))
|
foreach (var (pieceStart, pieceEnd) in cut.Pieces(from, to))
|
||||||
findings.Add(new CutOffClampFinding(index, zone.Id, location));
|
{
|
||||||
|
var start = vertical ? new Vector(cut.Position.X, pieceStart) : new Vector(pieceStart, cut.Position.Y);
|
||||||
|
var end = vertical ? new Vector(cut.Position.X, pieceEnd) : new Vector(pieceEnd, cut.Position.Y);
|
||||||
|
if (Intersects(start, end, zone.Bounds, out var location))
|
||||||
|
{
|
||||||
|
findings.Add(new CutOffClampFinding(index, zone.Id, location));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return findings.AsReadOnly();
|
return findings.AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, long Overcut)[] cutoffs;
|
// Gaps are replaced, never edited in place, so their reference stands for their content.
|
||||||
|
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End,
|
||||||
|
IReadOnlyList<CutOffGap> Gaps, long Overcut)[] cutoffs;
|
||||||
private readonly (long Width, long Length, int Quadrant) sheet;
|
private readonly (long Width, long Length, int Quadrant) sheet;
|
||||||
|
private readonly long partSpacing;
|
||||||
|
|
||||||
private 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?, 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,12 +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?, long) CutOffState(CutOff cut) =>
|
private static (CutOff, CutOffAxis, long, long, long?, long?, IReadOnlyList<CutOffGap>, long) CutOffState(CutOff cut) =>
|
||||||
cut == null ? default
|
cut == null ? default
|
||||||
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
|
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
|
||||||
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
|
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
|
||||||
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null,
|
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null,
|
||||||
Bits(cut.HeadLockOvercut));
|
cut.Gaps, Bits(cut.HeadLockOvercut));
|
||||||
|
|
||||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||||
|
|
||||||
@@ -74,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;
|
||||||
|
|||||||
@@ -21,10 +21,19 @@ namespace OpenNest.Diagnostics;
|
|||||||
/// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays
|
/// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays
|
||||||
/// valid.
|
/// valid.
|
||||||
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
|
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
|
||||||
/// that check. The sheet edge and clamps are not spacing-checked here.
|
/// that check. 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>One part to scan: <see cref="Id"/> is its position in the overlap check's input.
|
||||||
|
/// The profile loads inside the scan's error boundary; null means no material.</summary>
|
||||||
|
internal sealed record SpacingInput(int Id, Func<ShapeProfile> Profile, Vector Location);
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="spacing"/> requires any gap at all.</summary>
|
||||||
|
internal static bool Enforces(double spacing) =>
|
||||||
|
double.IsFinite(spacing) && SpacingOutline.InflationFor(spacing) > Tolerance.Epsilon;
|
||||||
|
|
||||||
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
|
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
|
||||||
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
|
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -36,14 +45,58 @@ 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 NativeContours[parts.Count];
|
var fine = new NativeContours[parts.Count];
|
||||||
var raw = new SpacingOutline[parts.Count];
|
var raw = new SpacingOutline[parts.Count];
|
||||||
@@ -51,24 +104,30 @@ internal static class PartSpacingReview
|
|||||||
for (var i = 0; i < parts.Count; i++)
|
for (var i = 0; i < parts.Count; i++)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
if (unreadable.Contains(i))
|
if (unreadable.Contains(parts[i].Id))
|
||||||
continue;
|
continue;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var profile = Profile(parts[i], profiles);
|
var profile = parts[i].Profile();
|
||||||
if (profile == null)
|
if (profile == null)
|
||||||
continue;
|
continue;
|
||||||
shapes[i] = profile;
|
shapes[i] = profile;
|
||||||
raw[i] = SpacingOutline.Build(profile, parts[i].Location, 0);
|
// Repeated parts share a profile, so each shape is offset once.
|
||||||
inflated[i] = SpacingOutline.Build(profile, parts[i].Location, inflateBy);
|
if (!outlines.TryGetValue(profile, out var outline))
|
||||||
if (raw[i] == null || inflated[i] == null)
|
{
|
||||||
|
outline = (SpacingOutline.Build(profile, Vector.Zero, 0),
|
||||||
|
SpacingOutline.Build(profile, Vector.Zero, inflateBy));
|
||||||
|
outlines[profile] = outline;
|
||||||
|
}
|
||||||
|
if (outline.Raw == null || outline.Inflated == null)
|
||||||
throw new ArgumentException("Part material has no closed perimeter.");
|
throw new ArgumentException("Part material has no closed perimeter.");
|
||||||
|
raw[i] = outline.Raw.MovedTo(parts[i].Location);
|
||||||
|
inflated[i] = outline.Inflated.MovedTo(parts[i].Location);
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (IsInvalid(exception))
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
{
|
{
|
||||||
raw[i] = inflated[i] = null;
|
raw[i] = inflated[i] = null;
|
||||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumbers[i], null,
|
incomplete(parts[i].Id, null, exception.Message);
|
||||||
"Part spacing check: " + exception.Message));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +147,8 @@ internal static class PartSpacingReview
|
|||||||
// Sorted by Left, so nothing further along can reach part i either.
|
// Sorted by Left, so nothing further along can reach part i either.
|
||||||
if (other.Left > reach.Right + Tolerance.Epsilon)
|
if (other.Left > reach.Right + Tolerance.Epsilon)
|
||||||
break;
|
break;
|
||||||
if (!BoxesTouch(reach, other) || overlapping.Contains(Key(i, j))
|
var (first, second) = Key(parts[i].Id, parts[j].Id);
|
||||||
|
if (!BoxesTouch(reach, other) || overlapping.Contains((first, second))
|
||||||
|| !inflated[i].Overlaps(raw[j]))
|
|| !inflated[i].Overlaps(raw[j]))
|
||||||
continue;
|
continue;
|
||||||
double gap;
|
double gap;
|
||||||
@@ -101,20 +161,11 @@ internal static class PartSpacingReview
|
|||||||
}
|
}
|
||||||
catch (Exception exception) when (IsInvalid(exception))
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
{
|
{
|
||||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber,
|
incomplete(first, second, exception.Message);
|
||||||
System.Math.Min(partNumbers[i], partNumbers[j]),
|
|
||||||
System.Math.Max(partNumbers[i], partNumbers[j]),
|
|
||||||
"Part spacing check: " + exception.Message));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!(gap < spacing - SpacingOutline.SpacingSlack))
|
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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,16 +182,34 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +147,7 @@ public static class PostVerificationAnalyzer
|
|||||||
&& move.Layer is LayerType.Cut or LayerType.Display
|
&& move.Layer is LayerType.Cut or LayerType.Display
|
||||||
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
||||||
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
||||||
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken,
|
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
|
||||||
proveOpenCuts: true);
|
|
||||||
if (clamps is { Count: > 0 })
|
if (clamps is { Count: > 0 })
|
||||||
foreach (var move in moves)
|
foreach (var move in moves)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -347,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
|
||||||
@@ -382,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;
|
||||||
|
|
||||||
@@ -408,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.
|
||||||
@@ -415,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -450,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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public static class MaximalRectangles
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The grid has a line through every vertex coordinate plus <paramref name="divisions"/>
|
/// The grid has a line through every vertex coordinate plus <paramref name="divisions"/>
|
||||||
/// evenly spaced lines per axis. A cell is free only when no edge passes through its
|
/// evenly spaced lines per axis. A cell is free only when no edge passes through its
|
||||||
/// interior and its centre is inside the region, so results are exact for regions whose
|
/// interior and its center is inside the region, so results are exact for regions whose
|
||||||
/// edges are all horizontal or vertical. Slanted and curved edges are followed as a
|
/// edges are all horizontal or vertical. Slanted and curved edges are followed as a
|
||||||
/// staircase: results stay inside, but can fall short of the true maximum by up to about
|
/// staircase: results stay inside, but can fall short of the true maximum by up to about
|
||||||
/// one cell on each side. Rotate the region to search other rectangle angles.
|
/// one cell on each side. Rotate the region to search other rectangle angles.
|
||||||
@@ -64,9 +64,9 @@ public static class MaximalRectangles
|
|||||||
var crossings = new List<double>();
|
var crossings = new List<double>();
|
||||||
for (var r = 0; r < rows; r++)
|
for (var r = 0; r < rows; r++)
|
||||||
{
|
{
|
||||||
// Even-odd scan along the row's centre line. No vertex lies on it, and an edge that
|
// Even-odd scan along the row's center line. No vertex lies on it, and an edge that
|
||||||
// meets it strictly inside a cell has already marked that cell crossed, so each
|
// meets it strictly inside a cell has already marked that cell crossed, so each
|
||||||
// uncrossed cell is on the same side as its centre.
|
// uncrossed cell is on the same side as its center.
|
||||||
var y = (ys[r] + ys[r + 1]) / 2;
|
var y = (ys[r] + ys[r + 1]) / 2;
|
||||||
crossings.Clear();
|
crossings.Clear();
|
||||||
foreach (var path in paths)
|
foreach (var path in paths)
|
||||||
@@ -279,7 +279,7 @@ public static class MaximalRectangles
|
|||||||
|
|
||||||
foreach (var larger in results)
|
foreach (var larger in results)
|
||||||
{
|
{
|
||||||
if (IsContainedIn(box, larger))
|
if (BoundingBox.IsWithin(box, larger))
|
||||||
{
|
{
|
||||||
dominated = true;
|
dominated = true;
|
||||||
break;
|
break;
|
||||||
@@ -292,13 +292,4 @@ public static class MaximalRectangles
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsContainedIn(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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ namespace OpenNest.Geometry
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bounded search for callers that cannot afford refinement along a long thin shape:
|
/// Bounded search for callers that cannot afford refinement along a long thin shape:
|
||||||
/// stops after about <paramref name="maxCells"/> distance evaluations and returns the best
|
/// stops after about <paramref name="maxCells"/> distance evaluations and returns the best
|
||||||
/// cell centre so far. When the starting grid would exceed a quarter of the budget it is
|
/// cell center so far. When the starting grid would exceed a quarter of the budget it is
|
||||||
/// coarsened so the whole shape is sampled first. The result can lie outside a shape
|
/// coarsened so the whole shape is sampled first. The result can lie outside a shape
|
||||||
/// thinner than the remaining refinement, so callers must check it.
|
/// thinner than the remaining refinement, so callers must check it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -28,12 +28,16 @@ namespace OpenNest.Geometry
|
|||||||
/// A precision proportional to the shape's mean material width (twice the net area over
|
/// A precision proportional to the shape's mean material width (twice the net area over
|
||||||
/// the total outline length, holes included), so small or slender shapes refine further
|
/// the total outline length, holes included), so small or slender shapes refine further
|
||||||
/// than large solid ones. A fixed precision near the shape's size stops at the first
|
/// than large solid ones. A fixed precision near the shape's size stops at the first
|
||||||
/// interior cell.
|
/// interior cell. Holes that leave no net area (a second outline mistaken for a hole, so
|
||||||
|
/// lying outside the first) fall back to the outline alone: a near-zero precision would
|
||||||
|
/// refine a pole ridge, such as a rectangle's centerline, into billions of cells.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static double ThicknessPrecision(Polygon outer, IList<Polygon> holes = null, double fraction = 0.05)
|
public static double ThicknessPrecision(Polygon outer, IList<Polygon> holes = null, double fraction = 0.05)
|
||||||
{
|
{
|
||||||
var area = outer.Area();
|
var outerArea = outer.Area();
|
||||||
var length = ClosedLength(outer);
|
var outerLength = ClosedLength(outer);
|
||||||
|
var area = outerArea;
|
||||||
|
var length = outerLength;
|
||||||
|
|
||||||
if (holes != null)
|
if (holes != null)
|
||||||
{
|
{
|
||||||
@@ -44,6 +48,13 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Equal outlines leave rounding noise, not material.
|
||||||
|
if (!(area > outerArea * 1e-6) || !(length > 0))
|
||||||
|
{
|
||||||
|
area = outerArea;
|
||||||
|
length = outerLength;
|
||||||
|
}
|
||||||
|
|
||||||
if (!(area > 0) || !(length > 0))
|
if (!(area > 0) || !(length > 0))
|
||||||
return 1e-9;
|
return 1e-9;
|
||||||
|
|
||||||
@@ -106,6 +117,7 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
|
|
||||||
var halfCell = cellSize / 2;
|
var halfCell = cellSize / 2;
|
||||||
|
var boxes = HoleBoxes(holes);
|
||||||
|
|
||||||
var initial = new List<Cell>();
|
var initial = new List<Cell>();
|
||||||
|
|
||||||
@@ -113,7 +125,7 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
for (var y = minY; y < maxY; y += cellSize)
|
for (var y = minY; y < maxY; y += cellSize)
|
||||||
{
|
{
|
||||||
initial.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes));
|
initial.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes, boxes));
|
||||||
// Far from the origin a step smaller than the coordinate's precision
|
// Far from the origin a step smaller than the coordinate's precision
|
||||||
// no longer advances; the grid would never end.
|
// no longer advances; the grid would never end.
|
||||||
if (y + cellSize == y)
|
if (y + cellSize == y)
|
||||||
@@ -125,7 +137,7 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
initial.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
|
initial.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
|
||||||
|
|
||||||
var bestCell = GetCentroidCell(outer, holes);
|
var bestCell = GetCentroidCell(outer, holes, boxes);
|
||||||
evaluatedCells = initial.Count + 1;
|
evaluatedCells = initial.Count + 1;
|
||||||
|
|
||||||
for (var i = 0; i < initial.Count; i++)
|
for (var i = 0; i < initial.Count; i++)
|
||||||
@@ -157,7 +169,7 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
halfCell = cell.HalfSize / 2;
|
halfCell = cell.HalfSize / 2;
|
||||||
|
|
||||||
// Far from the origin a half-step may no longer move the centre; such a cell
|
// Far from the origin a half-step may no longer move the center; such a cell
|
||||||
// cannot be split, and its identical children would multiply without end.
|
// cannot be split, and its identical children would multiply without end.
|
||||||
if (cell.X - halfCell == cell.X || cell.X + halfCell == cell.X
|
if (cell.X - halfCell == cell.X || cell.X + halfCell == cell.X
|
||||||
|| cell.Y - halfCell == cell.Y || cell.Y + halfCell == cell.Y)
|
|| cell.Y - halfCell == cell.Y || cell.Y + halfCell == cell.Y)
|
||||||
@@ -165,10 +177,10 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
var newCells = new[]
|
var newCells = new[]
|
||||||
{
|
{
|
||||||
new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, outer, holes),
|
new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, outer, holes, boxes),
|
||||||
new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, outer, holes),
|
new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, outer, holes, boxes),
|
||||||
new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, outer, holes),
|
new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, outer, holes, boxes),
|
||||||
new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, outer, holes),
|
new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, outer, holes, boxes),
|
||||||
};
|
};
|
||||||
evaluatedCells += newCells.Length;
|
evaluatedCells += newCells.Length;
|
||||||
|
|
||||||
@@ -182,7 +194,7 @@ namespace OpenNest.Geometry
|
|||||||
return new Vector(bestCell.X, bestCell.Y);
|
return new Vector(bestCell.X, bestCell.Y);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Cell GetCentroidCell(Polygon outer, IList<Polygon> holes)
|
private static Cell GetCentroidCell(Polygon outer, IList<Polygon> holes, Box[] boxes)
|
||||||
{
|
{
|
||||||
var area = 0.0;
|
var area = 0.0;
|
||||||
var cx = 0.0;
|
var cx = 0.0;
|
||||||
@@ -202,12 +214,12 @@ namespace OpenNest.Geometry
|
|||||||
area *= 0.5;
|
area *= 0.5;
|
||||||
|
|
||||||
if (System.Math.Abs(area) < 1e-10)
|
if (System.Math.Abs(area) < 1e-10)
|
||||||
return new Cell(verts[0].X, verts[0].Y, 0, outer, holes);
|
return new Cell(verts[0].X, verts[0].Y, 0, outer, holes, boxes);
|
||||||
|
|
||||||
cx /= (6 * area);
|
cx /= (6 * area);
|
||||||
cy /= (6 * area);
|
cy /= (6 * area);
|
||||||
|
|
||||||
return new Cell(cx, cy, 0, outer, holes);
|
return new Cell(cx, cy, 0, outer, holes, boxes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double PointToPolygonDist(double x, double y, Polygon polygon)
|
private static double PointToPolygonDist(double x, double y, Polygon polygon)
|
||||||
@@ -256,7 +268,7 @@ namespace OpenNest.Geometry
|
|||||||
public readonly double Dist;
|
public readonly double Dist;
|
||||||
public readonly double MaxDist;
|
public readonly double MaxDist;
|
||||||
|
|
||||||
public Cell(double x, double y, double halfSize, Polygon outer, IList<Polygon> holes)
|
public Cell(double x, double y, double halfSize, Polygon outer, IList<Polygon> holes, Box[] boxes)
|
||||||
{
|
{
|
||||||
X = x;
|
X = x;
|
||||||
Y = y;
|
Y = y;
|
||||||
@@ -269,7 +281,7 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
for (var i = 0; i < holes.Count; i++)
|
for (var i = 0; i < holes.Count; i++)
|
||||||
{
|
{
|
||||||
if (holes[i].ContainsPoint(pt))
|
if (boxes[i].Contains(x, y) && holes[i].ContainsPoint(pt))
|
||||||
{
|
{
|
||||||
inside = false;
|
inside = false;
|
||||||
break;
|
break;
|
||||||
@@ -277,7 +289,7 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Dist = PointToAllEdgesDist(x, y, outer, holes);
|
Dist = PointToAllEdgesDist(x, y, outer, holes, boxes);
|
||||||
|
|
||||||
if (!inside)
|
if (!inside)
|
||||||
Dist = -Dist;
|
Dist = -Dist;
|
||||||
@@ -290,7 +302,8 @@ namespace OpenNest.Geometry
|
|||||||
double x,
|
double x,
|
||||||
double y,
|
double y,
|
||||||
Polygon outer,
|
Polygon outer,
|
||||||
IList<Polygon> holes
|
IList<Polygon> holes,
|
||||||
|
Box[] boxes
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var minDist = PointToPolygonDist(x, y, outer);
|
var minDist = PointToPolygonDist(x, y, outer);
|
||||||
@@ -299,6 +312,9 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
for (var i = 0; i < holes.Count; i++)
|
for (var i = 0; i < holes.Count; i++)
|
||||||
{
|
{
|
||||||
|
// No edge is nearer than its hole's box, so a farther box cannot lower the minimum.
|
||||||
|
if (boxes[i].Distance(x, y) > minDist)
|
||||||
|
continue;
|
||||||
var d = PointToPolygonDist(x, y, holes[i]);
|
var d = PointToPolygonDist(x, y, holes[i]);
|
||||||
if (d < minDist)
|
if (d < minDist)
|
||||||
minDist = d;
|
minDist = d;
|
||||||
@@ -307,5 +323,38 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
return minDist;
|
return minDist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each hole's vertex bounds: a cell skips the containment test outside them and the
|
||||||
|
// edge distances when they are farther than the nearest edge found so far. Both skips
|
||||||
|
// leave the result exactly as without them; parts with many small holes search far faster.
|
||||||
|
private static Box[] HoleBoxes(IList<Polygon> holes)
|
||||||
|
{
|
||||||
|
if (holes == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var boxes = new Box[holes.Count];
|
||||||
|
for (var i = 0; i < holes.Count; i++)
|
||||||
|
{
|
||||||
|
var box = new Box(double.MaxValue, double.MaxValue, double.MinValue, double.MinValue);
|
||||||
|
foreach (var v in holes[i].Vertices)
|
||||||
|
box = new Box(System.Math.Min(box.MinX, v.X), System.Math.Min(box.MinY, v.Y),
|
||||||
|
System.Math.Max(box.MaxX, v.X), System.Math.Max(box.MaxY, v.Y));
|
||||||
|
boxes[i] = box;
|
||||||
|
}
|
||||||
|
return boxes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct Box(double MinX, double MinY, double MaxX, double MaxY)
|
||||||
|
{
|
||||||
|
// Inclusive, so a point on a hole's edge is still tested.
|
||||||
|
public bool Contains(double x, double y) => x >= MinX && x <= MaxX && y >= MinY && y <= MaxY;
|
||||||
|
|
||||||
|
public double Distance(double x, double y)
|
||||||
|
{
|
||||||
|
var dx = System.Math.Max(System.Math.Max(MinX - x, x - MaxX), 0);
|
||||||
|
var dy = System.Math.Max(System.Math.Max(MinY - y, y - MaxY), 0);
|
||||||
|
return System.Math.Sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
@@ -120,7 +120,7 @@ namespace OpenNest.Geometry
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public List<Line> ToLines()
|
public List<Line> ToLines()
|
||||||
{
|
{
|
||||||
var list = new List<Line>();
|
var list = new List<Line>(Vertices.Count);
|
||||||
|
|
||||||
if (Vertices.Count < 2)
|
if (Vertices.Count < 2)
|
||||||
return list;
|
return list;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -585,8 +585,8 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!TryGetEnds(last.Offset, out _, out var gapStart)
|
!EntityEndpoints.TryGet(last.Offset, out _, out var gapStart)
|
||||||
|| !TryGetEnds(next.Offset, out var gapEnd, out _)
|
|| !EntityEndpoints.TryGet(next.Offset, out var gapEnd, out _)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -625,7 +625,7 @@ namespace OpenNest.Geometry
|
|||||||
corner = default;
|
corner = default;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!TryGetEnds(last, out _, out corner)
|
!EntityEndpoints.TryGet(last, out _, out corner)
|
||||||
|| !TryGetTangents(last, out _, out var d1)
|
|| !TryGetTangents(last, out _, out var d1)
|
||||||
|| !TryGetTangents(next, out var d2, out _)
|
|| !TryGetTangents(next, out var d2, out _)
|
||||||
)
|
)
|
||||||
@@ -637,26 +637,6 @@ namespace OpenNest.Geometry
|
|||||||
|| (side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
|
|| (side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetEnds(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Direction of travel at the start and end of a line or arc.
|
/// Direction of travel at the start and end of a line or arc.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -750,64 +730,25 @@ namespace OpenNest.Geometry
|
|||||||
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
|
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
|
||||||
/// making the method independent of the original contour winding direction.
|
/// making the method independent of the original contour winding direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal Shape OffsetOutward(double distance)
|
internal Shape OffsetOutward(double distance) =>
|
||||||
{
|
OffsetLeftWithWinding(distance, RotationType.CW);
|
||||||
var poly = ToPolygon();
|
|
||||||
|
|
||||||
if (
|
|
||||||
poly == null
|
|
||||||
|| poly.Vertices.Count < 3
|
|
||||||
|| poly.RotationDirection() == RotationType.CW
|
|
||||||
)
|
|
||||||
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
|
||||||
|
|
||||||
// Shape is CCW — reverse to CW so Left offset goes outward.
|
|
||||||
var copy = new Shape();
|
|
||||||
|
|
||||||
for (var i = Entities.Count - 1; i >= 0; i--)
|
|
||||||
{
|
|
||||||
switch (Entities[i])
|
|
||||||
{
|
|
||||||
case Line l:
|
|
||||||
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
|
|
||||||
break;
|
|
||||||
case Arc a:
|
|
||||||
copy.Entities.Add(
|
|
||||||
new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed)
|
|
||||||
{
|
|
||||||
Layer = a.Layer,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case Circle c:
|
|
||||||
copy.Entities.Add(
|
|
||||||
new Circle(c.Center, c.Radius)
|
|
||||||
{
|
|
||||||
Layer = c.Layer,
|
|
||||||
Rotation = RotationType.CW,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Offsets the shape inward by the given distance.
|
/// Offsets the shape inward by the given distance.
|
||||||
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
|
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
|
||||||
/// making the method independent of the original contour winding direction.
|
/// making the method independent of the original contour winding direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal Shape OffsetInward(double distance)
|
internal Shape OffsetInward(double distance) =>
|
||||||
|
OffsetLeftWithWinding(distance, RotationType.CCW);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offsets Left after normalizing the contour to <paramref name="winding"/>.
|
||||||
|
/// </summary>
|
||||||
|
private Shape OffsetLeftWithWinding(double distance, RotationType winding)
|
||||||
{
|
{
|
||||||
var poly = ToPolygon();
|
var poly = ToPolygon();
|
||||||
|
|
||||||
if (
|
if (poly == null || poly.Vertices.Count < 3 || poly.RotationDirection() == winding)
|
||||||
poly == null
|
|
||||||
|| poly.Vertices.Count < 3
|
|
||||||
|| poly.RotationDirection() == RotationType.CCW
|
|
||||||
)
|
|
||||||
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||||
|
|
||||||
// Create a reversed copy to avoid mutating shared entity objects.
|
// Create a reversed copy to avoid mutating shared entity objects.
|
||||||
@@ -833,7 +774,7 @@ namespace OpenNest.Geometry
|
|||||||
new Circle(c.Center, c.Radius)
|
new Circle(c.Center, c.Radius)
|
||||||
{
|
{
|
||||||
Layer = c.Layer,
|
Layer = c.Layer,
|
||||||
Rotation = RotationType.CCW,
|
Rotation = winding,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -64,47 +64,28 @@ namespace OpenNest.Geometry
|
|||||||
if (weldTolerance.HasValue)
|
if (weldTolerance.HasValue)
|
||||||
WeldEndpoints(entityList, weldTolerance.Value);
|
WeldEndpoints(entityList, weldTolerance.Value);
|
||||||
|
|
||||||
while (entityList.Count > 0)
|
// Chain in list order: each shape starts at the first unused entity and takes the
|
||||||
|
// first unused entity, in list order, with an end at its current end. An endpoint
|
||||||
|
// grid finds those candidates without rescanning the whole list at every step.
|
||||||
|
var index = new EndpointIndex(entityList);
|
||||||
|
var used = new bool[entityList.Count];
|
||||||
|
for (var first = 0; first < entityList.Count; first++)
|
||||||
{
|
{
|
||||||
var next = entityList[0];
|
if (used[first])
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var next = entityList[first];
|
||||||
|
used[first] = true;
|
||||||
var shape = new Shape();
|
var shape = new Shape();
|
||||||
shape.Entities.Add(next);
|
shape.Entities.Add(next);
|
||||||
|
|
||||||
entityList.RemoveAt(0);
|
var startPoint = EndOf(next);
|
||||||
|
|
||||||
Vector startPoint = new Vector();
|
|
||||||
Entity connected;
|
Entity connected;
|
||||||
|
|
||||||
switch (next.Type)
|
while ((connected = index.TakeConnected(startPoint, entityList, used)) != null)
|
||||||
{
|
|
||||||
case EntityType.Arc:
|
|
||||||
var arc = (Arc)next;
|
|
||||||
startPoint = arc.EndPoint();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EntityType.Line:
|
|
||||||
var line = (Line)next;
|
|
||||||
startPoint = line.EndPoint;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
while ((connected = GetConnected(startPoint, entityList)) != null)
|
|
||||||
{
|
{
|
||||||
shape.Entities.Add(connected);
|
shape.Entities.Add(connected);
|
||||||
entityList.Remove(connected);
|
startPoint = EndOf(connected);
|
||||||
|
|
||||||
switch (connected.Type)
|
|
||||||
{
|
|
||||||
case EntityType.Arc:
|
|
||||||
var arc = (Arc)connected;
|
|
||||||
startPoint = arc.EndPoint();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EntityType.Line:
|
|
||||||
var line = (Line)connected;
|
|
||||||
startPoint = line.EndPoint;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
shape.UpdateBounds();
|
shape.UpdateBounds();
|
||||||
@@ -205,6 +186,131 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Vector EndOf(Entity entity) => entity.Type switch
|
||||||
|
{
|
||||||
|
EntityType.Arc => ((Arc)entity).EndPoint(),
|
||||||
|
EntityType.Line => ((Line)entity).EndPoint,
|
||||||
|
_ => new Vector(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Matches GetConnected entity by entity: true and reversed when only its end matches.
|
||||||
|
private static bool Connects(Entity entity, Vector pt, double tol)
|
||||||
|
{
|
||||||
|
switch (entity.Type)
|
||||||
|
{
|
||||||
|
case EntityType.Arc:
|
||||||
|
var arc = (Arc)entity;
|
||||||
|
if (arc.StartPoint().DistanceTo(pt) <= tol)
|
||||||
|
return true;
|
||||||
|
if (arc.EndPoint().DistanceTo(pt) <= tol)
|
||||||
|
{
|
||||||
|
arc.Reverse();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case EntityType.Line:
|
||||||
|
var line = (Line)entity;
|
||||||
|
if (line.StartPoint.DistanceTo(pt) <= tol)
|
||||||
|
return true;
|
||||||
|
if (line.EndPoint.DistanceTo(pt) <= tol)
|
||||||
|
{
|
||||||
|
line.Reverse();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entity positions by the grid cells of their endpoints, cells twice the chain
|
||||||
|
/// tolerance wide, so every endpoint within tolerance of a point lies in its cell or a
|
||||||
|
/// neighbor, rounding included. Reversing an entity swaps its endpoints, so the index stays valid.
|
||||||
|
/// Endpoints too far out or not finite to place are checked on every query.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class EndpointIndex
|
||||||
|
{
|
||||||
|
private const double CellSize = 2 * Tolerance.ChainTolerance;
|
||||||
|
private const double MaxCell = 1e15;
|
||||||
|
private readonly Dictionary<(long, long), List<int>> cells = new();
|
||||||
|
private readonly List<int> unplaced = new();
|
||||||
|
private readonly List<int> candidates = new();
|
||||||
|
|
||||||
|
public EndpointIndex(List<Entity> entities)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < entities.Count; i++)
|
||||||
|
{
|
||||||
|
var (start, end) = GetEndpoints(entities[i]);
|
||||||
|
if (!Place(i, start) | !Place(i, end))
|
||||||
|
unplaced.Add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool Place(int i, Vector point)
|
||||||
|
{
|
||||||
|
if (!TryCell(point, out var cell))
|
||||||
|
return false;
|
||||||
|
if (!cells.TryGetValue(cell, out var list))
|
||||||
|
cells[cell] = list = new List<int>();
|
||||||
|
if (list.Count == 0 || list[^1] != i)
|
||||||
|
list.Add(i);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCell(Vector point, out (long, long) cell)
|
||||||
|
{
|
||||||
|
var x = System.Math.Floor(point.X / CellSize);
|
||||||
|
var y = System.Math.Floor(point.Y / CellSize);
|
||||||
|
cell = default;
|
||||||
|
if (!(System.Math.Abs(x) < MaxCell) || !(System.Math.Abs(y) < MaxCell))
|
||||||
|
return false;
|
||||||
|
cell = ((long)x, (long)y);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The first unused entity, in list order, that connects to <paramref name="pt"/>; marked used.</summary>
|
||||||
|
public Entity TakeConnected(Vector pt, List<Entity> entities, bool[] used)
|
||||||
|
{
|
||||||
|
candidates.Clear();
|
||||||
|
if (TryCell(pt, out var cell))
|
||||||
|
{
|
||||||
|
for (var dx = -1L; dx <= 1; dx++)
|
||||||
|
{
|
||||||
|
for (var dy = -1L; dy <= 1; dy++)
|
||||||
|
{
|
||||||
|
if (cells.TryGetValue((cell.Item1 + dx, cell.Item2 + dy), out var list))
|
||||||
|
candidates.AddRange(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.AddRange(unplaced);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// A point that cannot be placed is compared with every entity, as before.
|
||||||
|
for (var i = 0; i < entities.Count; i++)
|
||||||
|
candidates.Add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.Sort();
|
||||||
|
var previous = -1;
|
||||||
|
foreach (var i in candidates)
|
||||||
|
{
|
||||||
|
if (i == previous || used[i])
|
||||||
|
continue;
|
||||||
|
previous = i;
|
||||||
|
if (Connects(entities[i], pt, Tolerance.ChainTolerance))
|
||||||
|
{
|
||||||
|
used[i] = true;
|
||||||
|
return entities[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
|
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
|
||||||
{
|
{
|
||||||
var tol = Tolerance.ChainTolerance;
|
var tol = Tolerance.ChainTolerance;
|
||||||
|
|||||||
@@ -15,10 +15,22 @@ namespace OpenNest.Geometry
|
|||||||
Update(entities);
|
Update(entities);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Update(List<Entity> entities)
|
private ShapeProfile()
|
||||||
{
|
{
|
||||||
var shapes = ShapeBuilder.GetShapes(entities);
|
}
|
||||||
|
|
||||||
|
/// <summary>A profile of shapes already built, for callers that need them too; at least one.</summary>
|
||||||
|
public static ShapeProfile FromShapes(List<Shape> shapes)
|
||||||
|
{
|
||||||
|
var profile = new ShapeProfile();
|
||||||
|
profile.Update(shapes);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Update(List<Entity> entities) => Update(ShapeBuilder.GetShapes(entities));
|
||||||
|
|
||||||
|
private void Update(List<Shape> shapes)
|
||||||
|
{
|
||||||
Perimeter = shapes[0];
|
Perimeter = shapes[0];
|
||||||
Cutouts = new List<Shape>();
|
Cutouts = new List<Shape>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
namespace OpenNest.Geometry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Conservative perpendicular-extent tests for slides along an exact axis direction.
|
||||||
|
/// A ray parallel to an axis can only meet an entity whose extent across that axis holds
|
||||||
|
/// the ray's perpendicular coordinate. Each test answers "may emit" whenever the matching
|
||||||
|
/// kernel could still report a hit: margins cover the kernel's own tolerances and its
|
||||||
|
/// floating-point rounding. A pair with any coordinate or radius that is NaN, infinite
|
||||||
|
/// or beyond <see cref="CoordinateLimit"/> is always kept, because the kernels' products
|
||||||
|
/// can then become NaN and still emit.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SlideBand
|
||||||
|
{
|
||||||
|
internal enum Axis
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>Push along ±X; the perpendicular coordinate is Y.</summary>
|
||||||
|
PerpendicularY,
|
||||||
|
|
||||||
|
/// <summary>Push along ±Y; the perpendicular coordinate is X.</summary>
|
||||||
|
PerpendicularX,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Largest coordinate or radius magnitude the tests reason about. Below it every
|
||||||
|
/// square and product in the kernels stays finite (the squares stay below 1e301).
|
||||||
|
/// </summary>
|
||||||
|
internal const double CoordinateLimit = 1e150;
|
||||||
|
|
||||||
|
// RayLine accepts a segment parameter in [-Eps, 1 + Eps], which reaches Eps of the
|
||||||
|
// segment's perpendicular span past either end. Twice that is kept: the computed
|
||||||
|
// parameter differs from the exact one by a few ulps, far less than the extra Eps.
|
||||||
|
// No absolute slack is needed: the kernels translate entities with the same
|
||||||
|
// expressions as these tests, and a circle point c + r·sinθ rounds monotonically, so
|
||||||
|
// it never lies outside fl(c ± r).
|
||||||
|
internal const double SpanSlack = 2 * Math.Tolerance.Epsilon;
|
||||||
|
|
||||||
|
// SolveRayCircle accepts a nonnegative discriminant b² − 4ac. For an axis ray that is
|
||||||
|
// 4(r² − oy²) up to rounding of terms of size ox² + oy² + r², so a hit is possible when
|
||||||
|
// oy² ≤ r² + ε(1 + ox² + oy² + r²). This ε is hundreds of ulps; the absolute 1 is a
|
||||||
|
// floor for squares that underflow.
|
||||||
|
internal const double QuadraticSlack = 1e-13;
|
||||||
|
|
||||||
|
public static Axis For(double dirX, double dirY)
|
||||||
|
{
|
||||||
|
if (dirY == 0 && (dirX == 1 || dirX == -1))
|
||||||
|
return Axis.PerpendicularY;
|
||||||
|
if (dirX == 0 && (dirY == 1 || dirY == -1))
|
||||||
|
return Axis.PerpendicularX;
|
||||||
|
return Axis.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// False only when <see cref="SlideEvents.Ray"/> from (vx, vy) along the axis cannot
|
||||||
|
/// emit against <paramref name="entity"/> translated by (dx, dy).
|
||||||
|
/// </summary>
|
||||||
|
public static bool RayMayHit(
|
||||||
|
Axis axis,
|
||||||
|
double vx,
|
||||||
|
double vy,
|
||||||
|
Entity entity,
|
||||||
|
double dx,
|
||||||
|
double dy
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (!Bounded(vx) || !Bounded(vy))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
switch (entity)
|
||||||
|
{
|
||||||
|
case Line line:
|
||||||
|
{
|
||||||
|
var p1x = line.pt1.X + dx;
|
||||||
|
var p1y = line.pt1.Y + dy;
|
||||||
|
var p2x = line.pt2.X + dx;
|
||||||
|
var p2y = line.pt2.Y + dy;
|
||||||
|
if (!Bounded(p1x) || !Bounded(p1y) || !Bounded(p2x) || !Bounded(p2y))
|
||||||
|
return true;
|
||||||
|
return PointMayReachSegment(
|
||||||
|
Perp(axis, vx, vy),
|
||||||
|
Perp(axis, p1x, p1y),
|
||||||
|
Perp(axis, p2x, p2y)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case Arc arc:
|
||||||
|
return PointMayReachCircle(
|
||||||
|
axis,
|
||||||
|
vx,
|
||||||
|
vy,
|
||||||
|
arc.Center.X + dx,
|
||||||
|
arc.Center.Y + dy,
|
||||||
|
arc.Radius
|
||||||
|
);
|
||||||
|
case Circle circle:
|
||||||
|
return PointMayReachCircle(
|
||||||
|
axis,
|
||||||
|
vx,
|
||||||
|
vy,
|
||||||
|
circle.Center.X + dx,
|
||||||
|
circle.Center.Y + dy,
|
||||||
|
circle.Radius
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// False only when no point of the circle (cx, cy, r) can reach the segment along the
|
||||||
|
/// axis, which is what <see cref="SlideEvents.ArcToLine"/> tests for each arc/line pair.
|
||||||
|
/// </summary>
|
||||||
|
public static bool CircleMayReachLine(
|
||||||
|
Axis axis,
|
||||||
|
double cx,
|
||||||
|
double cy,
|
||||||
|
double r,
|
||||||
|
double p1x,
|
||||||
|
double p1y,
|
||||||
|
double p2x,
|
||||||
|
double p2y
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
!Bounded(cx) || !Bounded(cy) || !Bounded(r)
|
||||||
|
|| !Bounded(p1x) || !Bounded(p1y) || !Bounded(p2x) || !Bounded(p2y)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var c = Perp(axis, cx, cy);
|
||||||
|
var a = Perp(axis, p1x, p1y);
|
||||||
|
var b = Perp(axis, p2x, p2y);
|
||||||
|
var lo = System.Math.Min(a, b);
|
||||||
|
var hi = System.Math.Max(a, b);
|
||||||
|
var radius = System.Math.Abs(r);
|
||||||
|
var margin = SpanSlack * (hi - lo);
|
||||||
|
if (c + radius < lo - margin || c - radius > hi + margin)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// False only when two circles sliding along the axis can never touch, the condition
|
||||||
|
/// <see cref="SlideEvents.CurveTangency"/> solves for both external and internal contact.
|
||||||
|
/// </summary>
|
||||||
|
public static bool CirclesMayTouch(
|
||||||
|
Axis axis,
|
||||||
|
double movingCx,
|
||||||
|
double movingCy,
|
||||||
|
double movingR,
|
||||||
|
double stationaryCx,
|
||||||
|
double stationaryCy,
|
||||||
|
double stationaryR
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
!Bounded(movingCx) || !Bounded(movingCy) || !Bounded(movingR)
|
||||||
|
|| !Bounded(stationaryCx) || !Bounded(stationaryCy) || !Bounded(stationaryR)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var radius = System.Math.Abs(movingR) + System.Math.Abs(stationaryR);
|
||||||
|
return CenterMayReach(
|
||||||
|
Para(axis, movingCx, movingCy) - Para(axis, stationaryCx, stationaryCy),
|
||||||
|
Perp(axis, movingCx, movingCy) - Perp(axis, stationaryCx, stationaryCy),
|
||||||
|
radius
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PointMayReachSegment(double v, double a, double b)
|
||||||
|
{
|
||||||
|
var lo = System.Math.Min(a, b);
|
||||||
|
var hi = System.Math.Max(a, b);
|
||||||
|
var margin = SpanSlack * (hi - lo);
|
||||||
|
if (v < lo - margin || v > hi + margin)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PointMayReachCircle(
|
||||||
|
Axis axis,
|
||||||
|
double vx,
|
||||||
|
double vy,
|
||||||
|
double cx,
|
||||||
|
double cy,
|
||||||
|
double r
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (!Bounded(cx) || !Bounded(cy) || !Bounded(r))
|
||||||
|
return true;
|
||||||
|
return CenterMayReach(
|
||||||
|
Para(axis, vx, vy) - Para(axis, cx, cy),
|
||||||
|
Perp(axis, vx, vy) - Perp(axis, cx, cy),
|
||||||
|
r
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CenterMayReach(double ox, double oy, double r)
|
||||||
|
{
|
||||||
|
var oy2 = oy * oy;
|
||||||
|
var r2 = r * r;
|
||||||
|
var slack = QuadraticSlack * (1 + ox * ox + oy2 + r2);
|
||||||
|
if (oy2 > r2 + slack)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>False for NaN, infinities and magnitudes beyond the coordinate limit.</summary>
|
||||||
|
private static bool Bounded(double value) => System.Math.Abs(value) <= CoordinateLimit;
|
||||||
|
|
||||||
|
internal static double Perp(Axis axis, double x, double y) =>
|
||||||
|
axis == Axis.PerpendicularY ? y : x;
|
||||||
|
|
||||||
|
internal static double Para(Axis axis, double x, double y) =>
|
||||||
|
axis == Axis.PerpendicularY ? x : y;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Geometry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Buckets entity indices by their perpendicular extent for one axis-aligned slide, so a
|
||||||
|
/// band query returns, in ascending entity order, a superset of the entities the
|
||||||
|
/// <see cref="SlideBand"/> tests accept. Callers still apply those exact tests; the index
|
||||||
|
/// only avoids visiting entities that cannot pass them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Each extent is the exact acceptance bound of the matching <see cref="SlideBand"/> test,
|
||||||
|
/// widened by a reach that covers the quadratic slack of ray-circle and curve-curve tests
|
||||||
|
/// (which grows with the along-axis distance) plus rounding. Bucket numbers are a
|
||||||
|
/// monotone function of the coordinate, so a value inside an extent always lands in one of
|
||||||
|
/// the extent's buckets.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class SlideBandIndex
|
||||||
|
{
|
||||||
|
/// <summary>Entities of one kind an index holds.</summary>
|
||||||
|
internal enum Kind
|
||||||
|
{
|
||||||
|
All,
|
||||||
|
Lines,
|
||||||
|
Curves,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below this many entities a plain scan is as cheap as building buckets.
|
||||||
|
internal const int MinimumEntities = 24;
|
||||||
|
|
||||||
|
private const int MaximumBuckets = 512;
|
||||||
|
|
||||||
|
// Merging more sorted bucket lists than this by repeated minimum is slower than a sort.
|
||||||
|
private const int MaximumMergedBuckets = 16;
|
||||||
|
|
||||||
|
private readonly double min;
|
||||||
|
private readonly double scale;
|
||||||
|
private readonly int bucketCount;
|
||||||
|
private readonly int[] starts;
|
||||||
|
private readonly int[] items;
|
||||||
|
|
||||||
|
private SlideBandIndex(double min, double scale, int bucketCount, int[] starts, int[] items)
|
||||||
|
{
|
||||||
|
this.min = min;
|
||||||
|
this.scale = scale;
|
||||||
|
this.bucketCount = bucketCount;
|
||||||
|
this.starts = starts;
|
||||||
|
this.items = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The widening every extent needs on top of its own exact bound: 2√ε·(1 + separation),
|
||||||
|
/// where separation is twice the largest along-axis coordinate (the quadratic slack),
|
||||||
|
/// plus a relative rounding term.
|
||||||
|
/// NaN when a coordinate is nonfinite or beyond <see cref="SlideBand.CoordinateLimit"/>,
|
||||||
|
/// where the band tests keep every pair and an index could not stand in for them.
|
||||||
|
/// </summary>
|
||||||
|
public static double Reach(double maxAbsAlong, double maxAbsPerpendicular)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
!(maxAbsAlong <= SlideBand.CoordinateLimit)
|
||||||
|
|| !(maxAbsPerpendicular <= SlideBand.CoordinateLimit)
|
||||||
|
)
|
||||||
|
return double.NaN;
|
||||||
|
var separation = 2 * maxAbsAlong;
|
||||||
|
// The second term is headroom for rounding of the extents and the curve tests'
|
||||||
|
// 2ε·|r|; no test case needs it today, but it costs nothing measurable.
|
||||||
|
return 2 * System.Math.Sqrt(SlideBand.QuadraticSlack) * (1 + separation)
|
||||||
|
+ 1e-12 * (1 + separation + maxAbsPerpendicular);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A curve's half-width before widening, |r|. The quadratic tests accept at most
|
||||||
|
/// |oy| ≤ |r|·(1 + 2ε) + 2√ε·(1 + |ox|) with ε = QuadraticSlack; the reach holds both the
|
||||||
|
/// second term and the first one's 2ε·|r|, since its rounding term is 1e-12 times a bound
|
||||||
|
/// that includes every radius.
|
||||||
|
/// </summary>
|
||||||
|
public static double CurveReach(double r) => System.Math.Abs(r);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indexes the entities of <paramref name="kind"/> in <paramref name="entities"/>
|
||||||
|
/// translated by (dx, dy), or returns null when the list is small or the geometry or
|
||||||
|
/// reach is not usable.
|
||||||
|
/// </summary>
|
||||||
|
public static SlideBandIndex? Build(
|
||||||
|
SlideBand.Axis axis,
|
||||||
|
List<Entity> entities,
|
||||||
|
double dx,
|
||||||
|
double dy,
|
||||||
|
double reach,
|
||||||
|
Kind kind
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var count = entities.Count;
|
||||||
|
if (count < MinimumEntities || !double.IsFinite(reach) || reach < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var lo = new double[count];
|
||||||
|
var hi = new double[count];
|
||||||
|
var indexed = new bool[count];
|
||||||
|
var members = 0;
|
||||||
|
var min = double.PositiveInfinity;
|
||||||
|
var max = double.NegativeInfinity;
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
if (!TryExtent(axis, entities[i], dx, dy, kind, out var a, out var b))
|
||||||
|
continue;
|
||||||
|
a -= reach;
|
||||||
|
b += reach;
|
||||||
|
if (!double.IsFinite(a) || !double.IsFinite(b))
|
||||||
|
return null;
|
||||||
|
lo[i] = a;
|
||||||
|
hi[i] = b;
|
||||||
|
indexed[i] = true;
|
||||||
|
members++;
|
||||||
|
if (a < min)
|
||||||
|
min = a;
|
||||||
|
if (b > max)
|
||||||
|
max = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (members == 0)
|
||||||
|
return new SlideBandIndex(0, 0, 1, new int[2], System.Array.Empty<int>());
|
||||||
|
|
||||||
|
var buckets = System.Math.Min(MaximumBuckets, members);
|
||||||
|
var span = max - min;
|
||||||
|
var scale = span > 0 ? buckets / span : 0;
|
||||||
|
if (!double.IsFinite(scale) || scale <= 0)
|
||||||
|
{
|
||||||
|
scale = 0;
|
||||||
|
buckets = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shape = new SlideBandIndex(
|
||||||
|
min,
|
||||||
|
scale,
|
||||||
|
buckets,
|
||||||
|
System.Array.Empty<int>(),
|
||||||
|
System.Array.Empty<int>()
|
||||||
|
);
|
||||||
|
var next = new int[buckets + 1];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
if (!indexed[i])
|
||||||
|
continue;
|
||||||
|
var last = shape.Bucket(hi[i]);
|
||||||
|
for (var b = shape.Bucket(lo[i]); b <= last; b++)
|
||||||
|
next[b + 1]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var b = 0; b < buckets; b++)
|
||||||
|
next[b + 1] += next[b];
|
||||||
|
var starts = (int[])next.Clone();
|
||||||
|
var items = new int[next[buckets]];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
if (!indexed[i])
|
||||||
|
continue;
|
||||||
|
var last = shape.Bucket(hi[i]);
|
||||||
|
for (var b = shape.Bucket(lo[i]); b <= last; b++)
|
||||||
|
items[next[b]++] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SlideBandIndex(min, scale, buckets, starts, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entities whose extent may contain <paramref name="value"/>, as the item positions
|
||||||
|
/// [start, end), ascending. Empty when the value lies below every extent; false for a
|
||||||
|
/// nonfinite value (scan everything).
|
||||||
|
/// </summary>
|
||||||
|
public bool TryPoint(double value, out int start, out int end)
|
||||||
|
{
|
||||||
|
start = end = 0;
|
||||||
|
if (!double.IsFinite(value))
|
||||||
|
return false;
|
||||||
|
if (value < min)
|
||||||
|
return true;
|
||||||
|
var b = Bucket(value);
|
||||||
|
start = starts[b];
|
||||||
|
end = starts[b + 1];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entities whose extent may overlap [<paramref name="from"/>, <paramref name="to"/>],
|
||||||
|
/// ascending and without repeats, written to <paramref name="result"/>. False for a
|
||||||
|
/// nonfinite interval (scan everything).
|
||||||
|
/// </summary>
|
||||||
|
public bool TryRange(double from, double to, List<int> result)
|
||||||
|
{
|
||||||
|
result.Clear();
|
||||||
|
if (!double.IsFinite(from) || !double.IsFinite(to))
|
||||||
|
return false;
|
||||||
|
if (to < min)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var first = Bucket(from);
|
||||||
|
var last = Bucket(to);
|
||||||
|
if (first == last)
|
||||||
|
{
|
||||||
|
for (var k = starts[first]; k < starts[first + 1]; k++)
|
||||||
|
result.Add(items[k]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lists = last - first + 1;
|
||||||
|
if (lists > MaximumMergedBuckets)
|
||||||
|
{
|
||||||
|
for (var k = starts[first]; k < starts[last + 1]; k++)
|
||||||
|
result.Add(items[k]);
|
||||||
|
result.Sort();
|
||||||
|
Deduplicate(result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each bucket list is ascending: merge them by repeated minimum, dropping repeats.
|
||||||
|
System.Span<int> heads = stackalloc int[MaximumMergedBuckets];
|
||||||
|
for (var b = 0; b < lists; b++)
|
||||||
|
heads[b] = starts[first + b];
|
||||||
|
var previous = -1;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var best = int.MaxValue;
|
||||||
|
var source = -1;
|
||||||
|
for (var b = 0; b < lists; b++)
|
||||||
|
{
|
||||||
|
var head = heads[b];
|
||||||
|
if (head < starts[first + b + 1] && items[head] < best)
|
||||||
|
{
|
||||||
|
best = items[head];
|
||||||
|
source = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (source < 0)
|
||||||
|
return true;
|
||||||
|
heads[source]++;
|
||||||
|
if (best != previous)
|
||||||
|
result.Add(best);
|
||||||
|
previous = best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Item(int position) => items[position];
|
||||||
|
|
||||||
|
private static void Deduplicate(List<int> sorted)
|
||||||
|
{
|
||||||
|
var write = 0;
|
||||||
|
for (var k = 0; k < sorted.Count; k++)
|
||||||
|
{
|
||||||
|
if (write > 0 && sorted[write - 1] == sorted[k])
|
||||||
|
continue;
|
||||||
|
sorted[write++] = sorted[k];
|
||||||
|
}
|
||||||
|
sorted.RemoveRange(write, sorted.Count - write);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Monotone in <paramref name="value"/>, clamped to the bucket range.</summary>
|
||||||
|
private int Bucket(double value)
|
||||||
|
{
|
||||||
|
if (scale == 0)
|
||||||
|
return 0;
|
||||||
|
var b = (value - min) * scale;
|
||||||
|
if (!(b >= 0))
|
||||||
|
return 0;
|
||||||
|
if (b >= bucketCount)
|
||||||
|
return bucketCount - 1;
|
||||||
|
return (int)b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The exact acceptance bound of the entity's <see cref="SlideBand"/> tests before
|
||||||
|
/// widening: a line's span with its RayLine slack, or a curve's circle ±|r| (its slack
|
||||||
|
/// is in the reach). False for entity types no slide kernel emits for, or not of
|
||||||
|
/// <paramref name="kind"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryExtent(
|
||||||
|
SlideBand.Axis axis,
|
||||||
|
Entity entity,
|
||||||
|
double dx,
|
||||||
|
double dy,
|
||||||
|
Kind kind,
|
||||||
|
out double lo,
|
||||||
|
out double hi
|
||||||
|
)
|
||||||
|
{
|
||||||
|
switch (entity)
|
||||||
|
{
|
||||||
|
case Line line when kind != Kind.Curves:
|
||||||
|
{
|
||||||
|
var a = SlideBand.Perp(axis, line.pt1.X + dx, line.pt1.Y + dy);
|
||||||
|
var b = SlideBand.Perp(axis, line.pt2.X + dx, line.pt2.Y + dy);
|
||||||
|
var low = System.Math.Min(a, b);
|
||||||
|
var high = System.Math.Max(a, b);
|
||||||
|
var margin = SlideBand.SpanSlack * (high - low);
|
||||||
|
lo = low - margin;
|
||||||
|
hi = high + margin;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case Arc arc when kind != Kind.Lines:
|
||||||
|
return CurveExtent(
|
||||||
|
axis,
|
||||||
|
arc.Center.X + dx,
|
||||||
|
arc.Center.Y + dy,
|
||||||
|
arc.Radius,
|
||||||
|
out lo,
|
||||||
|
out hi
|
||||||
|
);
|
||||||
|
case Circle circle when kind != Kind.Lines:
|
||||||
|
return CurveExtent(
|
||||||
|
axis,
|
||||||
|
circle.Center.X + dx,
|
||||||
|
circle.Center.Y + dy,
|
||||||
|
circle.Radius,
|
||||||
|
out lo,
|
||||||
|
out hi
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
lo = hi = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CurveExtent(
|
||||||
|
SlideBand.Axis axis,
|
||||||
|
double cx,
|
||||||
|
double cy,
|
||||||
|
double r,
|
||||||
|
out double lo,
|
||||||
|
out double hi
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var c = SlideBand.Perp(axis, cx, cy);
|
||||||
|
var radius = CurveReach(r);
|
||||||
|
lo = c - radius;
|
||||||
|
hi = c + radius;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises the largest |along| and |perpendicular| coordinate (perpendicular plus radius
|
||||||
|
/// for curves) of translated vertices, line ends and curve centres; used to size
|
||||||
|
/// <see cref="Reach"/>. A NaN propagates. Returns the number of curves seen.
|
||||||
|
/// </summary>
|
||||||
|
public static int Measure(
|
||||||
|
SlideBand.Axis axis,
|
||||||
|
List<Entity> entities,
|
||||||
|
Vector[] vertices,
|
||||||
|
double dx,
|
||||||
|
double dy,
|
||||||
|
ref double maxAlong,
|
||||||
|
ref double maxPerpendicular
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var curves = 0;
|
||||||
|
for (var i = 0; i < vertices.Length; i++)
|
||||||
|
{
|
||||||
|
var v = vertices[i];
|
||||||
|
Accumulate(axis, v.X + dx, v.Y + dy, 0, ref maxAlong, ref maxPerpendicular);
|
||||||
|
}
|
||||||
|
for (var i = 0; i < entities.Count; i++)
|
||||||
|
{
|
||||||
|
switch (entities[i])
|
||||||
|
{
|
||||||
|
case Line line:
|
||||||
|
Accumulate(
|
||||||
|
axis,
|
||||||
|
line.pt1.X + dx,
|
||||||
|
line.pt1.Y + dy,
|
||||||
|
0,
|
||||||
|
ref maxAlong,
|
||||||
|
ref maxPerpendicular
|
||||||
|
);
|
||||||
|
Accumulate(
|
||||||
|
axis,
|
||||||
|
line.pt2.X + dx,
|
||||||
|
line.pt2.Y + dy,
|
||||||
|
0,
|
||||||
|
ref maxAlong,
|
||||||
|
ref maxPerpendicular
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case Arc arc:
|
||||||
|
curves++;
|
||||||
|
Accumulate(
|
||||||
|
axis,
|
||||||
|
arc.Center.X + dx,
|
||||||
|
arc.Center.Y + dy,
|
||||||
|
arc.Radius,
|
||||||
|
ref maxAlong,
|
||||||
|
ref maxPerpendicular
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case Circle circle:
|
||||||
|
curves++;
|
||||||
|
Accumulate(
|
||||||
|
axis,
|
||||||
|
circle.Center.X + dx,
|
||||||
|
circle.Center.Y + dy,
|
||||||
|
circle.Radius,
|
||||||
|
ref maxAlong,
|
||||||
|
ref maxPerpendicular
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return curves;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Accumulate(
|
||||||
|
SlideBand.Axis axis,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double r,
|
||||||
|
ref double maxAlong,
|
||||||
|
ref double maxPerpendicular
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var along = System.Math.Abs(SlideBand.Para(axis, x, y));
|
||||||
|
var perpendicular = System.Math.Abs(SlideBand.Perp(axis, x, y)) + System.Math.Abs(r);
|
||||||
|
// Written so a NaN propagates instead of being ignored by Math.Max.
|
||||||
|
maxAlong = along > maxAlong || double.IsNaN(along) ? along : maxAlong;
|
||||||
|
maxPerpendicular = perpendicular > maxPerpendicular || double.IsNaN(perpendicular)
|
||||||
|
? perpendicular
|
||||||
|
: maxPerpendicular;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The band indexes one <see cref="EntitySlideEvents"/> enumeration uses, built only for
|
||||||
|
/// the phases that have work: both sides for vertex rays, the line-only side an arc of the
|
||||||
|
/// other side queries, and the stationary curves the moving curves query.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SlideBandIndexes
|
||||||
|
{
|
||||||
|
public SlideBandIndex? Stationary { get; private init; }
|
||||||
|
|
||||||
|
public SlideBandIndex? Moving { get; private init; }
|
||||||
|
|
||||||
|
public SlideBandIndex? StationaryLines { get; private init; }
|
||||||
|
|
||||||
|
public SlideBandIndex? MovingLines { get; private init; }
|
||||||
|
|
||||||
|
public SlideBandIndex? StationaryCurves { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>Scratch list for range queries.</summary>
|
||||||
|
public List<int> Candidates { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Null when neither side is large enough to index or a coordinate is unusable.
|
||||||
|
/// </summary>
|
||||||
|
public static SlideBandIndexes? Build(
|
||||||
|
SlideBand.Axis axis,
|
||||||
|
List<Entity> moving,
|
||||||
|
Vector[] movingVertices,
|
||||||
|
double movingDx,
|
||||||
|
double movingDy,
|
||||||
|
List<Entity> stationary,
|
||||||
|
Vector[] stationaryVertices,
|
||||||
|
bool arcToLine
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
stationary.Count < SlideBandIndex.MinimumEntities
|
||||||
|
&& moving.Count < SlideBandIndex.MinimumEntities
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
double along = 0,
|
||||||
|
perpendicular = 0;
|
||||||
|
var stationaryCurves = SlideBandIndex.Measure(
|
||||||
|
axis,
|
||||||
|
stationary,
|
||||||
|
stationaryVertices,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
ref along,
|
||||||
|
ref perpendicular
|
||||||
|
);
|
||||||
|
var movingCurves = SlideBandIndex.Measure(
|
||||||
|
axis,
|
||||||
|
moving,
|
||||||
|
movingVertices,
|
||||||
|
movingDx,
|
||||||
|
movingDy,
|
||||||
|
ref along,
|
||||||
|
ref perpendicular
|
||||||
|
);
|
||||||
|
var reach = SlideBandIndex.Reach(along, perpendicular);
|
||||||
|
if (!double.IsFinite(reach))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var all = SlideBandIndex.Kind.All;
|
||||||
|
var lines = SlideBandIndex.Kind.Lines;
|
||||||
|
var curves = SlideBandIndex.Kind.Curves;
|
||||||
|
return new SlideBandIndexes
|
||||||
|
{
|
||||||
|
Stationary = SlideBandIndex.Build(axis, stationary, 0, 0, reach, all),
|
||||||
|
Moving = SlideBandIndex.Build(axis, moving, movingDx, movingDy, reach, all),
|
||||||
|
StationaryLines = arcToLine && movingCurves > 0
|
||||||
|
? SlideBandIndex.Build(axis, stationary, 0, 0, reach, lines)
|
||||||
|
: null,
|
||||||
|
MovingLines = arcToLine && stationaryCurves > 0
|
||||||
|
? SlideBandIndex.Build(axis, moving, movingDx, movingDy, reach, lines)
|
||||||
|
: null,
|
||||||
|
StationaryCurves = movingCurves > 0 && stationaryCurves > 0
|
||||||
|
? SlideBandIndex.Build(axis, stationary, 0, 0, reach, curves)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,10 +37,15 @@ namespace OpenNest.Geometry
|
|||||||
internal const double IncidenceTolerance = 1e-7;
|
internal const double IncidenceTolerance = 1e-7;
|
||||||
|
|
||||||
// Drawing data fixes arc centers to finite precision, so a tangent joint's arc end
|
// Drawing data fixes arc centers to finite precision, so a tangent joint's arc end
|
||||||
// can sit a little past its neighbour's end. A contact this close to a joint takes
|
// can sit a little past its neighbor's end. A contact this close to a joint takes
|
||||||
// the joint's tangents; otherwise the neighbour reads as a second boundary there.
|
// the joint's tangents; otherwise the neighbor reads as a second boundary there.
|
||||||
internal const double JointTolerance = 1e-6;
|
internal const double JointTolerance = 1e-6;
|
||||||
|
|
||||||
|
// Loops are recovered with ShapeBuilder's chaining tolerance: a source contour that
|
||||||
|
// closes within it (PEP rounding leaves 1e-6 gaps) is a loop everywhere else, and an
|
||||||
|
// unrecovered loop leaves every contact with the whole part unresolved and blocking.
|
||||||
|
internal const double ChainTolerance = Tolerance.ChainTolerance;
|
||||||
|
|
||||||
private readonly List<Entity> entities;
|
private readonly List<Entity> entities;
|
||||||
private readonly int[] loopOf;
|
private readonly int[] loopOf;
|
||||||
private readonly int[] previous;
|
private readonly int[] previous;
|
||||||
@@ -83,11 +88,11 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (!TryEndpoints(entities[i], out var start, out _))
|
if (!EntityEndpoints.TryGet(entities[i], out var start, out _))
|
||||||
return Unresolved(entities);
|
return Unresolved(entities);
|
||||||
|
|
||||||
var closed = false;
|
var closed = false;
|
||||||
while (i < count && TryEndpoints(entities[i], out _, out var end))
|
while (i < count && EntityEndpoints.TryGet(entities[i], out _, out var end))
|
||||||
{
|
{
|
||||||
// A lone closed arc is a loop; a lone line cannot be, even when it
|
// A lone closed arc is a loop; a lone line cannot be, even when it
|
||||||
// has zero length and so ends where it starts.
|
// has zero length and so ends where it starts.
|
||||||
@@ -100,7 +105,7 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
i + 1 >= count
|
i + 1 >= count
|
||||||
|| !TryEndpoints(entities[i + 1], out var nextStart, out _)
|
|| !EntityEndpoints.TryGet(entities[i + 1], out var nextStart, out _)
|
||||||
|| !Near(nextStart, end)
|
|| !Near(nextStart, end)
|
||||||
)
|
)
|
||||||
break;
|
break;
|
||||||
@@ -258,11 +263,11 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
|
|
||||||
private bool StartsNear(int index, Vector point) =>
|
private bool StartsNear(int index, Vector point) =>
|
||||||
TryEndpoints(entities[index], out var start, out _)
|
EntityEndpoints.TryGet(entities[index], out var start, out _)
|
||||||
&& start.DistanceTo(point) <= JointTolerance;
|
&& start.DistanceTo(point) <= JointTolerance;
|
||||||
|
|
||||||
private bool EndsNear(int index, Vector point) =>
|
private bool EndsNear(int index, Vector point) =>
|
||||||
TryEndpoints(entities[index], out _, out var end)
|
EntityEndpoints.TryGet(entities[index], out _, out var end)
|
||||||
&& end.DistanceTo(point) <= JointTolerance;
|
&& end.DistanceTo(point) <= JointTolerance;
|
||||||
|
|
||||||
private bool InRun(int index, int first, int last)
|
private bool InRun(int index, int first, int last)
|
||||||
@@ -355,25 +360,7 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryEndpoints(Entity entity, out Vector start, out Vector end)
|
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= ChainTolerance;
|
||||||
{
|
|
||||||
switch (entity)
|
|
||||||
{
|
|
||||||
case Line line:
|
|
||||||
start = line.pt1;
|
|
||||||
end = line.pt2;
|
|
||||||
return true;
|
|
||||||
case Arc arc:
|
|
||||||
start = arc.StartPoint();
|
|
||||||
end = arc.EndPoint();
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
start = end = new Vector();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance;
|
|
||||||
|
|
||||||
private static double SignedArea(List<Entity> entities, int first, int last)
|
private static double SignedArea(List<Entity> entities, int first, int last)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -228,7 +228,10 @@ namespace OpenNest.Geometry
|
|||||||
double lineDy,
|
double lineDy,
|
||||||
double rayX,
|
double rayX,
|
||||||
double rayY,
|
double rayY,
|
||||||
bool arcMoves
|
bool arcMoves,
|
||||||
|
SlideBand.Axis band,
|
||||||
|
SlideBandIndex lineIndex,
|
||||||
|
List<int> candidates
|
||||||
)
|
)
|
||||||
where TSink : struct, ISlideEventSink
|
where TSink : struct, ISlideEventSink
|
||||||
{
|
{
|
||||||
@@ -241,8 +244,19 @@ namespace OpenNest.Geometry
|
|||||||
var cx = localCx + arcDx;
|
var cx = localCx + arcDx;
|
||||||
var cy = localCy + arcDy;
|
var cy = localCy + arcDy;
|
||||||
|
|
||||||
for (var j = 0; j < lineEntities.Count; j++)
|
// With an index, visit only the lines CircleMayReachLine can accept, ascending.
|
||||||
|
var indexed = false;
|
||||||
|
if (lineIndex != null)
|
||||||
{
|
{
|
||||||
|
var c = SlideBand.Perp(band, cx, cy);
|
||||||
|
var radius = System.Math.Abs(r);
|
||||||
|
indexed = lineIndex.TryRange(c - radius, c + radius, candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = indexed ? candidates.Count : lineEntities.Count;
|
||||||
|
for (var n = 0; n < count; n++)
|
||||||
|
{
|
||||||
|
var j = indexed ? candidates[n] : n;
|
||||||
if (lineEntities[j] is not Line line)
|
if (lineEntities[j] is not Line line)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -250,6 +264,13 @@ namespace OpenNest.Geometry
|
|||||||
var p1y = line.pt1.Y + lineDy;
|
var p1y = line.pt1.Y + lineDy;
|
||||||
var p2x = line.pt2.X + lineDx;
|
var p2x = line.pt2.X + lineDx;
|
||||||
var p2y = line.pt2.Y + lineDy;
|
var p2y = line.pt2.Y + lineDy;
|
||||||
|
|
||||||
|
if (
|
||||||
|
band != SlideBand.Axis.None
|
||||||
|
&& !SlideBand.CircleMayReachLine(band, cx, cy, r, p1x, p1y, p2x, p2y)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
|
||||||
var ex = p2x - p1x;
|
var ex = p2x - p1x;
|
||||||
var ey = p2y - p1y;
|
var ey = p2y - p1y;
|
||||||
|
|
||||||
@@ -476,14 +497,59 @@ namespace OpenNest.Geometry
|
|||||||
public void Enumerate<TSink>(ref TSink sink)
|
public void Enumerate<TSink>(ref TSink sink)
|
||||||
where TSink : struct, ISlideEventSink
|
where TSink : struct, ISlideEventSink
|
||||||
{
|
{
|
||||||
|
// An axis-aligned push only meets entities whose perpendicular extent can hold the
|
||||||
|
// ray. The band tests skip only pairs whose kernels cannot emit and keep the
|
||||||
|
// surviving pairs in their original order, so the emitted events are unchanged.
|
||||||
|
// For larger boundaries, bucket indexes over those extents limit each query to a
|
||||||
|
// superset of the accepted entities, still visited in ascending order.
|
||||||
|
var band = SlideBand.For(dirX, dirY);
|
||||||
|
var indexes = band == SlideBand.Axis.None
|
||||||
|
? null
|
||||||
|
: SlideBandIndexes.Build(
|
||||||
|
band,
|
||||||
|
moving,
|
||||||
|
movingVertices,
|
||||||
|
movingDx,
|
||||||
|
movingDy,
|
||||||
|
stationary,
|
||||||
|
stationaryVertices,
|
||||||
|
arcToLine
|
||||||
|
);
|
||||||
|
|
||||||
// Phase 1: moving vertices along the push against stationary entities.
|
// Phase 1: moving vertices along the push against stationary entities.
|
||||||
for (var v = 0; v < movingVertices.Length; v++)
|
for (var v = 0; v < movingVertices.Length; v++)
|
||||||
{
|
{
|
||||||
var vx = movingVertices[v].X + movingDx;
|
var vx = movingVertices[v].X + movingDx;
|
||||||
var vy = movingVertices[v].Y + movingDy;
|
var vy = movingVertices[v].Y + movingDy;
|
||||||
|
|
||||||
|
if (
|
||||||
|
indexes?.Stationary != null
|
||||||
|
&& indexes.Stationary.TryPoint(
|
||||||
|
SlideBand.Perp(band, vx, vy),
|
||||||
|
out var first,
|
||||||
|
out var last
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
for (var k = first; k < last; k++)
|
||||||
|
{
|
||||||
|
var j = indexes.Stationary.Item(k);
|
||||||
|
if (!SlideBand.RayMayHit(band, vx, vy, stationary[j], 0, 0))
|
||||||
|
continue;
|
||||||
|
SlideEvents.Ray(ref sink, vx, vy, stationary[j], 0, 0, dirX, dirY, true);
|
||||||
|
if (sink.IsDone)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (var j = 0; j < stationary.Count; j++)
|
for (var j = 0; j < stationary.Count; j++)
|
||||||
{
|
{
|
||||||
|
if (
|
||||||
|
band != SlideBand.Axis.None
|
||||||
|
&& !SlideBand.RayMayHit(band, vx, vy, stationary[j], 0, 0)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
SlideEvents.Ray(ref sink, vx, vy, stationary[j], 0, 0, dirX, dirY, true);
|
SlideEvents.Ray(ref sink, vx, vy, stationary[j], 0, 0, dirX, dirY, true);
|
||||||
if (sink.IsDone)
|
if (sink.IsDone)
|
||||||
return;
|
return;
|
||||||
@@ -496,8 +562,44 @@ namespace OpenNest.Geometry
|
|||||||
var vx = stationaryVertices[v].X;
|
var vx = stationaryVertices[v].X;
|
||||||
var vy = stationaryVertices[v].Y;
|
var vy = stationaryVertices[v].Y;
|
||||||
|
|
||||||
|
if (
|
||||||
|
indexes?.Moving != null
|
||||||
|
&& indexes.Moving.TryPoint(
|
||||||
|
SlideBand.Perp(band, vx, vy),
|
||||||
|
out var first,
|
||||||
|
out var last
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
for (var k = first; k < last; k++)
|
||||||
|
{
|
||||||
|
var j = indexes.Moving.Item(k);
|
||||||
|
if (!SlideBand.RayMayHit(band, vx, vy, moving[j], movingDx, movingDy))
|
||||||
|
continue;
|
||||||
|
SlideEvents.Ray(
|
||||||
|
ref sink,
|
||||||
|
vx,
|
||||||
|
vy,
|
||||||
|
moving[j],
|
||||||
|
movingDx,
|
||||||
|
movingDy,
|
||||||
|
-dirX,
|
||||||
|
-dirY,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
if (sink.IsDone)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (var j = 0; j < moving.Count; j++)
|
for (var j = 0; j < moving.Count; j++)
|
||||||
{
|
{
|
||||||
|
if (
|
||||||
|
band != SlideBand.Axis.None
|
||||||
|
&& !SlideBand.RayMayHit(band, vx, vy, moving[j], movingDx, movingDy)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
SlideEvents.Ray(
|
SlideEvents.Ray(
|
||||||
ref sink,
|
ref sink,
|
||||||
vx,
|
vx,
|
||||||
@@ -527,7 +629,10 @@ namespace OpenNest.Geometry
|
|||||||
0,
|
0,
|
||||||
dirX,
|
dirX,
|
||||||
dirY,
|
dirY,
|
||||||
true
|
true,
|
||||||
|
band,
|
||||||
|
indexes?.StationaryLines,
|
||||||
|
indexes?.Candidates
|
||||||
);
|
);
|
||||||
if (sink.IsDone)
|
if (sink.IsDone)
|
||||||
return;
|
return;
|
||||||
@@ -541,7 +646,10 @@ namespace OpenNest.Geometry
|
|||||||
movingDy,
|
movingDy,
|
||||||
-dirX,
|
-dirX,
|
||||||
-dirY,
|
-dirY,
|
||||||
false
|
false,
|
||||||
|
band,
|
||||||
|
indexes?.MovingLines,
|
||||||
|
indexes?.Candidates
|
||||||
);
|
);
|
||||||
if (sink.IsDone)
|
if (sink.IsDone)
|
||||||
return;
|
return;
|
||||||
@@ -553,11 +661,39 @@ namespace OpenNest.Geometry
|
|||||||
if (!SlideEvents.TryGetCurve(moving[i], out var mcx, out var mcy, out var mr))
|
if (!SlideEvents.TryGetCurve(moving[i], out var mcx, out var mcy, out var mr))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
for (var j = 0; j < stationary.Count; j++)
|
var indexed = false;
|
||||||
|
if (indexes?.StationaryCurves != null)
|
||||||
{
|
{
|
||||||
|
var c = SlideBand.Perp(band, mcx + movingDx, mcy + movingDy);
|
||||||
|
var reach = SlideBandIndex.CurveReach(mr);
|
||||||
|
indexed = indexes.StationaryCurves.TryRange(
|
||||||
|
c - reach,
|
||||||
|
c + reach,
|
||||||
|
indexes.Candidates
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = indexed ? indexes.Candidates.Count : stationary.Count;
|
||||||
|
for (var n = 0; n < count; n++)
|
||||||
|
{
|
||||||
|
var j = indexed ? indexes.Candidates[n] : n;
|
||||||
if (!SlideEvents.TryGetCurve(stationary[j], out var scx, out var scy, out var sr))
|
if (!SlideEvents.TryGetCurve(stationary[j], out var scx, out var scy, out var sr))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
if (
|
||||||
|
band != SlideBand.Axis.None
|
||||||
|
&& !SlideBand.CirclesMayTouch(
|
||||||
|
band,
|
||||||
|
mcx + movingDx,
|
||||||
|
mcy + movingDy,
|
||||||
|
mr,
|
||||||
|
scx,
|
||||||
|
scy,
|
||||||
|
sr
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
|
||||||
SlideEvents.CurveTangency(
|
SlideEvents.CurveTangency(
|
||||||
ref sink,
|
ref sink,
|
||||||
mcx + movingDx,
|
mcx + movingDx,
|
||||||
|
|||||||
@@ -85,6 +85,24 @@ namespace OpenNest.Geometry
|
|||||||
return new SpacingOutline(perimeter, region.Holes);
|
return new SpacingOutline(perimeter, region.Holes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>This outline, built at the origin, moved to <paramref name="location"/>: what
|
||||||
|
/// <see cref="Build"/> returns there, without repeating the offset for identical parts.</summary>
|
||||||
|
public SpacingOutline MovedTo(Vector location)
|
||||||
|
{
|
||||||
|
var perimeter = (Polygon)Perimeter.Clone();
|
||||||
|
ToWorld(perimeter, location);
|
||||||
|
var holes = new List<Polygon>(Holes.Count);
|
||||||
|
|
||||||
|
foreach (var hole in Holes)
|
||||||
|
{
|
||||||
|
var moved = (Polygon)hole.Clone();
|
||||||
|
ToWorld(moved, location);
|
||||||
|
holes.Add(moved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SpacingOutline(perimeter, holes);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>True when the two outlines share material (holes subtracted).</summary>
|
/// <summary>True when the two outlines share material (holes subtracted).</summary>
|
||||||
public bool Overlaps(SpacingOutline other)
|
public bool Overlaps(SpacingOutline other)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -791,7 +791,11 @@ namespace OpenNest.Geometry
|
|||||||
return vertices.ToArray();
|
return vertices.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddArcExtremeVertices(HashSet<Vector> points, Arc arc)
|
/// <summary>
|
||||||
|
/// Adds the cardinal extreme points of an arc (0°, 90°, 180°, 270°)
|
||||||
|
/// if they fall within the arc's angular span.
|
||||||
|
/// </summary>
|
||||||
|
internal static void AddArcExtremeVertices(ICollection<Vector> points, Arc arc)
|
||||||
{
|
{
|
||||||
var a1 = arc.StartAngle;
|
var a1 = arc.StartAngle;
|
||||||
var a2 = arc.EndAngle;
|
var a2 = arc.EndAngle;
|
||||||
|
|||||||
@@ -9,5 +9,15 @@ namespace OpenNest;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IPostVerificationSupport
|
public interface IPostVerificationSupport
|
||||||
{
|
{
|
||||||
|
/// <summary>True when the post preserves the placed order for every nest.</summary>
|
||||||
bool PreservesPlacedProgramOrder { get; }
|
bool PreservesPlacedProgramOrder { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Why posting <paramref name="nest"/> with the current settings would not move in the
|
||||||
|
/// placed order, or null when it would. A post whose order depends on the nest or its
|
||||||
|
/// settings overrides this; it must return null only when the posted motion sequence
|
||||||
|
/// provably equals the placed one.
|
||||||
|
/// </summary>
|
||||||
|
string PlacedProgramOrderChange(Nest nest) =>
|
||||||
|
PreservesPlacedProgramOrder ? null : "does not declare that it preserves placed part/contour order and pierce positions";
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nesting priority places higher numbers first. Files, the drawing library and server job
|
||||||
|
/// documents written before that stored a lower-first value under <c>priority</c>; they keep
|
||||||
|
/// writing it beside the new value so older readers see the same order. Converting negates,
|
||||||
|
/// which keeps the order and is its own inverse.
|
||||||
|
/// </summary>
|
||||||
|
public static class LegacyPriority
|
||||||
|
{
|
||||||
|
/// <summary>Converts between a higher-first priority and its lower-first legacy value, either way.</summary>
|
||||||
|
public static int Convert(int priority) => priority == int.MinValue ? int.MaxValue : -priority;
|
||||||
|
}
|
||||||
@@ -7,12 +7,21 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
public static class PartGeometry
|
public static class PartGeometry
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Nesting material of a program, without lead-ins and lead-outs: a lead is a cut
|
||||||
|
/// into scrap, not part material, and chaining it into the perimeter leaves an open
|
||||||
|
/// spur, so the offset contour no longer closes and every slide contact against it
|
||||||
|
/// blocks.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Entity> MaterialEntities(CNC.Program program) =>
|
||||||
|
ConvertProgram.ToGeometry(program)
|
||||||
|
.Where(e => e.Layer != SpecialLayers.Leadin && e.Layer != SpecialLayers.Leadout)
|
||||||
|
.ForNesting();
|
||||||
|
|
||||||
public static List<Line> GetPartLines(Part part, double chordTolerance = 0.001)
|
public static List<Line> GetPartLines(Part part, double chordTolerance = 0.001)
|
||||||
{
|
{
|
||||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
var entities = MaterialEntities(part.Program);
|
||||||
var shapes = ShapeBuilder.GetShapes(
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
entities.ForNesting()
|
|
||||||
);
|
|
||||||
var lines = new List<Line>();
|
var lines = new List<Line>();
|
||||||
|
|
||||||
foreach (var shape in shapes)
|
foreach (var shape in shapes)
|
||||||
@@ -55,10 +64,8 @@ namespace OpenNest
|
|||||||
public static List<Entity> GetOffsetPerimeterEntities(CNC.Program program, double spacing)
|
public static List<Entity> GetOffsetPerimeterEntities(CNC.Program program, double spacing)
|
||||||
{
|
{
|
||||||
PerfCounters.CountOffsetPerimeterEntities();
|
PerfCounters.CountOffsetPerimeterEntities();
|
||||||
var geoEntities = ConvertProgram.ToGeometry(program);
|
var geoEntities = MaterialEntities(program);
|
||||||
var profile = new ShapeProfile(
|
var profile = new ShapeProfile(geoEntities);
|
||||||
geoEntities.ForNesting().ToList()
|
|
||||||
);
|
|
||||||
|
|
||||||
var offsetShape = profile.Perimeter.OffsetOutward(spacing);
|
var offsetShape = profile.Perimeter.OffsetOutward(spacing);
|
||||||
if (offsetShape == null)
|
if (offsetShape == null)
|
||||||
@@ -73,10 +80,8 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<Entity> GetOffsetPartEntities(Part part, double spacing)
|
public static List<Entity> GetOffsetPartEntities(Part part, double spacing)
|
||||||
{
|
{
|
||||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
var geoEntities = MaterialEntities(part.Program);
|
||||||
var profile = new ShapeProfile(
|
var profile = new ShapeProfile(geoEntities);
|
||||||
geoEntities.ForNesting().ToList()
|
|
||||||
);
|
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
|
|
||||||
var perimeter = profile.Perimeter.OffsetOutward(spacing);
|
var perimeter = profile.Perimeter.OffsetOutward(spacing);
|
||||||
@@ -106,10 +111,8 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<Entity> GetPerimeterEntities(Part part)
|
public static List<Entity> GetPerimeterEntities(Part part)
|
||||||
{
|
{
|
||||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
var geoEntities = MaterialEntities(part.Program);
|
||||||
var profile = new ShapeProfile(
|
var profile = new ShapeProfile(geoEntities);
|
||||||
geoEntities.ForNesting().ToList()
|
|
||||||
);
|
|
||||||
|
|
||||||
return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
|
return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
|
||||||
}
|
}
|
||||||
@@ -120,10 +123,8 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<Entity> GetPartEntities(Part part)
|
public static List<Entity> GetPartEntities(Part part)
|
||||||
{
|
{
|
||||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
var geoEntities = MaterialEntities(part.Program);
|
||||||
var profile = new ShapeProfile(
|
var profile = new ShapeProfile(geoEntities);
|
||||||
geoEntities.ForNesting().ToList()
|
|
||||||
);
|
|
||||||
var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
|
var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
|
||||||
|
|
||||||
foreach (var cutout in profile.Cutouts)
|
foreach (var cutout in profile.Cutouts)
|
||||||
@@ -165,10 +166,8 @@ namespace OpenNest
|
|||||||
Vector vectorDirection
|
Vector vectorDirection
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
var entities = MaterialEntities(part.Program);
|
||||||
var shapes = ShapeBuilder.GetShapes(
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
entities.ForNesting()
|
|
||||||
);
|
|
||||||
var lines = new List<Line>();
|
var lines = new List<Line>();
|
||||||
|
|
||||||
foreach (var shape in shapes)
|
foreach (var shape in shapes)
|
||||||
|
|||||||
+136
-32
@@ -98,6 +98,9 @@ namespace OpenNest
|
|||||||
// A turned sheet is the same sheet; any other size is no longer the coded item.
|
// A turned sheet is the same sheet; any other size is no longer the coded item.
|
||||||
if (stockItemCodes.Count > 0 && !SameSheet(size, value))
|
if (stockItemCodes.Count > 0 && !SameSheet(size, value))
|
||||||
stockItemCodes = Array.Empty<string>();
|
stockItemCodes = Array.Empty<string>();
|
||||||
|
// An outline is a particular sheet; any other size is a rectangle again.
|
||||||
|
if (outline != null && (outline.Size.Width != value.Width || outline.Size.Length != value.Length))
|
||||||
|
outline = null;
|
||||||
size = value;
|
size = value;
|
||||||
ResolveClampRail();
|
ResolveClampRail();
|
||||||
}
|
}
|
||||||
@@ -105,6 +108,26 @@ namespace OpenNest
|
|||||||
|
|
||||||
private Size size;
|
private Size size;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The sheet's material when it is not a rectangle (see <see cref="SheetOutline"/>), in
|
||||||
|
/// plate coordinates; null for a rectangular sheet. Setting one makes <see cref="Size"/> its
|
||||||
|
/// bounding box, and setting any other size drops it. Only the Irregular whole-job engine
|
||||||
|
/// nests on an outlined sheet; cut-offs are trimmed to it, automatic cut-offs refuse it, and
|
||||||
|
/// <see cref="Area"/> is its material area.
|
||||||
|
/// </summary>
|
||||||
|
public SheetOutline Outline
|
||||||
|
{
|
||||||
|
get { return outline; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
outline = value;
|
||||||
|
if (value != null)
|
||||||
|
Size = value.Size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SheetOutline outline;
|
||||||
|
|
||||||
private static bool SameSheet(Size a, Size b) =>
|
private static bool SameSheet(Size a, Size b) =>
|
||||||
a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
|
a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
|
||||||
|
|
||||||
@@ -146,6 +169,13 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ObservableList<CutOff> CutOffs { get; set; }
|
public ObservableList<CutOff> CutOffs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy of the settings the cut-offs were last regenerated with, or null before the
|
||||||
|
/// first regeneration. Saved with the nest, so reopening it rebuilds the same cut-off
|
||||||
|
/// geometry rather than geometry from built-in defaults.
|
||||||
|
/// </summary>
|
||||||
|
public CutOffSettings CutOffSettings { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fixed rectangular machine-clamp keep-outs in plate coordinates
|
/// Fixed rectangular machine-clamp keep-outs in plate coordinates
|
||||||
/// (<see cref="ClampZone"/>). Never parts: excluded from quantity, utilization,
|
/// (<see cref="ClampZone"/>). Never parts: excluded from quantity, utilization,
|
||||||
@@ -170,6 +200,14 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ClampRail ClampRail { get; private set; }
|
public ClampRail ClampRail { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The gap part material keeps from every clamp zone: the rail's own
|
||||||
|
/// <see cref="OpenNest.ClampRail.Clearance"/>, else the bottom edge spacing (D6), never
|
||||||
|
/// negative. Part spacing never applies to clamps.
|
||||||
|
/// </summary>
|
||||||
|
public double ClampClearance =>
|
||||||
|
System.Math.Max(0, ClampRail?.Clearance ?? EdgeSpacing.Bottom);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The machine support bed this sheet rests on (its nest's, see
|
/// The machine support bed this sheet rests on (its nest's, see
|
||||||
/// <see cref="Nest.SetSupportBed"/>), or null for none. Display, analysis and support
|
/// <see cref="Nest.SetSupportBed"/>), or null for none. Display, analysis and support
|
||||||
@@ -303,11 +341,13 @@ namespace OpenNest
|
|||||||
public void RegenerateCutOffs(CutOffSettings settings)
|
public void RegenerateCutOffs(CutOffSettings settings)
|
||||||
{
|
{
|
||||||
var saved = new Dictionary<CutOff, List<(int Index, double Start, double End)>>();
|
var saved = new Dictionary<CutOff, List<(int Index, double Start, double End)>>();
|
||||||
|
var places = new List<int>();
|
||||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
if (!Parts[i].BaseDrawing.IsCutOff)
|
if (!Parts[i].BaseDrawing.IsCutOff)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
places.Add(i);
|
||||||
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
|
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
|
||||||
if (cutoff != null)
|
if (cutoff != null)
|
||||||
{
|
{
|
||||||
@@ -343,7 +383,7 @@ namespace OpenNest
|
|||||||
indices[k] = previous = best >= 0 ? best : previous;
|
indices[k] = previous = best >= 0 ? best : previous;
|
||||||
}
|
}
|
||||||
return indices;
|
return indices;
|
||||||
});
|
}, _ => places);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -353,38 +393,79 @@ namespace OpenNest
|
|||||||
/// from it are added at the end. Callers must remove existing cut-off parts first.
|
/// from it are added at the end. Callers must remove existing cut-off parts first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence) =>
|
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence) =>
|
||||||
RegenerateCutOffs(settings, (cutoff, extents) =>
|
RegenerateCutOffs(settings, null, sequence);
|
||||||
sequence != null && sequence.TryGetValue(cutoff, out var index)
|
|
||||||
? Enumerable.Range(index, extents.Count).ToArray() : null);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// As <see cref="RegenerateCutOffs(CutOffSettings, IReadOnlyDictionary{CutOff, int})"/>,
|
/// As <see cref="RegenerateCutOffs(CutOffSettings, IReadOnlyDictionary{CutOff, int})"/>,
|
||||||
/// but each cut-off lists one index per segment in ascending order along its axis. A list
|
/// but each cut-off lists one index per segment in ascending order along its axis. A list
|
||||||
/// whose length differs from the regenerated segment count places the segments
|
/// whose length differs from the regenerated segment count places the segments
|
||||||
/// consecutively from its smallest index.
|
/// consecutively at its smallest index.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> sequence) =>
|
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> sequence) =>
|
||||||
|
RegenerateCutOffs(settings, sequence, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Places each cut-off's regenerated segments where its saved segments were, among the
|
||||||
|
/// parts saved around them: <paramref name="places"/> lists every saved segment's place;
|
||||||
|
/// <paramref name="firstPlaces"/> gives only the first, for files that recorded no more
|
||||||
|
/// (their segments were consecutive, but how many is unknown). Settings other than the
|
||||||
|
/// saved ones can regenerate a different number of segments; the parts keep their
|
||||||
|
/// places relative to the cut-offs either way. Where only a first place is known the
|
||||||
|
/// count is a guess, so a cut-off that splits the sheet goes last, as sequencing puts it.
|
||||||
|
/// </summary>
|
||||||
|
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> places,
|
||||||
|
IReadOnlyDictionary<CutOff, int> firstPlaces) =>
|
||||||
RegenerateCutOffs(settings, (cutoff, extents) =>
|
RegenerateCutOffs(settings, (cutoff, extents) =>
|
||||||
{
|
{
|
||||||
if (sequence == null || !sequence.TryGetValue(cutoff, out var indices) || indices == null || indices.Count == 0)
|
if (places != null && places.TryGetValue(cutoff, out var indices) && indices is { Count: > 0 })
|
||||||
return null;
|
{
|
||||||
if (indices.Count != extents.Count)
|
if (indices.Count != extents.Count)
|
||||||
return Enumerable.Range(indices.Min(), extents.Count).ToArray();
|
return Enumerable.Repeat(indices.Min(), extents.Count).ToArray();
|
||||||
// Program order can run toward the origin; the file lists segments ascending.
|
// Program order can run toward the origin; the file lists segments ascending.
|
||||||
var ascending = Enumerable.Range(0, extents.Count).OrderBy(k => extents[k].Start).ToArray();
|
var ascending = Enumerable.Range(0, extents.Count).OrderBy(k => extents[k].Start).ToArray();
|
||||||
var result = new int[extents.Count];
|
var result = new int[extents.Count];
|
||||||
for (var rank = 0; rank < ascending.Length; rank++)
|
for (var rank = 0; rank < ascending.Length; rank++)
|
||||||
result[ascending[rank]] = indices[rank];
|
result[ascending[rank]] = indices[rank];
|
||||||
return result;
|
return result;
|
||||||
});
|
}
|
||||||
|
return firstPlaces != null && firstPlaces.TryGetValue(cutoff, out var first)
|
||||||
|
? Enumerable.Repeat(first, extents.Count).ToArray() : null;
|
||||||
|
}, counts =>
|
||||||
|
{
|
||||||
|
var saved = new HashSet<int>();
|
||||||
|
foreach (var list in places?.Values ?? [])
|
||||||
|
saved.UnionWith(list ?? []);
|
||||||
|
if (firstPlaces == null)
|
||||||
|
return saved;
|
||||||
|
// A cut-off saved by its first place alone held consecutive places, as many as it
|
||||||
|
// has segments now unless that runs into the next cut-off's first place.
|
||||||
|
var starts = saved.Concat(firstPlaces.Values).Distinct().Order().ToArray();
|
||||||
|
foreach (var (cutoff, first) in firstPlaces)
|
||||||
|
{
|
||||||
|
var next = starts.FirstOrDefault(start => start > first, int.MaxValue);
|
||||||
|
var count = System.Math.Max(1, System.Math.Min(counts.GetValueOrDefault(cutoff, 1), next - first));
|
||||||
|
saved.UnionWith(Enumerable.Range(first, count));
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}, cutoff => firstPlaces != null && firstPlaces.ContainsKey(cutoff));
|
||||||
|
|
||||||
// sequence gives a cut-off's segment indices in program order from its segments' extents
|
// sequence gives a cut-off's saved place for each segment in program order, from its
|
||||||
// along its axis (NaN when unmeasurable), or null to append them.
|
// segments' extents along its axis (NaN when unmeasurable), or null to append them.
|
||||||
|
// savedPlaces gives every place cut-off segments held in the saved order, from each
|
||||||
|
// cut-off's regenerated segment count; the parts held the others, in their order.
|
||||||
|
// separateLast says which cut-offs go after everything when they split the sheet.
|
||||||
private void RegenerateCutOffs(CutOffSettings settings,
|
private void RegenerateCutOffs(CutOffSettings settings,
|
||||||
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence)
|
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence,
|
||||||
|
Func<IReadOnlyDictionary<CutOff, int>, IEnumerable<int>> savedPlaces,
|
||||||
|
Func<CutOff, bool> separateLast = null)
|
||||||
{
|
{
|
||||||
var cache = BuildPerimeterCache(this);
|
var cache = BuildPerimeterCache(this);
|
||||||
|
var sheet = BoundingBox(includeParts: false);
|
||||||
var placed = new List<(int Index, int Order, Part Part)>();
|
var placed = new List<(int Index, int Order, Part Part)>();
|
||||||
|
var separating = new List<Part>();
|
||||||
|
var counts = new Dictionary<CutOff, int>();
|
||||||
|
var partCount = Parts.Count;
|
||||||
|
CutOffSettings = settings?.Copy();
|
||||||
|
|
||||||
// Regenerate and materialize each cut-off's segments
|
// Regenerate and materialize each cut-off's segments
|
||||||
foreach (var cutoff in CutOffs)
|
foreach (var cutoff in CutOffs)
|
||||||
@@ -399,20 +480,34 @@ namespace OpenNest
|
|||||||
var parts = programs.Select(program => new Part(cutoff.Drawing, program)).ToArray();
|
var parts = programs.Select(program => new Part(cutoff.Drawing, program)).ToArray();
|
||||||
var extents = parts.Select(part => CutOffSegments.Extent(part, vertical) ?? (double.NaN, double.NaN)).ToArray();
|
var extents = parts.Select(part => CutOffSegments.Extent(part, vertical) ?? (double.NaN, double.NaN)).ToArray();
|
||||||
var indices = sequence?.Invoke(cutoff, extents);
|
var indices = sequence?.Invoke(cutoff, extents);
|
||||||
|
counts[cutoff] = parts.Length;
|
||||||
for (var k = 0; k < parts.Length; k++)
|
for (var k = 0; k < parts.Length; k++)
|
||||||
{
|
{
|
||||||
if (indices != null && k < indices.Count)
|
// A line splitting the sheet comes after every part and every other cut-off.
|
||||||
|
if (separateLast?.Invoke(cutoff) == true && CutOff.SeparatesSheet(parts[k], cutoff, sheet))
|
||||||
|
separating.Add(parts[k]);
|
||||||
|
else if (indices != null && k < indices.Count)
|
||||||
placed.Add((indices[k], placed.Count, parts[k]));
|
placed.Add((indices[k], placed.Count, parts[k]));
|
||||||
else
|
else
|
||||||
Parts.Add(parts[k]);
|
Parts.Add(parts[k]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lowest index first: each insert then lands on its saved index, because every part
|
// Saved places count every cut-off segment then on the plate, which may now be more or
|
||||||
// sequenced before it is already in place. Segments sharing an index keep their
|
// fewer. A segment goes after the parts saved before it and the segments placed before
|
||||||
// program order (a later insert at the same index lands before an earlier one).
|
// it, so a changed count never shifts a later cut-off or part. Segments sharing a place
|
||||||
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenByDescending(p => p.Order))
|
// keep their program order.
|
||||||
Parts.Insert(System.Math.Clamp(index, 0, Parts.Count), part);
|
var saved = (savedPlaces?.Invoke(counts) ?? []).Distinct().Order().ToArray();
|
||||||
|
var inserted = 0;
|
||||||
|
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenBy(p => p.Order))
|
||||||
|
{
|
||||||
|
var savedBefore = Array.BinarySearch(saved, index);
|
||||||
|
savedBefore = savedBefore < 0 ? ~savedBefore : savedBefore;
|
||||||
|
var partsBefore = System.Math.Clamp(index - savedBefore, 0, partCount);
|
||||||
|
Parts.Insert(partsBefore + inserted++, part);
|
||||||
|
}
|
||||||
|
foreach (var part in separating)
|
||||||
|
Parts.Add(part);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -456,10 +551,13 @@ namespace OpenNest
|
|||||||
|
|
||||||
// Leads are cutting paths in scrap, not boundary edges. Chaining them
|
// Leads are cutting paths in scrap, not boundary edges. Chaining them
|
||||||
// into the outline can make a closed part appear open and replace its
|
// into the outline can make a closed part appear open and replace its
|
||||||
// recesses with a convex hull. Keep the full geometry for the existing
|
// recesses with a convex hull. A cut-off may cross a lead, so the open-
|
||||||
// conservative fallback when the material contour really is open.
|
// contour hull also leaves them out: hulling a lead's pierce point would
|
||||||
|
// stop cut-offs far short of the part.
|
||||||
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
|
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
|
||||||
e.Layer != SpecialLayers.Leadout).ToList();
|
e.Layer != SpecialLayers.Leadout).ToList();
|
||||||
|
if (outline.Count == 0)
|
||||||
|
return null;
|
||||||
var profile = new Geometry.ShapeProfile(outline);
|
var profile = new Geometry.ShapeProfile(outline);
|
||||||
|
|
||||||
if (profile.Perimeter.IsClosed())
|
if (profile.Perimeter.IsClosed())
|
||||||
@@ -469,7 +567,7 @@ namespace OpenNest
|
|||||||
return perimeter;
|
return perimeter;
|
||||||
}
|
}
|
||||||
|
|
||||||
var points = entities.CollectPoints();
|
var points = outline.CollectPoints();
|
||||||
if (points.Count < 3)
|
if (points.Count < 3)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@@ -526,7 +624,9 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
const double oneAndHalfPI = System.Math.PI * 1.5;
|
const double oneAndHalfPI = System.Math.PI * 1.5;
|
||||||
|
|
||||||
|
var turned = Outline?.Rotated90(rotationDirection);
|
||||||
Size = new Size(Size.Length, Size.Width);
|
Size = new Size(Size.Length, Size.Width);
|
||||||
|
Outline = turned;
|
||||||
|
|
||||||
// After Size swap above, new Size.Width = old Length (old X extent),
|
// After Size swap above, new Size.Width = old Length (old X extent),
|
||||||
// new Size.Length = old Width (old Y extent).
|
// new Size.Length = old Width (old Y extent).
|
||||||
@@ -632,10 +732,12 @@ namespace OpenNest
|
|||||||
}
|
}
|
||||||
|
|
||||||
Rotate(System.Math.PI, centerpt);
|
Rotate(System.Math.PI, centerpt);
|
||||||
|
Outline = Outline?.Rotated180();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Rotate(System.Math.PI);
|
Rotate(System.Math.PI);
|
||||||
|
Outline = Outline?.Rotated180();
|
||||||
Quadrant = (Quadrant + 2) % 4;
|
Quadrant = (Quadrant + 2) % 4;
|
||||||
|
|
||||||
if (Quadrant == 0)
|
if (Quadrant == 0)
|
||||||
@@ -841,7 +943,8 @@ namespace OpenNest
|
|||||||
/// </example>
|
/// </example>
|
||||||
public void AutoSize(double roundingFactor = 1.0)
|
public void AutoSize(double roundingFactor = 1.0)
|
||||||
{
|
{
|
||||||
if (Parts.Count == 0)
|
// An outlined sheet is a particular piece of material; it cannot be resized to fit.
|
||||||
|
if (Parts.Count == 0 || Outline != null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var bounds = Parts.GetBoundingBox();
|
var bounds = Parts.GetBoundingBox();
|
||||||
@@ -890,7 +993,7 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
|
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
|
||||||
{
|
{
|
||||||
if (Parts.Count == 0)
|
if (Parts.Count == 0 || Outline != null)
|
||||||
return default;
|
return default;
|
||||||
|
|
||||||
var bounds = Parts.GetBoundingBox();
|
var bounds = Parts.GetBoundingBox();
|
||||||
@@ -942,12 +1045,13 @@ namespace OpenNest
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the area of the top surface of the plate.
|
/// Gets the area of the top surface of the plate: the material inside its
|
||||||
|
/// <see cref="Outline"/> when it has one, otherwise the whole rectangle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public double Area()
|
public double Area()
|
||||||
{
|
{
|
||||||
return Size.Width * Size.Length;
|
return Outline?.Area ?? Size.Width * Size.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ namespace OpenNest
|
|||||||
var followsDefaults = trailing != null
|
var followsDefaults = trailing != null
|
||||||
&& trailing.Parts.Count == 0
|
&& trailing.Parts.Count == 0
|
||||||
&& trailing.CutOffs.Count == 0
|
&& trailing.CutOffs.Count == 0
|
||||||
|
&& trailing.Outline == null
|
||||||
&& trailing.Size.Width == defaults.Size.Width
|
&& trailing.Size.Width == defaults.Size.Width
|
||||||
&& trailing.Size.Length == defaults.Size.Length
|
&& trailing.Size.Length == defaults.Size.Length
|
||||||
&& trailing.Quadrant == defaults.Quadrant
|
&& trailing.Quadrant == defaults.Quadrant
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
@@ -38,10 +39,14 @@ public sealed class PlateRemnant
|
|||||||
/// (no limit short of either sheet edge) with no part material beyond it defines the drop: the
|
/// (no limit short of either sheet edge) with no part material beyond it defines the drop: the
|
||||||
/// sheet past its nominal position, less the plate's kerf for each bounding cut-off line. It
|
/// sheet past its nominal position, less the plate's kerf for each bounding cut-off line. It
|
||||||
/// wins over a larger edge offcut. Otherwise the drop is the largest edge offcut that
|
/// wins over a larger edge offcut. Otherwise the drop is the largest edge offcut that
|
||||||
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks.
|
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks. A plate
|
||||||
|
/// with an <see cref="Plate.Outline"/> reports none.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static PlateRemnant Expected(Plate plate, double minimumDimension)
|
public static PlateRemnant Expected(Plate plate, double minimumDimension)
|
||||||
{
|
{
|
||||||
|
// No rectangular strip is guaranteed inside an outlined sheet.
|
||||||
|
if (plate.Outline != null)
|
||||||
|
return null;
|
||||||
// A part with no material (marks only) bounds nothing.
|
// A part with no material (marks only) bounds nothing.
|
||||||
var bounds = plate.Parts
|
var bounds = plate.Parts
|
||||||
.Where(p => !p.BaseDrawing.IsCutOff && p.Program != null)
|
.Where(p => !p.BaseDrawing.IsCutOff && p.Program != null)
|
||||||
@@ -70,11 +75,16 @@ public sealed class PlateRemnant
|
|||||||
/// material envelope (<paramref name="left"/> to <paramref name="top"/>) by
|
/// material envelope (<paramref name="left"/> to <paramref name="top"/>) by
|
||||||
/// <paramref name="partSpacing"/>, with both sides at least <paramref name="minimum"/>; null
|
/// <paramref name="partSpacing"/>, with both sides at least <paramref name="minimum"/>; null
|
||||||
/// when none qualifies. Ties keep the first of bottom, top, left, right. This is the salvage
|
/// when none qualifies. Ties keep the first of bottom, top, left, right. This is the salvage
|
||||||
/// offcut whole-job costing credits.
|
/// offcut whole-job costing credits. A <paramref name="cut"/> keeps only the strips one cut
|
||||||
|
/// along that axis frees: <see cref="CutOffAxis.Horizontal"/> the full-width bottom and top
|
||||||
|
/// strips, <see cref="CutOffAxis.Vertical"/> the full-length left and right ones.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Box LargestOffcut(Box workArea, double partSpacing,
|
public static Box LargestOffcut(Box workArea, double partSpacing,
|
||||||
double left, double bottom, double right, double top, double minimum)
|
double left, double bottom, double right, double top, double minimum,
|
||||||
|
CutOffAxis? cut = null)
|
||||||
{
|
{
|
||||||
|
if (cut is { } axis && !Enum.IsDefined(axis))
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(cut));
|
||||||
var work = workArea;
|
var work = workArea;
|
||||||
var gap = partSpacing;
|
var gap = partSpacing;
|
||||||
var candidates = new[]
|
var candidates = new[]
|
||||||
@@ -85,8 +95,11 @@ public sealed class PlateRemnant
|
|||||||
new Box(right + gap, work.Bottom, work.Right - right - gap, work.Width),
|
new Box(right + gap, work.Bottom, work.Right - right - gap, work.Width),
|
||||||
};
|
};
|
||||||
Box best = null;
|
Box best = null;
|
||||||
foreach (var candidate in candidates)
|
for (var i = 0; i < candidates.Length; i++)
|
||||||
{
|
{
|
||||||
|
var candidate = candidates[i];
|
||||||
|
if (cut == CutOffAxis.Horizontal && i >= 2 || cut == CutOffAxis.Vertical && i < 2)
|
||||||
|
continue;
|
||||||
if (candidate.Length >= minimum && candidate.Width >= minimum
|
if (candidate.Length >= minimum && candidate.Width >= minimum
|
||||||
&& (best == null || candidate.Length * candidate.Width > best.Length * best.Width))
|
&& (best == null || candidate.Length * candidate.Width > best.Length * best.Width))
|
||||||
best = candidate;
|
best = candidate;
|
||||||
@@ -148,8 +161,8 @@ public sealed class PlateRemnant
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the nominal line runs edge to edge. A head-locked overcut only extends the
|
/// Whether the nominal line runs edge to edge with no gap on the sheet. A head-locked
|
||||||
/// program past the far edge, so the nominal limits decide.
|
/// overcut only extends the program past the far edge, so the nominal limits decide.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool SpansSheet(CutOff cutOff, Box sheet)
|
private static bool SpansSheet(CutOff cutOff, Box sheet)
|
||||||
{
|
{
|
||||||
@@ -157,7 +170,8 @@ public sealed class PlateRemnant
|
|||||||
var alongMin = vertical ? sheet.Bottom : sheet.Left;
|
var alongMin = vertical ? sheet.Bottom : sheet.Left;
|
||||||
var alongMax = vertical ? sheet.Top : sheet.Right;
|
var alongMax = vertical ? sheet.Top : sheet.Right;
|
||||||
return (cutOff.StartLimit is not double start || start <= alongMin + Tolerance.Epsilon)
|
return (cutOff.StartLimit is not double start || start <= alongMin + Tolerance.Epsilon)
|
||||||
&& (cutOff.EndLimit is not double end || end >= alongMax - Tolerance.Epsilon);
|
&& (cutOff.EndLimit is not double end || end >= alongMax - Tolerance.Epsilon)
|
||||||
|
&& !cutOff.Gaps.Any(gap => gap.End > alongMin + Tolerance.Epsilon && gap.Start < alongMax - Tolerance.Epsilon);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool Reaches(Box material, bool vertical, double from, double to)
|
private static bool Reaches(Box material, bool vertical, double from, double to)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Where a post keeps its own settings: %APPDATA%\OpenNest\Posts\<post assembly>.json.
|
||||||
|
/// The install folder holds only the shipped defaults, because an update replaces it and
|
||||||
|
/// would silently reset every edit saved there. The shipped JSON is read until the first save.
|
||||||
|
/// </summary>
|
||||||
|
public static class PostSettingsFile
|
||||||
|
{
|
||||||
|
/// <summary>Replaces <see cref="Directory"/>, so test runs never read or write the user's settings.</summary>
|
||||||
|
public static string DirectoryOverride { get; set; }
|
||||||
|
|
||||||
|
public static string Directory => DirectoryOverride ?? Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenNest", "Posts");
|
||||||
|
|
||||||
|
public static string PathFor(Assembly post) =>
|
||||||
|
Path.Combine(Directory, Path.GetFileNameWithoutExtension(post.Location) + ".json");
|
||||||
|
|
||||||
|
/// <summary>The defaults deployed beside the post's assembly.</summary>
|
||||||
|
public static string ShippedPathFor(Assembly post) => Path.ChangeExtension(post.Location, ".json");
|
||||||
|
|
||||||
|
/// <summary>The saved settings, else the shipped defaults, else null.</summary>
|
||||||
|
public static string Read(Assembly post)
|
||||||
|
{
|
||||||
|
var saved = PathFor(post);
|
||||||
|
if (File.Exists(saved))
|
||||||
|
return File.ReadAllText(saved);
|
||||||
|
var shipped = ShippedPathFor(post);
|
||||||
|
return File.Exists(shipped) ? File.ReadAllText(shipped) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves through a temporary file, so a failed write leaves the previous settings intact.
|
||||||
|
/// </summary>
|
||||||
|
public static void Write(Assembly post, string json)
|
||||||
|
{
|
||||||
|
var path = PathFor(post);
|
||||||
|
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||||
|
var temp = path + ".tmp";
|
||||||
|
File.WriteAllText(temp, json);
|
||||||
|
File.Move(temp, path, overwrite: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Clipper2Lib;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The material of a sheet that is not a rectangle: a remnant, a drop, or a sheet with a
|
||||||
|
/// burned or damaged area. A closed polygon in plate coordinates (the sheet's lower-left
|
||||||
|
/// frame, as <see cref="Plate.ClampZones"/>) with optional obstacle polygons for areas inside
|
||||||
|
/// it that must stay empty. The polygon lies inside the true material and every obstacle
|
||||||
|
/// polygon contains its true area, so chording never adds room. Its bounding box starts at
|
||||||
|
/// the origin and is the sheet's <see cref="Size"/>; rectangular code paths keep using that
|
||||||
|
/// box. Immutable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SheetOutline : IEquatable<SheetOutline>
|
||||||
|
{
|
||||||
|
/// <summary>Chord deviation when importing arcs; the outline is also eroded, and each
|
||||||
|
/// obstacle inflated, by this much so the stored polygons never reach past the true edges.</summary>
|
||||||
|
public const double ChordTolerance = SpacingOutline.ChordTolerance;
|
||||||
|
|
||||||
|
private const int Precision = ClipperBridge.Precision;
|
||||||
|
private const double ArcTolerance = 0.001;
|
||||||
|
private const double MiterLimit = 2.0;
|
||||||
|
|
||||||
|
private readonly PathD outline;
|
||||||
|
private readonly PathsD obstacles;
|
||||||
|
private readonly PathsD material;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an outline from its vertices and optional obstacles, each a simple polygon of at
|
||||||
|
/// least three finite vertices with a positive area. Winding is normalized and the whole
|
||||||
|
/// is translated so the outline's bounds start at the origin; an obstacle wholly outside
|
||||||
|
/// the outline is dropped.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentException">A polygon is missing, degenerate, nonfinite or
|
||||||
|
/// self-intersecting.</exception>
|
||||||
|
public SheetOutline(IEnumerable<Vector> points, IEnumerable<IEnumerable<Vector>> obstacles = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(points);
|
||||||
|
var path = SimplePolygon(points, "outline");
|
||||||
|
var box = Clipper.GetBounds(path);
|
||||||
|
var shift = new PointD(-box.left, -box.top); // RectD top is the minimum Y.
|
||||||
|
outline = Clipper.TranslatePath(path, shift.x, shift.y);
|
||||||
|
Size = new Size(box.Height, box.Width);
|
||||||
|
|
||||||
|
this.obstacles = new PathsD();
|
||||||
|
if (obstacles != null)
|
||||||
|
{
|
||||||
|
var index = 0;
|
||||||
|
foreach (var obstacle in obstacles)
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
var obstaclePath = Clipper.TranslatePath(SimplePolygon(obstacle, $"obstacle {index}"), shift.x, shift.y);
|
||||||
|
var inside = Clipper.Intersect(new PathsD { outline }, new PathsD { obstaclePath }, FillRule.NonZero, Precision);
|
||||||
|
if (inside.Sum(Clipper.Area) > 0)
|
||||||
|
this.obstacles.Add(obstaclePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
material = Clipper.Difference(new PathsD { outline }, this.obstacles, FillRule.NonZero, Precision);
|
||||||
|
Area = material.Sum(Clipper.Area);
|
||||||
|
if (!(Area > 0))
|
||||||
|
throw new ArgumentException("The obstacles leave no material inside the outline.", nameof(obstacles));
|
||||||
|
|
||||||
|
Points = outline.Select(p => new Vector(p.x, p.y)).ToArray();
|
||||||
|
Obstacles = this.obstacles
|
||||||
|
.Select(o => (IReadOnlyList<Vector>)o.Select(p => new Vector(p.x, p.y)).ToArray())
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Vertices of the outline, counterclockwise, in plate coordinates.</summary>
|
||||||
|
public IReadOnlyList<Vector> Points { get; }
|
||||||
|
|
||||||
|
/// <summary>Vertices of each obstacle, counterclockwise, in plate coordinates.</summary>
|
||||||
|
public IReadOnlyList<IReadOnlyList<Vector>> Obstacles { get; }
|
||||||
|
|
||||||
|
/// <summary>The bounding box of the outline: the plate's size.</summary>
|
||||||
|
public Size Size { get; }
|
||||||
|
|
||||||
|
/// <summary>Material area: the outline less its obstacles.</summary>
|
||||||
|
public double Area { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the plate's lower-left corner lies in the placement frame of its parts for
|
||||||
|
/// <paramref name="quadrant"/>: -Length on X in quadrants 2 and 3, -Width on Y in 3 and 4,
|
||||||
|
/// exactly as <see cref="ClampZoneFrame"/> and the engine stock translate.
|
||||||
|
/// </summary>
|
||||||
|
public Vector PlacementOrigin(int quadrant) =>
|
||||||
|
new(quadrant is 2 or 3 ? -Size.Length : 0, quadrant is 3 or 4 ? -Size.Width : 0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The outline less its obstacles, translated by <paramref name="origin"/>: outer paths
|
||||||
|
/// positive, holes negative.
|
||||||
|
/// </summary>
|
||||||
|
public PathsD Material(Vector origin) => Translate(material, origin);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The material eroded by <paramref name="inset"/> (round joins) and translated by
|
||||||
|
/// <paramref name="origin"/>: where a part may lie when it must keep <paramref name="inset"/>
|
||||||
|
/// from every edge and obstacle. Empty when nothing is left. A nonpositive inset returns
|
||||||
|
/// the material itself.
|
||||||
|
/// </summary>
|
||||||
|
public PathsD Usable(double inset, Vector origin)
|
||||||
|
{
|
||||||
|
if (!(inset > 0))
|
||||||
|
return Material(origin);
|
||||||
|
var eroded = Clipper.InflatePaths(material, -inset, JoinType.Round, EndType.Polygon,
|
||||||
|
MiterLimit, Precision, ArcTolerance);
|
||||||
|
eroded.RemoveAll(p => p.Count < 3);
|
||||||
|
return Translate(eroded, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a region lies inside the material eroded by <paramref name="inset"/>, allowing
|
||||||
|
/// <paramref name="slack"/> outside it: nothing of <paramref name="region"/> (outer paths
|
||||||
|
/// positive, holes negative, in the frame of <paramref name="origin"/>) is left after
|
||||||
|
/// subtracting the usable area grown by the slack. The test is exact on the stored polygons;
|
||||||
|
/// it never loosens for arcs.
|
||||||
|
/// </summary>
|
||||||
|
public bool Holds(PathsD region, double inset, Vector origin, double slack)
|
||||||
|
{
|
||||||
|
var usable = Usable(inset, origin);
|
||||||
|
if (slack > 0)
|
||||||
|
{
|
||||||
|
usable = Clipper.InflatePaths(usable, slack, JoinType.Miter, EndType.Polygon,
|
||||||
|
MiterLimit, Precision, ArcTolerance);
|
||||||
|
}
|
||||||
|
var outside = Clipper.Difference(region, usable, FillRule.NonZero, Precision);
|
||||||
|
return outside.Sum(p => System.Math.Abs(Clipper.Area(p))) <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>This outline turned a quarter turn about the origin, re-normalized to start
|
||||||
|
/// at the origin, as <see cref="Plate.Rotate90"/> turns the sheet.</summary>
|
||||||
|
public SheetOutline Rotated90(RotationType direction)
|
||||||
|
{
|
||||||
|
Vector Turn(Vector p) => direction == RotationType.CW ? new Vector(p.Y, -p.X) : new Vector(-p.Y, p.X);
|
||||||
|
return new SheetOutline(Points.Select(Turn), Obstacles.Select(o => o.Select(Turn)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>This outline turned half a turn, re-normalized to start at the origin.</summary>
|
||||||
|
public SheetOutline Rotated180() =>
|
||||||
|
new(Points.Select(p => new Vector(-p.X, -p.Y)), Obstacles.Select(o => o.Select(p => new Vector(-p.X, -p.Y))));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An outline from imported geometry: the largest closed contour becomes the outline and
|
||||||
|
/// every other closed contour an obstacle. Arcs are chorded at <see cref="ChordTolerance"/>,
|
||||||
|
/// then the outline is eroded and each obstacle inflated by that much, so the stored
|
||||||
|
/// polygons stay within the true edges. Marks and open contours are ignored.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentException">No closed contour with a positive area.</exception>
|
||||||
|
public static SheetOutline FromGeometry(IEnumerable<Entity> entities)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entities);
|
||||||
|
var material = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList();
|
||||||
|
ShapeProfile profile;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
profile = material.Count == 0 ? null : new ShapeProfile(material);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The geometry has no closed outline.", nameof(entities), exception);
|
||||||
|
}
|
||||||
|
if (profile?.Perimeter == null)
|
||||||
|
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
|
||||||
|
|
||||||
|
var perimeter = ClipperBridge.ToPath(ClipperBridge.Flatten(profile.Perimeter, ChordTolerance, circumscribe: false), positive: true);
|
||||||
|
if (perimeter.Count < 3 || !(Clipper.Area(perimeter) > 0))
|
||||||
|
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
|
||||||
|
var outer = Clipper.InflatePaths(new PathsD { perimeter },
|
||||||
|
-ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
|
||||||
|
var outline = outer.Where(p => p.Count >= 3).OrderByDescending(Clipper.Area).FirstOrDefault()
|
||||||
|
?? throw new ArgumentException("The outline is too thin to hold material.", nameof(entities));
|
||||||
|
|
||||||
|
var obstacles = new List<IEnumerable<Vector>>();
|
||||||
|
foreach (var cutout in profile.Cutouts)
|
||||||
|
{
|
||||||
|
var hole = ClipperBridge.ToPath(ClipperBridge.Flatten(cutout, ChordTolerance, circumscribe: false), positive: true);
|
||||||
|
if (hole.Count < 3 || !(Clipper.Area(hole) > 0))
|
||||||
|
continue;
|
||||||
|
var grown = Clipper.InflatePaths(new PathsD { hole },
|
||||||
|
ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
|
||||||
|
foreach (var path in grown.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
|
||||||
|
obstacles.Add(path.Select(p => new Vector(p.x, p.y)).ToList());
|
||||||
|
}
|
||||||
|
return new SheetOutline(outline.Select(p => new Vector(p.x, p.y)), obstacles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Equals(SheetOutline other)
|
||||||
|
{
|
||||||
|
if (other is null)
|
||||||
|
return false;
|
||||||
|
if (ReferenceEquals(this, other))
|
||||||
|
return true;
|
||||||
|
if (Points.Count != other.Points.Count || Obstacles.Count != other.Obstacles.Count)
|
||||||
|
return false;
|
||||||
|
for (var i = 0; i < Points.Count; i++)
|
||||||
|
if (!Same(Points[i], other.Points[i]))
|
||||||
|
return false;
|
||||||
|
for (var k = 0; k < Obstacles.Count; k++)
|
||||||
|
{
|
||||||
|
if (Obstacles[k].Count != other.Obstacles[k].Count)
|
||||||
|
return false;
|
||||||
|
for (var i = 0; i < Obstacles[k].Count; i++)
|
||||||
|
if (!Same(Obstacles[k][i], other.Obstacles[k][i]))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool Equals(object obj) => Equals(obj as SheetOutline);
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
var hash = new System.HashCode();
|
||||||
|
hash.Add(Points.Count);
|
||||||
|
hash.Add(Obstacles.Count);
|
||||||
|
foreach (var p in Points)
|
||||||
|
{
|
||||||
|
hash.Add(BitConverter.DoubleToInt64Bits(p.X));
|
||||||
|
hash.Add(BitConverter.DoubleToInt64Bits(p.Y));
|
||||||
|
}
|
||||||
|
return hash.ToHashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"{Size} outline, {Points.Count} vertices" + (Obstacles.Count == 0 ? "" : $", {Obstacles.Count} obstacle(s)");
|
||||||
|
|
||||||
|
/// <summary>Exact bits: two outlines are the same stock only when nothing moved.</summary>
|
||||||
|
private static bool Same(Vector a, Vector b) =>
|
||||||
|
BitConverter.DoubleToInt64Bits(a.X) == BitConverter.DoubleToInt64Bits(b.X)
|
||||||
|
&& BitConverter.DoubleToInt64Bits(a.Y) == BitConverter.DoubleToInt64Bits(b.Y);
|
||||||
|
|
||||||
|
private static PathsD Translate(PathsD paths, Vector origin) =>
|
||||||
|
origin.X == 0 && origin.Y == 0
|
||||||
|
? new PathsD(paths.Select(p => new PathD(p)))
|
||||||
|
: Clipper.TranslatePaths(paths, origin.X, origin.Y);
|
||||||
|
|
||||||
|
/// <summary>A counterclockwise copy without the closing or repeated vertices, after
|
||||||
|
/// checking it is one simple polygon of positive area.</summary>
|
||||||
|
private static PathD SimplePolygon(IEnumerable<Vector> points, string what)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(points, nameof(points));
|
||||||
|
var path = new PathD();
|
||||||
|
foreach (var p in points)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(p.X) || !double.IsFinite(p.Y))
|
||||||
|
throw new ArgumentException($"The {what} has a nonfinite vertex.", nameof(points));
|
||||||
|
if (path.Count > 0 && path[^1].x == p.X && path[^1].y == p.Y)
|
||||||
|
continue;
|
||||||
|
path.Add(new PointD(p.X, p.Y));
|
||||||
|
}
|
||||||
|
if (path.Count > 1 && path[0].x == path[^1].x && path[0].y == path[^1].y)
|
||||||
|
path.RemoveAt(path.Count - 1);
|
||||||
|
if (path.Count < 3)
|
||||||
|
throw new ArgumentException($"The {what} needs at least three distinct vertices.", nameof(points));
|
||||||
|
var area = Clipper.Area(path);
|
||||||
|
if (!(System.Math.Abs(area) > 0))
|
||||||
|
throw new ArgumentException($"The {what} has no area.", nameof(points));
|
||||||
|
if (area < 0)
|
||||||
|
path.Reverse();
|
||||||
|
// Start at the lowest-left vertex so equal polygons list their vertices alike.
|
||||||
|
var start = 0;
|
||||||
|
for (var i = 1; i < path.Count; i++)
|
||||||
|
if (path[i].y < path[start].y || path[i].y == path[start].y && path[i].x < path[start].x)
|
||||||
|
start = i;
|
||||||
|
if (start != 0)
|
||||||
|
path = new PathD(path.Skip(start).Concat(path.Take(start)));
|
||||||
|
// A simple polygon unions to itself: one path of the same area. A figure eight or a
|
||||||
|
// bow tie does not, and a sheet edge that crosses itself is not a sheet.
|
||||||
|
var union = Clipper.Union(new PathsD { path }, new PathsD(), FillRule.NonZero, Precision);
|
||||||
|
var scale = System.Math.Pow(10, -Precision);
|
||||||
|
if (union.Count != 1 || System.Math.Abs(Clipper.Area(union[0]) - System.Math.Abs(area)) > scale * (path.Count + 1) * Perimeter(path))
|
||||||
|
throw new ArgumentException($"The {what} must be a simple polygon that does not cross itself.", nameof(points));
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Perimeter(PathD path)
|
||||||
|
{
|
||||||
|
var length = 0.0;
|
||||||
|
for (var i = 0; i < path.Count; i++)
|
||||||
|
{
|
||||||
|
var a = path[i];
|
||||||
|
var b = path[(i + 1) % path.Count];
|
||||||
|
length += System.Math.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -252,13 +252,17 @@ public static class DrawingSplitter
|
|||||||
&& line.Position < bounds.Top - OpenNest.Math.Tolerance.Epsilon;
|
&& line.Position < bounds.Top - OpenNest.Math.Tolerance.Epsilon;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Box> BuildClipRegions(List<SplitLine> sortedLines, Box bounds)
|
/// <summary>
|
||||||
|
/// The grid of pieces the split lines cut <paramref name="bounds"/> into, row by row
|
||||||
|
/// from the bottom-left.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Box> BuildClipRegions(IEnumerable<SplitLine> lines, Box bounds)
|
||||||
{
|
{
|
||||||
var verticals = sortedLines
|
var verticals = lines
|
||||||
.Where(l => l.Axis == CutOffAxis.Vertical)
|
.Where(l => l.Axis == CutOffAxis.Vertical)
|
||||||
.OrderBy(l => l.Position)
|
.OrderBy(l => l.Position)
|
||||||
.ToList();
|
.ToList();
|
||||||
var horizontals = sortedLines
|
var horizontals = lines
|
||||||
.Where(l => l.Axis == CutOffAxis.Horizontal)
|
.Where(l => l.Axis == CutOffAxis.Horizontal)
|
||||||
.OrderBy(l => l.Position)
|
.OrderBy(l => l.Position)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -737,7 +741,7 @@ public static class DrawingSplitter
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
var bbB = shapes[j].BoundingBox;
|
var bbB = shapes[j].BoundingBox;
|
||||||
if (!BoxContainsBox(bbB, bbA))
|
if (!BoundingBox.IsWithin(bbA, bbB))
|
||||||
continue;
|
continue;
|
||||||
if (!polygons[j].ContainsPoint(repA))
|
if (!polygons[j].ContainsPoint(repA))
|
||||||
continue;
|
continue;
|
||||||
@@ -775,7 +779,7 @@ public static class DrawingSplitter
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
var bbJ = shapes[j].BoundingBox;
|
var bbJ = shapes[j].BoundingBox;
|
||||||
if (!BoxContainsBox(shapes[i].BoundingBox, bbJ))
|
if (!BoundingBox.IsWithin(bbJ, shapes[i].BoundingBox))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var rep = FirstVertexOf(shapes[j]);
|
var rep = FirstVertexOf(shapes[j]);
|
||||||
@@ -812,19 +816,6 @@ public static class DrawingSplitter
|
|||||||
return GetStartPoint(shape.Entities[0]);
|
return GetStartPoint(shape.Entities[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True iff box <paramref name="inner"/> is entirely inside box
|
|
||||||
/// <paramref name="outer"/> (tolerant comparison).
|
|
||||||
/// </summary>
|
|
||||||
private static bool BoxContainsBox(Box outer, Box inner)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Vector GetStartPoint(Entity entity)
|
private static Vector GetStartPoint(Entity entity)
|
||||||
{
|
{
|
||||||
return entity switch
|
return entity switch
|
||||||
|
|||||||
@@ -180,9 +180,47 @@ namespace OpenNest.Support
|
|||||||
return new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
|
return new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The full angle range checks every hull edge.
|
// The minimum-area rectangle over every hull edge. Shapes such as a right isosceles
|
||||||
private static BoundingRectangleResult BoundingRectangle(IReadOnlyList<Vector> outline) =>
|
// triangle have more than one (its legs' square and its hypotenuse's 2:1 box have the same
|
||||||
RotatingCalipers.MinimumBoundingRectangle(ConvexHull.Compute(outline.ToList()), 0, Math.Angle.TwoPI);
|
// area), and which one rounding picks depends on the part's rotation; of rectangles tied
|
||||||
|
// for area, the one with the shorter long side wins, so a drawing measures the same
|
||||||
|
// however it is placed.
|
||||||
|
private static BoundingRectangleResult BoundingRectangle(IReadOnlyList<Vector> outline)
|
||||||
|
{
|
||||||
|
var hull = ConvexHull.Compute(outline.ToList()).Vertices;
|
||||||
|
var n = hull.Count > 1 && hull[0] == hull[^1] ? hull.Count - 1 : hull.Count;
|
||||||
|
if (n < 3)
|
||||||
|
return RotatingCalipers.MinimumBoundingRectangle(outline.ToList());
|
||||||
|
|
||||||
|
var candidates = new List<BoundingRectangleResult>(n);
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var edge = hull[(i + 1) % n] - hull[i];
|
||||||
|
var length = System.Math.Sqrt(edge.X * edge.X + edge.Y * edge.Y);
|
||||||
|
if (length <= Math.Tolerance.Epsilon)
|
||||||
|
continue;
|
||||||
|
var u = new Vector(edge.X / length, edge.Y / length);
|
||||||
|
var v = new Vector(-u.Y, u.X);
|
||||||
|
double minU = double.MaxValue, maxU = double.MinValue, minV = double.MaxValue, maxV = double.MinValue;
|
||||||
|
for (var j = 0; j < n; j++)
|
||||||
|
{
|
||||||
|
var pu = hull[j].X * u.X + hull[j].Y * u.Y;
|
||||||
|
var pv = hull[j].X * v.X + hull[j].Y * v.Y;
|
||||||
|
minU = System.Math.Min(minU, pu);
|
||||||
|
maxU = System.Math.Max(maxU, pu);
|
||||||
|
minV = System.Math.Min(minV, pv);
|
||||||
|
maxV = System.Math.Max(maxV, pv);
|
||||||
|
}
|
||||||
|
candidates.Add(new BoundingRectangleResult(System.Math.Atan2(edge.Y, edge.X), maxU - minU, maxV - minV));
|
||||||
|
}
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
return RotatingCalipers.MinimumBoundingRectangle(outline.ToList());
|
||||||
|
|
||||||
|
var least = candidates.Min(c => c.Area);
|
||||||
|
var slack = Math.Tolerance.Epsilon * System.Math.Max(least, 1);
|
||||||
|
return candidates.Where(c => c.Area <= least + slack)
|
||||||
|
.MinBy(c => System.Math.Max(c.Width, c.Height));
|
||||||
|
}
|
||||||
|
|
||||||
// A point `clear` from the nearest inside corner keeps `clearance` from it; with no
|
// A point `clear` from the nearest inside corner keeps `clearance` from it; with no
|
||||||
// clearance it must still not be the corner.
|
// clearance it must still not be the corner.
|
||||||
@@ -360,19 +398,19 @@ namespace OpenNest.Support
|
|||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
var step = perimeter / TabSamples;
|
var step = perimeter / TabSamples;
|
||||||
var travelled = 0.0;
|
var traveled = 0.0;
|
||||||
var next = step;
|
var next = step;
|
||||||
for (var i = 0; i < outline.Count; i++)
|
for (var i = 0; i < outline.Count; i++)
|
||||||
{
|
{
|
||||||
var a = outline[i];
|
var a = outline[i];
|
||||||
var b = outline[(i + 1) % outline.Count];
|
var b = outline[(i + 1) % outline.Count];
|
||||||
var length = a.DistanceTo(b);
|
var length = a.DistanceTo(b);
|
||||||
while (length > 0 && next < travelled + length)
|
while (length > 0 && next < traveled + length)
|
||||||
{
|
{
|
||||||
yield return a + (b - a) * ((next - travelled) / length);
|
yield return a + (b - a) * ((next - traveled) / length);
|
||||||
next += step;
|
next += step;
|
||||||
}
|
}
|
||||||
travelled += length;
|
traveled += length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ namespace OpenNest.TabbedCutouts
|
|||||||
public double Length { get; internal set; }
|
public double Length { get; internal set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One open cut per span of the contour between tabs, in contour order, travelling
|
/// One open cut per span of the contour between tabs, in contour order, traveling
|
||||||
/// counterclockwise (the slug on the left).
|
/// counterclockwise (the slug on the left).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<IReadOnlyList<Entity>> Pieces { get; internal set; }
|
public IReadOnlyList<IReadOnlyList<Entity>> Pieces { get; internal set; }
|
||||||
@@ -65,7 +65,7 @@ namespace OpenNest.TabbedCutouts
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab keeps a
|
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab keeps a
|
||||||
/// gap from its neighbours around the contour and does not pass the contour's start
|
/// gap from its neighbors around the contour and does not pass the contour's start
|
||||||
/// (which would reorder the tabs).
|
/// (which would reorder the tabs).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double ClampTabCenter(int index, double center)
|
public double ClampTabCenter(int index, double center)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public interface IBrowsePage
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Browse state shared by the paged server lists: the requested search, sort and page, and the
|
/// Browse state shared by the paged server lists: the requested search, sort and page, and the
|
||||||
/// last page applied. Every change issues one bounded server query. A response that a later
|
/// last page applied. Every change issues one bounded server query. A response that a later
|
||||||
/// request superseded is discarded (and the earlier request is cancelled), so an older result
|
/// request superseded is discarded (and the earlier request is canceled), so an older result
|
||||||
/// can never replace a newer one. Call from one thread (the UI thread).
|
/// can never replace a newer one. Call from one thread (the UI thread).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class BrowseSession<TPage, TSort> : IDisposable
|
public abstract class BrowseSession<TPage, TSort> : IDisposable
|
||||||
@@ -130,7 +130,7 @@ public abstract class BrowseSession<TPage, TSort> : IDisposable
|
|||||||
private async Task<bool> RunAsync()
|
private async Task<bool> RunAsync()
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
// Supersede before cancelling: a request that completes synchronously when cancelled
|
// Supersede before canceling: a request that completes synchronously when canceled
|
||||||
// must already see itself as superseded. Each source is released by its own request.
|
// must already see itself as superseded. Each source is released by its own request.
|
||||||
var previous = _pending;
|
var previous = _pending;
|
||||||
using var cancellation = new CancellationTokenSource();
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public sealed class ClampRailEditor
|
|||||||
Width = initial.Width;
|
Width = initial.Width;
|
||||||
Depth = initial.Depth;
|
Depth = initial.Depth;
|
||||||
RemovalMinutes = initial.RemovalMinutes;
|
RemovalMinutes = initial.RemovalMinutes;
|
||||||
|
Clearance = initial.Clearance;
|
||||||
_clamps.AddRange(initial.Clamps.Order());
|
_clamps.AddRange(initial.Clamps.Order());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,9 @@ public sealed class ClampRailEditor
|
|||||||
/// length, so it is never converted between units.</summary>
|
/// length, so it is never converted between units.</summary>
|
||||||
public double RemovalMinutes { get; set; }
|
public double RemovalMinutes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The gap parts keep from every clamp; null uses each sheet's bottom edge spacing.</summary>
|
||||||
|
public double? Clearance { get; set; }
|
||||||
|
|
||||||
/// <summary>Whether saved machine defaults give new nests these clamps.</summary>
|
/// <summary>Whether saved machine defaults give new nests these clamps.</summary>
|
||||||
public bool ApplyToNewNests { get; set; }
|
public bool ApplyToNewNests { get; set; }
|
||||||
|
|
||||||
@@ -91,6 +95,7 @@ public sealed class ClampRailEditor
|
|||||||
Width = converted.Width;
|
Width = converted.Width;
|
||||||
Depth = converted.Depth;
|
Depth = converted.Depth;
|
||||||
RemovalMinutes = converted.RemovalMinutes;
|
RemovalMinutes = converted.RemovalMinutes;
|
||||||
|
Clearance = converted.Clearance;
|
||||||
ApplyToNewNests = settings.Enabled;
|
ApplyToNewNests = settings.Enabled;
|
||||||
_clamps.Clear();
|
_clamps.Clear();
|
||||||
_clamps.AddRange(converted.Clamps.Distinct().Order());
|
_clamps.AddRange(converted.Clamps.Distinct().Order());
|
||||||
@@ -99,10 +104,10 @@ public sealed class ClampRailEditor
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The edited rail, or null when no clamp is listed. Throws
|
/// The edited rail, or null when no clamp is listed. Throws
|
||||||
/// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth, or a
|
/// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth, or a
|
||||||
/// negative/non-finite removal time.
|
/// negative/non-finite removal time or clearance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ClampRail? BuildRail() =>
|
public ClampRail? BuildRail() =>
|
||||||
_clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes);
|
_clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes, Clearance);
|
||||||
|
|
||||||
/// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary>
|
/// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary>
|
||||||
public void ApplyTo(Nest nest)
|
public void ApplyTo(Nest nest)
|
||||||
@@ -128,6 +133,11 @@ public sealed class ClampRailEditor
|
|||||||
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 clearance && (!double.IsFinite(clearance) || clearance < 0))
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(Clearance),
|
||||||
|
"Clamp clearance must be finite and zero or more."
|
||||||
|
);
|
||||||
var settings = new ClampSettings
|
var settings = new ClampSettings
|
||||||
{
|
{
|
||||||
Enabled = ApplyToNewNests && _clamps.Count > 0,
|
Enabled = ApplyToNewNests && _clamps.Count > 0,
|
||||||
@@ -135,6 +145,7 @@ public sealed class ClampRailEditor
|
|||||||
Width = Width,
|
Width = Width,
|
||||||
Depth = Depth,
|
Depth = Depth,
|
||||||
RemovalMinutes = RemovalMinutes,
|
RemovalMinutes = RemovalMinutes,
|
||||||
|
Clearance = Clearance,
|
||||||
};
|
};
|
||||||
settings.SetClamps(_clamps);
|
settings.SetClamps(_clamps);
|
||||||
return settings;
|
return settings;
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ public sealed class ClampSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
|
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The gap parts keep from every clamp, in <see cref="Units"/>; null uses each sheet's
|
||||||
|
/// bottom edge spacing (<see cref="ClampRail.Clearance"/>).
|
||||||
|
/// </summary>
|
||||||
|
public double? Clearance { get; set; }
|
||||||
|
|
||||||
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
|
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
|
||||||
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
|
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
|
||||||
|
|
||||||
@@ -133,6 +139,7 @@ public sealed class ClampSettings
|
|||||||
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
|
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
|
||||||
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
|
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
|
||||||
RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
|
RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
|
||||||
|
Clearance = rail?.Clearance,
|
||||||
};
|
};
|
||||||
if (rail != null)
|
if (rail != null)
|
||||||
settings.SetClamps(rail.Clamps);
|
settings.SetClamps(rail.Clamps);
|
||||||
@@ -150,11 +157,12 @@ public sealed class ClampSettings
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
var converted = ConvertedTo(units);
|
var converted = ConvertedTo(units);
|
||||||
return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes);
|
return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes,
|
||||||
|
converted.Clearance);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A copy with width, depth and centers expressed in <paramref name="units"/>
|
/// A copy with width, depth, clearance and centers expressed in <paramref name="units"/>
|
||||||
/// (inches and millimeters convert at 25.4); <see cref="Enabled"/> and
|
/// (inches and millimeters convert at 25.4); <see cref="Enabled"/> and
|
||||||
/// <see cref="RemovalMinutes"/> (a time, not a length) are kept unscaled.
|
/// <see cref="RemovalMinutes"/> (a time, not a length) are kept unscaled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -168,6 +176,7 @@ public sealed class ClampSettings
|
|||||||
Width = Width * scale,
|
Width = Width * scale,
|
||||||
Depth = Depth * scale,
|
Depth = Depth * scale,
|
||||||
RemovalMinutes = RemovalMinutes,
|
RemovalMinutes = RemovalMinutes,
|
||||||
|
Clearance = Clearance * scale,
|
||||||
Centers = Centers.Select(c => c * scale).ToArray(),
|
Centers = Centers.Select(c => c * scale).ToArray(),
|
||||||
FarSideCenters = FarSideCenters.Select(c => c * scale).ToArray(),
|
FarSideCenters = FarSideCenters.Select(c => c * scale).ToArray(),
|
||||||
OriginEndCenters = OriginEndCenters.Select(c => c * scale).ToArray(),
|
OriginEndCenters = OriginEndCenters.Select(c => c * scale).ToArray(),
|
||||||
@@ -207,7 +216,7 @@ public sealed class ClampSettings
|
|||||||
{
|
{
|
||||||
json = File.ReadAllText(path);
|
json = File.ReadAllText(path);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (IsUnreadableFile(ex))
|
catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
|
||||||
{
|
{
|
||||||
status = ClampSettingsStatus.Invalid;
|
status = ClampSettingsStatus.Invalid;
|
||||||
return Fallback;
|
return Fallback;
|
||||||
@@ -238,7 +247,7 @@ public sealed class ClampSettings
|
|||||||
foreach (var property in document.RootElement.EnumerateObject())
|
foreach (var property in document.RootElement.EnumerateObject())
|
||||||
values[property.Name] = property.Value.Clone();
|
values[property.Name] = property.Value.Clone();
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (IsUnreadableFile(ex))
|
catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
|
||||||
{
|
{
|
||||||
status = ClampSettingsStatus.Invalid;
|
status = ClampSettingsStatus.Invalid;
|
||||||
return settings;
|
return settings;
|
||||||
@@ -256,7 +265,7 @@ public sealed class ClampSettings
|
|||||||
|
|
||||||
if (values.TryGetValue("units", out var units))
|
if (values.TryGetValue("units", out var units))
|
||||||
{
|
{
|
||||||
if (TryParseUnits(units, out var parsed))
|
if (SettingsFile.TryParseUnits(units, out var parsed))
|
||||||
settings.Units = parsed;
|
settings.Units = parsed;
|
||||||
else
|
else
|
||||||
ignored = true;
|
ignored = true;
|
||||||
@@ -290,6 +299,15 @@ public sealed class ClampSettings
|
|||||||
ignored = true;
|
ignored = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Missing or null keeps the bottom edge spacing; anything else must be a length.
|
||||||
|
if (values.TryGetValue("clearance", out var clearance) && clearance.ValueKind != JsonValueKind.Null)
|
||||||
|
{
|
||||||
|
if (TryLength(clearance, out var value) && value >= 0)
|
||||||
|
settings.Clearance = value;
|
||||||
|
else
|
||||||
|
ignored = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (values.TryGetValue("centers", out var centers))
|
if (values.TryGetValue("centers", out var centers))
|
||||||
settings.Centers = ReadCenters(centers, ref ignored);
|
settings.Centers = ReadCenters(centers, ref ignored);
|
||||||
if (values.TryGetValue("farSideCenters", out var farSide))
|
if (values.TryGetValue("farSideCenters", out var farSide))
|
||||||
@@ -329,24 +347,7 @@ public sealed class ClampSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void Save(string path, int maxRetries = 3)
|
public void Save(string path, int maxRetries = 3)
|
||||||
{
|
{
|
||||||
var json = ToJson();
|
SettingsFile.Write(path, ToJson(), maxRetries);
|
||||||
|
|
||||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
|
||||||
if (!string.IsNullOrEmpty(directory))
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
|
|
||||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.WriteAllText(path, json);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (IOException) when (attempt < maxRetries - 1)
|
|
||||||
{
|
|
||||||
Thread.Sleep(100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The settings as <see cref="Save"/> writes them, for the shared copy.</summary>
|
/// <summary>The settings as <see cref="Save"/> writes them, for the shared copy.</summary>
|
||||||
@@ -362,6 +363,8 @@ public sealed class ClampSettings
|
|||||||
["removalMinutes"] = RemovalMinutes,
|
["removalMinutes"] = RemovalMinutes,
|
||||||
["centers"] = Centers,
|
["centers"] = Centers,
|
||||||
};
|
};
|
||||||
|
if (Clearance is double clearance)
|
||||||
|
dto["clearance"] = clearance;
|
||||||
// Other edges are written only when used, so origin-side-only files stay unchanged.
|
// Other edges are written only when used, so origin-side-only files stay unchanged.
|
||||||
if (FarSideCenters.Count > 0)
|
if (FarSideCenters.Count > 0)
|
||||||
dto["farSideCenters"] = FarSideCenters;
|
dto["farSideCenters"] = FarSideCenters;
|
||||||
@@ -392,35 +395,4 @@ public sealed class ClampSettings
|
|||||||
&& double.IsFinite(value)
|
&& double.IsFinite(value)
|
||||||
&& double.IsFinite(value * MillimetersPerInch);
|
&& double.IsFinite(value * MillimetersPerInch);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Accepts only a defined unit name (case-insensitive); Enum.TryParse would also accept
|
|
||||||
/// numeric strings and comma-joined names.
|
|
||||||
/// </summary>
|
|
||||||
private static bool TryParseUnits(JsonElement element, out Units units)
|
|
||||||
{
|
|
||||||
units = default;
|
|
||||||
if (element.ValueKind != JsonValueKind.String)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var text = element.GetString();
|
|
||||||
foreach (var candidate in Enum.GetValues<Units>())
|
|
||||||
{
|
|
||||||
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
units = candidate;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsUnreadableFile(Exception ex) =>
|
|
||||||
ex
|
|
||||||
is JsonException
|
|
||||||
or IOException
|
|
||||||
or UnauthorizedAccessException
|
|
||||||
or NotSupportedException
|
|
||||||
or System.Security.SecurityException;
|
|
||||||
}
|
}
|
||||||
Loaded 100 of 825 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user