mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-15 17:05:33 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
205d066762 | ||
|
|
391e57a479 | ||
|
|
41ad75d385 | ||
|
|
122f9a9e47 |
No files matched your search
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
name: planner
|
|
||||||
description: Designs an implementation plan for an OpenNest change before any code is written. Use for features, behavior changes or refactors that touch more than a file or two. Read-only; returns a plan, never edits.
|
|
||||||
tools: Read, Grep, Glob, Bash, WebFetch
|
|
||||||
---
|
|
||||||
You plan changes to OpenNest, a .NET 8 CNC-nesting application. You do not edit
|
|
||||||
files; you return a plan the main session will implement.
|
|
||||||
|
|
||||||
Before planning:
|
|
||||||
- Read `AGENTS.md`. Its project map, boundaries and geometry/ownership safeguards
|
|
||||||
are binding constraints on the plan.
|
|
||||||
- Read every `docs/` contract that `AGENTS.md` links for the area you touch, and
|
|
||||||
plan within it. If the change must alter a contract, say so and list the doc
|
|
||||||
edits.
|
|
||||||
- Find the existing code paths, tests and helpers the change should reuse.
|
|
||||||
Bash is for read-only inspection only (`git log`, `git grep`, `dotnet` listing
|
|
||||||
commands); never build, test, write or change git state.
|
|
||||||
|
|
||||||
Return, concisely:
|
|
||||||
1. **Goal and approach**: the design in a few sentences, and why over the main
|
|
||||||
alternative if there is a real one.
|
|
||||||
2. **Steps**: ordered, each naming the files/types it changes. Mark steps that
|
|
||||||
are independent of each other.
|
|
||||||
3. **Boundaries**: which project owns each piece (Core/Engine/IO/Desktop/
|
|
||||||
WinForms/Server), and any boundary the change comes near (IO depends on Core
|
|
||||||
only; Desktop never references WinForms, System.Drawing.Common or Avalonia;
|
|
||||||
Server stays off Engine/IO; WinForms tests stay out of OpenNest.Tests).
|
|
||||||
4. **Safeguards at risk**: the specific `AGENTS.md` rules the change could break
|
|
||||||
(tolerances, `PortableMath`, `SpecialLayers.IsMaterial`, undo recording,
|
|
||||||
irregular stock, cutoffs, common lines, posting checks) and how the plan
|
|
||||||
keeps them.
|
|
||||||
5. **Verification**: the exact `dotnet test` projects and filters to run; new
|
|
||||||
tests to add; whether the problem-nest corpus applies and with which
|
|
||||||
`OPENNEST_PROBLEM_NESTS_CHECKS` areas (Plan Cutting and engine changes only,
|
|
||||||
never fills, UI, posts or file formats; only the maintainer has it, so when
|
|
||||||
`OPENNEST_PROBLEM_NESTS_SERVER` is unset mark it a maintainer run before
|
|
||||||
merge); whether a nesting engine benchmark
|
|
||||||
applies; whether user help in `ajisaacs/opennest-help` needs updating.
|
|
||||||
6. **Open questions**: decisions that are genuinely the user's.
|
|
||||||
|
|
||||||
Cite code as `path:line`. Keep the plan to what was asked: no speculative
|
|
||||||
extras. Plans are not committed; if one should be saved, it goes under
|
|
||||||
`.hermes/plans/`.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
name: reviewer
|
|
||||||
description: Independently reviews an OpenNest diff for correctness bugs and broken project safeguards. Use after implementing a change, before merging. Give it the branch or commit range, not your conclusions. Read-only; reports findings, never fixes them.
|
|
||||||
tools: Read, Grep, Glob, Bash
|
|
||||||
---
|
|
||||||
You review changes to OpenNest, a .NET 8 CNC-nesting application, with fresh
|
|
||||||
eyes. You did not write this code; do not assume it is right.
|
|
||||||
|
|
||||||
Scope: the diff you are given (default `git diff master...HEAD` plus uncommitted
|
|
||||||
changes). Read surrounding code as needed to judge it. Bash is for inspection
|
|
||||||
(`git diff`, `git log`, `git grep`) and, when useful, building or running the
|
|
||||||
relevant tests; never edit files, commit, stash or switch branches. On Linux,
|
|
||||||
build Windows projects with `-p:EnableWindowsTargeting=true` and say that this
|
|
||||||
is not Windows runtime verification.
|
|
||||||
|
|
||||||
Check, in order:
|
|
||||||
1. **Correctness**: logic errors, wrong edge cases, null/empty handling,
|
|
||||||
cancellation and threading, broken invariants, units (angles are radians).
|
|
||||||
2. **AGENTS.md safeguards**: read `AGENTS.md` and the `docs/` contracts it links
|
|
||||||
for the touched area, then check the diff against them. Common misses:
|
|
||||||
geometry compared without `Tolerance.Epsilon`; `System.Math` trig in
|
|
||||||
Core/Engine/IO/Data instead of `PortableMath`; marks treated as material
|
|
||||||
instead of `SpecialLayers.IsMaterial`; desktop nest edits without
|
|
||||||
`BeginChange`/`RecordChange`; double quantity accounting around
|
|
||||||
`ObservableList`; anything placed on an irregular (`SheetOutline`) plate
|
|
||||||
outside the Irregular fill; cutoffs applied as preview parts; posting
|
|
||||||
without checks or with a persisted warning bypass; loosened tolerances that
|
|
||||||
hide failures; caches shared across calls or threads.
|
|
||||||
3. **Boundaries**: project references (IO -> Core only; Desktop free of
|
|
||||||
WinForms/System.Drawing.Common/Avalonia; Server off Engine/IO; no WinForms in
|
|
||||||
OpenNest.Tests); plug-in engine projects kept out of the solution.
|
|
||||||
4. **Tests and docs**: behavior changes have tests that would fail without the
|
|
||||||
change; affected `docs/`, `AGENTS.md` and user help are updated; no design
|
|
||||||
specs, plans, progress notes or benchmark reports are committed; no vendor
|
|
||||||
manual text; no competitor product names.
|
|
||||||
5. **Verification owed**: whether the change needs the problem-nest corpus
|
|
||||||
(Plan Cutting and engine changes, limited by area) or an engine benchmark,
|
|
||||||
and whether that was evidently run. Only the maintainer has the corpus: when
|
|
||||||
`OPENNEST_PROBLEM_NESTS_SERVER` is unset, list the needed areas as a
|
|
||||||
maintainer run before merge, not as a finding against the change.
|
|
||||||
|
|
||||||
Report only real problems, most severe first. For each: `path:line`, what is
|
|
||||||
wrong, a concrete input or state that produces the failure, and how sure you are
|
|
||||||
(confirmed by reading/running vs. plausible). Skip style nits that
|
|
||||||
`dotnet format` or `.editorconfig` would settle. If nothing survives scrutiny,
|
|
||||||
say so plainly; do not pad the list.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"isRoot": true,
|
|
||||||
"tools": {
|
|
||||||
"vpk": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"commands": [
|
|
||||||
"vpk"
|
|
||||||
],
|
|
||||||
"rollForward": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,6 @@
|
|||||||
!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,6 +20,3 @@
|
|||||||
*.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,11 +19,9 @@ 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:
|
||||||
@@ -32,7 +30,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x'
|
dotnet-version: '8.0.x'
|
||||||
- name: Run ${{ matrix.project }}
|
- name: Run ${{ matrix.project }}
|
||||||
run: dotnet test "${{ matrix.project }}/${{ matrix.project }}.csproj" -c Release
|
run: dotnet test "${{ matrix.project }}/${{ matrix.project }}.csproj"
|
||||||
|
|
||||||
synthetic-nests:
|
synthetic-nests:
|
||||||
name: Six synthetic Irregular nests
|
name: Six synthetic Irregular nests
|
||||||
@@ -72,7 +70,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 also run here, beside the desktop build.
|
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop.
|
||||||
runs-on: windows-2022
|
runs-on: windows-2022
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
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,10 +6,6 @@ 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'
|
||||||
@@ -25,10 +21,6 @@ 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'
|
||||||
@@ -122,7 +114,8 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
if: >-
|
if: >-
|
||||||
github.repository == 'ajisaacs/OpenNest' &&
|
github.repository == 'ajisaacs/OpenNest' &&
|
||||||
github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'
|
((github.event_name == 'release' && github.event.release.prerelease == false) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
# mini dump in TestResults, instead of holding the runner until the
|
# mini dump in TestResults, instead of holding the runner until the
|
||||||
# job timeout.
|
# job timeout.
|
||||||
run: |
|
run: |
|
||||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Desktop.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
foreach ($project in @('OpenNest.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,11 +96,6 @@ 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
|
||||||
|
|||||||
+1
-6
@@ -208,9 +208,7 @@ FakesAssemblies/
|
|||||||
*.db-journal
|
*.db-journal
|
||||||
|
|
||||||
# Local agent state and temporary planning/progress documents
|
# Local agent state and temporary planning/progress documents
|
||||||
# (shared Claude Code agent definitions stay tracked)
|
.claude/
|
||||||
.claude/*
|
|
||||||
!.claude/agents/
|
|
||||||
/.hermes/plans/
|
/.hermes/plans/
|
||||||
/.hermes/progress/
|
/.hermes/progress/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
@@ -227,6 +225,3 @@ OpenNest.Tests/test-config.json
|
|||||||
# Vendor programming manuals: keep reference copies outside source control.
|
# Vendor programming manuals: keep reference copies outside source control.
|
||||||
CINCINNATI LASER PROGRAMMING MANUAL.pdf
|
CINCINNATI LASER PROGRAMMING MANUAL.pdf
|
||||||
TF5200_programming_manual_en.pdf
|
TF5200_programming_manual_en.pdf
|
||||||
|
|
||||||
# Python bytecode
|
|
||||||
__pycache__/
|
|
||||||
@@ -7,7 +7,7 @@ OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform librari
|
|||||||
|
|
||||||
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
|
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
|
||||||
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
|
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
|
||||||
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`. User guides and their screenshots live in the separate `ajisaacs/opennest-help` repo: update or recapture them when a UI change alters a documented screen, and never rename a `HelpTopics` ID.
|
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`.
|
||||||
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
|
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
|
||||||
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
|
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
|
||||||
|
|
||||||
@@ -22,67 +22,48 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
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).
|
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`.
|
||||||
|
|
||||||
Plan Cutting and nesting-engine changes need the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`), which holds every nest that ever caused a problem; engine changes run only its `auto-nest` area. Never run it for fills, UI, posts or file formats. Limit it to the areas the change touches with `OPENNEST_PROBLEM_NESTS_CHECKS` (table in that doc); `part-order` and `entries` plan every nest. No plate may rank below its baseline, and none may join or climb the near-budget list compared with master's run. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
|
|
||||||
|
|
||||||
Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
|
Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
|
||||||
|
|
||||||
## Project map and boundaries
|
## Project map and boundaries
|
||||||
|
|
||||||
- `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter, and call `PortableMath` (not `System.Math`) for trig in Core/Engine/IO/Data so layouts match across platforms. `OpenNest.Math` and `OpenNest.Geometry` are Core's bottom layers (Math uses nothing else in OpenNest; Geometry uses Math plus the root types `OffsetSide`, `RotationType`, `PushDirection`, `RelativePosition`, `PerfCounters`, `SpecialLayers`); `CoreLayeringTests` enforces it.
|
- `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 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.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
|
||||||
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). 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 each desktop app's output `Posts/` directory (WinForms and Avalonia). 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`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory.
|
||||||
- `OpenNest.Desktop` (net8.0): toolkit-neutral desktop logic shared by the WinForms app and `OpenNest.Avalonia`, the cross-platform desktop in progress. It must not reference WinForms/WindowsDesktop, `System.Drawing.Common` or Avalonia (`OpenNest.Desktop.Tests` guards this, including transitive packages); put toolkit-specific rendering, dialogs and settings behind adapters in the front ends.
|
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
|
||||||
- `OpenNest.Server`: nest storage HTTP service, kept off Engine and IO. `OpenNest.Server.Worker`: the server nesting worker, shipped in the same image.
|
|
||||||
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection and the Python sketch-search prototype (`sketch-search/README.md`). Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
|
|
||||||
|
|
||||||
## Geometry and ownership safeguards
|
## Geometry and ownership safeguards
|
||||||
|
|
||||||
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
|
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
|
||||||
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check, the runner's candidate validation and the pre-post part-spacing review all find spacing candidates with Core's `SpacingOutline` and confirm them on `NativeContours` (the outline circumscribes arcs by more than the slack), so they agree; `NestLayoutCheck.Clears` skips the confirmation and stays stricter for engine certification. Do not loosen tolerances to hide failures.
|
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check and the pre-post part-spacing review share Core's `SpacingOutline` for candidates; the review then confirms each one on finely flattened native contours, so it never warns where Auto Nest's check would pass. Do not loosen tolerances to hide failures.
|
||||||
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
|
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
|
||||||
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
|
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
|
||||||
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
|
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
|
||||||
- `Plate.Outline`/`NestPlateStock.Outline` (`SheetOutline`) make a sheet irregular; `Size` stays its bounding box. Only Irregular fills it (`PackBoundary` eroded by the largest edge spacing); every other engine and fill strategy must place nothing on it, validation must reject parts outside it or over its obstacles, and automatic cut-offs refuse it. See [irregular stock](docs/irregular-stock.md).
|
|
||||||
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
|
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
|
||||||
- A cutoff starts only at the origin-side edge: stock can be oversize, so the far edge is unknown. Only a head-locked overcut (`CutOff.HeadLockOvercut`, `HeadHeightLock` markers) may start its far-edge segment there; posts refuse it without lock codes.
|
|
||||||
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
|
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
|
||||||
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. 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 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
|
- [Lead-in placement](docs/geometry/lead-in-placement.md) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
|
||||||
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
|
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
|
||||||
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
|
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
|
||||||
- [Dimensions](docs/dimensions.md): CAD Converter annotations that store placement only, are measured from the cut geometry when shown and never affect the program
|
|
||||||
- [Common-line cutting](docs/common-line-cutting.md): parts one kerf apart in `Plate.CommonLines` share an edge; spans are derived, never stored, and spacing checks hold group members to the kerf. Plan Cutting cuts each shared span once (the owner, cut first, starts its compensated outside on the common line; partners cut only their remainders) unless the plate is set to cut them twice; only posts implementing `ICommonLinePostProcessor` (CI Fiber) may write it, others refuse. Machine trial pending. Auto Nest and Fill Area pair `AllowCommonLine` drawings at the nest's `CommonLineUsage` (Off by default, byte-identical to no common lines); `NestLayoutCheck` exempts only explicit result groups
|
|
||||||
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, Plan Cutting's tab placement for parts the grid cannot hold level, and its opt-in slicing of slugs 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, tabs, pierce and cut-off settings per material and thickness; the Cutting Parameters dialog saves to that thickness
|
|
||||||
- [Shared settings](docs/shared-settings.md): in Database mode machines, post settings, clamps and nest defaults come from the server once it holds them; offline uses the last copy read, read-only
|
|
||||||
- [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): 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`.
|
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
|
||||||
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
|
|
||||||
- [Desktop help](docs/desktop-help.md): WebView2 help window, topic IDs and the pinned help release both packaging scripts install.
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
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,6 +7,5 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<Application xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
x:Class="OpenNest.Avalonia.App"
|
|
||||||
RequestedThemeVariant="Light">
|
|
||||||
<Application.Styles>
|
|
||||||
<FluentTheme />
|
|
||||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
|
||||||
</Application.Styles>
|
|
||||||
</Application>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
|
||||||
{
|
|
||||||
public partial class App : Application
|
|
||||||
{
|
|
||||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
|
||||||
{
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
|
||||||
{
|
|
||||||
var window = new MainWindow();
|
|
||||||
desktop.MainWindow = window;
|
|
||||||
var args = desktop.Args ?? System.Array.Empty<string>();
|
|
||||||
// Each nest path before the first --option opens in its own tab; the last is shown.
|
|
||||||
foreach (var path in args)
|
|
||||||
{
|
|
||||||
if (path.StartsWith("--"))
|
|
||||||
break;
|
|
||||||
window.OpenNest(path);
|
|
||||||
}
|
|
||||||
// --auto-nest: run Auto Nest on the opened nest (with --snapshot, before rendering).
|
|
||||||
if (System.Array.IndexOf(args, "--auto-nest") >= 0)
|
|
||||||
window.AutoNestOnOpen();
|
|
||||||
// --snapshot <png> [plate]: render the window to an image (status line in <png>.txt) and exit.
|
|
||||||
var snapshot = System.Array.IndexOf(args, "--snapshot");
|
|
||||||
if (snapshot >= 0 && snapshot + 1 < args.Length)
|
|
||||||
window.SaveSnapshotAndClose(args[snapshot + 1],
|
|
||||||
snapshot + 2 < args.Length && int.TryParse(args[snapshot + 2], out var plate) ? plate : 1);
|
|
||||||
else
|
|
||||||
{
|
|
||||||
window.RememberPlacement();
|
|
||||||
window.RunStartupOnOpen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Layout;
|
|
||||||
using Avalonia.Media;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A message with a row of buttons ending in Cancel (OK for a plain message). That last
|
|
||||||
/// button is the default and Esc, so a stray Enter never discards work, and closing the
|
|
||||||
/// dialog counts as it.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class ConfirmDialog : Window
|
|
||||||
{
|
|
||||||
private ConfirmDialog(string title, string message, string[] choices, string cancelText)
|
|
||||||
{
|
|
||||||
Title = title;
|
|
||||||
Width = 460;
|
|
||||||
SizeToContent = SizeToContent.Height;
|
|
||||||
CanResize = false;
|
|
||||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
|
||||||
|
|
||||||
var buttons = new StackPanel
|
|
||||||
{
|
|
||||||
Orientation = Orientation.Horizontal,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Right,
|
|
||||||
Spacing = 8,
|
|
||||||
};
|
|
||||||
for (var i = 0; i < choices.Length; i++)
|
|
||||||
{
|
|
||||||
var index = i;
|
|
||||||
var choice = new Button { Content = choices[i] };
|
|
||||||
choice.Click += (_, _) => Close(index);
|
|
||||||
buttons.Children.Add(choice);
|
|
||||||
}
|
|
||||||
var cancel = new Button { Content = cancelText, IsDefault = true, IsCancel = true };
|
|
||||||
cancel.Click += (_, _) => Close(null);
|
|
||||||
buttons.Children.Add(cancel);
|
|
||||||
|
|
||||||
var panel = new StackPanel { Margin = new Thickness(18), Spacing = 18 };
|
|
||||||
// A long message (many load warnings) scrolls rather than pushing the buttons away.
|
|
||||||
panel.Children.Add(new ScrollViewer
|
|
||||||
{
|
|
||||||
MaxHeight = 420,
|
|
||||||
Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
|
||||||
});
|
|
||||||
panel.Children.Add(buttons);
|
|
||||||
Content = panel;
|
|
||||||
Opened += (_, _) => cancel.Focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>True when the user chose <paramref name="confirmText"/>.</summary>
|
|
||||||
public static async Task<bool> AskAsync(
|
|
||||||
Window owner, string title, string message, string confirmText) =>
|
|
||||||
await ChooseAsync(owner, title, message, confirmText) == 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The index of the choice the user clicked, or -1 for Cancel (also Esc, Enter and
|
|
||||||
/// closing the dialog).
|
|
||||||
/// </summary>
|
|
||||||
public static async Task<int> ChooseAsync(
|
|
||||||
Window owner, string title, string message, params string[] choices) =>
|
|
||||||
await new ConfirmDialog(title, message, choices, "Cancel")
|
|
||||||
.ShowDialog<int?>(owner) ?? -1;
|
|
||||||
|
|
||||||
/// <summary>A message with only an OK button.</summary>
|
|
||||||
public static Task ShowAsync(Window owner, string title, string message) =>
|
|
||||||
new ConfirmDialog(title, message, System.Array.Empty<string>(), "OK")
|
|
||||||
.ShowDialog<int?>(owner);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,103 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using OpenNest.Data;
|
|
||||||
using OpenNest.Desktop;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The machines, nest defaults and post processors this app works with, from the shared
|
|
||||||
/// desktop library: in Database mode (storage.json, shared with the WinForms app) the nest
|
|
||||||
/// server's copy once it holds one, otherwise this PC's files, with the user settings both
|
|
||||||
/// apps keep in user-settings.json.
|
|
||||||
/// </summary>
|
|
||||||
internal static class DesktopSettings
|
|
||||||
{
|
|
||||||
/// <summary>The storage mode and nest server from storage.json; Tools > Options can change the server.</summary>
|
|
||||||
private static NestStorageSettings storage = NestStorageSettings.Load(NestStorageSettings.DefaultPath);
|
|
||||||
|
|
||||||
public static SharedSettingsSource Shared { get; } = new(() => storage);
|
|
||||||
|
|
||||||
public static MachineSettingsSource Machines { get; } =
|
|
||||||
new(Shared, new MachineUserSettingsFile(UserSettingsFile.DefaultPath));
|
|
||||||
|
|
||||||
private static RemoteNestRepository repository;
|
|
||||||
private static string repositoryUrl;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// File > New nests, numbered by the nest server in Database mode and otherwise by the
|
|
||||||
/// counter both apps keep in user-settings.json.
|
|
||||||
/// </summary>
|
|
||||||
public static NewNestSource NewNests { get; } = new(
|
|
||||||
Machines,
|
|
||||||
new NewNestUserSettingsFile(UserSettingsFile.DefaultPath),
|
|
||||||
() => storage.IsDatabaseMode ? Repository(storage.ServerUrl) : null);
|
|
||||||
|
|
||||||
/// <summary>The nest server URL saved in storage.json (blank when none).</summary>
|
|
||||||
public static string ServerUrl => storage.ServerUrl;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Saves <paramref name="serverUrl"/> as the nest server in storage.json, keeping the
|
|
||||||
/// storage mode, as the WinForms Options form does; the next request uses it.
|
|
||||||
/// </summary>
|
|
||||||
public static void SaveServerUrl(string serverUrl) =>
|
|
||||||
storage = NestServerUrlOption.SaveToFile(NestStorageSettings.DefaultPath, serverUrl);
|
|
||||||
|
|
||||||
// One client per server, recreated when the URL changes.
|
|
||||||
private static RemoteNestRepository Repository(string url)
|
|
||||||
{
|
|
||||||
if (repository == null || repositoryUrl != url)
|
|
||||||
{
|
|
||||||
repository?.Dispose();
|
|
||||||
repository = new RemoteNestRepository(url);
|
|
||||||
repositoryUrl = url;
|
|
||||||
}
|
|
||||||
return repository;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The post processors loaded from Posts/ beside the app, by load order.</summary>
|
|
||||||
public static IReadOnlyList<IPostProcessor> PostProcessors { get; private set; } = [];
|
|
||||||
|
|
||||||
/// <summary>The readable machine configurations, once <see cref="LoadMachinesAsync"/> has run.</summary>
|
|
||||||
public static IReadOnlyList<MachineConfig> MachineConfigs { get; private set; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loads the post processors in <paramref name="directory"/> (each DLL on its own, so one
|
|
||||||
/// that fails does not stop the others) and registers their materials. Returns the
|
|
||||||
/// failures to show, or null when every post loaded.
|
|
||||||
/// </summary>
|
|
||||||
public static string LoadPosts(string directory)
|
|
||||||
{
|
|
||||||
var result = PostProcessorLoader.Load(directory);
|
|
||||||
PostProcessors = result.PostProcessors;
|
|
||||||
return result.Failures.Count == 0
|
|
||||||
? null
|
|
||||||
: "These post processors could not be loaded: " + string.Join("; ", result.Failures);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the machines off the UI thread (the nest server can take seconds to answer), then
|
|
||||||
/// copies the legacy clamps onto the default or only machine, as the WinForms app does at
|
|
||||||
/// startup (with several machines and no default it waits for the WinForms app to ask).
|
|
||||||
/// Returns what to tell the user: the machines' source when the server could not be
|
|
||||||
/// reached, or why the clamps were not copied; null when there is nothing to say.
|
|
||||||
/// </summary>
|
|
||||||
public static Task<string> LoadMachinesAsync() => Task.Run(() =>
|
|
||||||
{
|
|
||||||
MachineConfigs = Machines.LoadMachines();
|
|
||||||
var clamps = Machines.MigrateLegacyClamps(askMachine: null);
|
|
||||||
var offline = Shared.TakeOfflineWarning();
|
|
||||||
var notices = new[] { offline, clamps }
|
|
||||||
.Where(text => !string.IsNullOrEmpty(text))
|
|
||||||
.Select(OneLine)
|
|
||||||
.ToList();
|
|
||||||
return notices.Count == 0 ? null : string.Join(" ", notices);
|
|
||||||
});
|
|
||||||
|
|
||||||
private static string OneLine(string text) =>
|
|
||||||
string.Join(" ", text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Avalonia.Platform.Storage;
|
|
||||||
using OpenNest.Avalonia.Rendering;
|
|
||||||
using OpenNest.Data;
|
|
||||||
using OpenNest.Desktop;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// File > Export Plate, Export All Plates and Nest Report, and Plate > View in CAD, as
|
|
||||||
/// the WinForms app has them: the same suggested file names and writers
|
|
||||||
/// (<see cref="PlateExport"/>, <see cref="NestReportExport"/>). A viewed report opens in the
|
|
||||||
/// system's PDF viewer, which saves and prints it; View in CAD opens the plate's DXF in the
|
|
||||||
/// program the system associates with .dxf.
|
|
||||||
/// </summary>
|
|
||||||
public partial class MainWindow
|
|
||||||
{
|
|
||||||
private static readonly FilePickerFileType DxfFiles =
|
|
||||||
new("DXF file") { Patterns = new[] { "*.dxf" } };
|
|
||||||
|
|
||||||
private static readonly FilePickerFileType ImageFiles =
|
|
||||||
new("Image as displayed") { Patterns = new[] { "*.png", "*.jpg", "*.jpeg" } };
|
|
||||||
|
|
||||||
private static readonly FilePickerFileType PlacementFiles =
|
|
||||||
new("Locations and rotations") { Patterns = new[] { "*.txt" } };
|
|
||||||
|
|
||||||
private static readonly FilePickerFileType SvgFiles =
|
|
||||||
new("SVG file") { Patterns = new[] { "*.svg" } };
|
|
||||||
|
|
||||||
private void UpdateExportCommands(bool hasNest)
|
|
||||||
{
|
|
||||||
ExportPlateMenuItem.IsEnabled = hasNest;
|
|
||||||
ExportAllPlatesMenuItem.IsEnabled = hasNest;
|
|
||||||
ViewNestReportMenuItem.IsEnabled = hasNest;
|
|
||||||
ViewInCadMenuItem.IsEnabled = hasNest;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnExportPlate(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var view = ActiveView;
|
|
||||||
if (view == null || !CanExport())
|
|
||||||
return;
|
|
||||||
writingFile = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ExportPlateAsync(view, view.PlateIndex);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
writingFile = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// As WinForms: Export Plate once per plate, first to last, each plate shown while its
|
|
||||||
/// file is chosen; cancelling or a failure stops the rest.
|
|
||||||
/// </summary>
|
|
||||||
private async void OnExportAllPlates(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var view = ActiveView;
|
|
||||||
if (view == null || !CanExport())
|
|
||||||
return;
|
|
||||||
writingFile = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var count = view.PlateCount;
|
|
||||||
var exported = await PlateExport.ExportAllAsync(count, i => ExportPlateAsync(view, i));
|
|
||||||
if (exported < count)
|
|
||||||
ShowNotice($"Exported {exported} of {count} plates of {view.Tab.Name}");
|
|
||||||
else
|
|
||||||
ShowNotice($"Exported all {count} plates of {view.Tab.Name}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
writingFile = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One export at a time; nesting does not block it, as its result is kept on this thread.
|
|
||||||
private bool CanExport()
|
|
||||||
{
|
|
||||||
if (!writingFile)
|
|
||||||
return true;
|
|
||||||
ShowNotice("A nest is already being saved or exported. Finish that before exporting.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shows plate <paramref name="index"/>, asks for its file and writes it in the format the
|
|
||||||
/// file's extension names. True when it was written.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<bool> ExportPlateAsync(NestView view, int index)
|
|
||||||
{
|
|
||||||
if (index < 0 || index >= view.PlateCount)
|
|
||||||
return false;
|
|
||||||
view.ShowPlate(index);
|
|
||||||
var plate = view.PlateView.Plate;
|
|
||||||
if (plate == null)
|
|
||||||
return false;
|
|
||||||
var nest = view.Tab.Document.Nest;
|
|
||||||
var number = index + 1;
|
|
||||||
var target = await PickPlateExportFileAsync(view.Tab, PlateExport.SuggestedFileName(nest.Name, number));
|
|
||||||
if (target == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var (path, format) = target.Value;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// The image is of the plate as shown, so show it again if another was picked meanwhile.
|
|
||||||
if (format == PlateExportFormat.Image && !ReferenceEquals(view.PlateView.Plate, plate))
|
|
||||||
view.ShowPlate(nest.Plates.IndexOf(plate));
|
|
||||||
PlateExport.Write(plate, nest.Units, path, format, file => ViewImage.Save(view.PlateView, file));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await ConfirmDialog.ShowAsync(this, "Export Plate",
|
|
||||||
$"Could not export plate to '{path}': {ex.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
ShowNotice($"Exported plate {number} of {view.Tab.Name} to {path}");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The file to export a plate to and its format, or null when cancelled or refused.
|
|
||||||
private async Task<(string Path, PlateExportFormat Format)?> PickPlateExportFileAsync(
|
|
||||||
NestTab tab, string suggestedFileName)
|
|
||||||
{
|
|
||||||
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
|
||||||
{
|
|
||||||
Title = "Export Plate",
|
|
||||||
SuggestedFileName = suggestedFileName,
|
|
||||||
DefaultExtension = "dxf",
|
|
||||||
ShowOverwritePrompt = true,
|
|
||||||
FileTypeChoices = new[] { DxfFiles, ImageFiles, PlacementFiles, SvgFiles },
|
|
||||||
SuggestedStartLocation = await NestFolderAsync(tab),
|
|
||||||
});
|
|
||||||
if (file == null)
|
|
||||||
return null;
|
|
||||||
var path = file.TryGetLocalPath();
|
|
||||||
if (path == null)
|
|
||||||
{
|
|
||||||
ShowNotice("Plates can only be exported to a folder on this computer.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A name typed without an extension is the dialog's default type, DXF.
|
|
||||||
if (Path.GetExtension(path).Length == 0)
|
|
||||||
{
|
|
||||||
path += ".dxf";
|
|
||||||
if (File.Exists(path) && !await ConfirmDialog.AskAsync(this, "Export Plate",
|
|
||||||
$"{Path.GetFileName(path)} already exists. Replace it?", "Replace"))
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (PlateExport.FromPath(path) is not { } format)
|
|
||||||
{
|
|
||||||
await ConfirmDialog.ShowAsync(this, "Export Plate",
|
|
||||||
$"A plate cannot be exported as '{Path.GetFileName(path)}'. Choose a file name "
|
|
||||||
+ "ending in .dxf, .png, .jpg, .txt or .svg.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (path, format);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Writes the shown plate as a DXF in the temp folder and opens it in the system's CAD program.</summary>
|
|
||||||
private async void OnViewInCad(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var view = ActiveView;
|
|
||||||
var plate = view?.PlateView.Plate;
|
|
||||||
if (plate == null)
|
|
||||||
return;
|
|
||||||
var nest = view.Tab.Document.Nest;
|
|
||||||
var path = PlateExport.ExternalCadPath(nest.Name, view.PlateIndex + 1);
|
|
||||||
Task<string> opened;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
PlateExport.Write(plate, nest.Units, path, PlateExportFormat.Dxf, null);
|
|
||||||
opened = ExternalFileLauncher.OpenAsync(path);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await ConfirmDialog.ShowAsync(this, "View in CAD", $"Could not open '{path}' in CAD: {ex.Message}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ShowNotice($"Opening {path}");
|
|
||||||
await ReportOpenFailureAsync(opened, "View in CAD", $"Could not open '{path}' in CAD");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes the nest report to a private temporary folder and opens it in the system's PDF
|
|
||||||
/// viewer, which saves and prints it. Viewed reports older than a day are removed.
|
|
||||||
/// </summary>
|
|
||||||
private async void OnViewNestReport(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var tab = tabs.Active;
|
|
||||||
if (tab == null || !TryReportFileName(tab, out var fileName))
|
|
||||||
return;
|
|
||||||
if (!CanWrite(tab, "creating the report"))
|
|
||||||
return;
|
|
||||||
writingFile = true;
|
|
||||||
string path = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await MachinesLoaded;
|
|
||||||
// Auto Nest may have started on it while the machines were read.
|
|
||||||
if (IsNesting(tab, "creating the report"))
|
|
||||||
return;
|
|
||||||
path = NestReportExport.CreateViewPath(fileName);
|
|
||||||
await WriteReportAsync(tab, path);
|
|
||||||
await Task.Run(() => NestReportExport.DeleteViewedReportsBefore(
|
|
||||||
DateTime.UtcNow - NestReportExport.ViewedReportLifetime));
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (IsReportFailure(ex))
|
|
||||||
{
|
|
||||||
if (path != null)
|
|
||||||
NestReportExport.DeleteReportFolder(path);
|
|
||||||
await ReportFailureAsync(tab, ex);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
writingFile = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Task<string> opened;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
opened = ExternalFileLauncher.OpenAsync(path);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await ConfirmDialog.ShowAsync(this, "Nest Report",
|
|
||||||
$"The nest report was written to '{path}', but it could not be opened: {ex.Message}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ShowNotice($"Opening the nest report for {tab.Name}");
|
|
||||||
await ReportOpenFailureAsync(opened, "Nest Report",
|
|
||||||
$"The nest report was written to '{path}', but it could not be opened");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The report's file name, or false (after saying why) when the nest's name cannot be one.
|
|
||||||
private bool TryReportFileName(NestTab tab, out string fileName)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
fileName = NestReportExport.ReportFileName(tab.Document.Nest);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
fileName = null;
|
|
||||||
_ = ConfirmDialog.ShowAsync(this, "Nest Report", $"Could not create the nest report: {ex.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Captures the report on this thread, so the nest is read as it stands and is left
|
|
||||||
/// unchanged, then writes the detached snapshot off it.
|
|
||||||
/// </summary>
|
|
||||||
private static Task WriteReportAsync(NestTab tab, string path)
|
|
||||||
{
|
|
||||||
var snapshot = NestReportExport.Capture(
|
|
||||||
tab.Document.Nest,
|
|
||||||
DateTimeOffset.Now,
|
|
||||||
DesktopSettings.MachineConfigs,
|
|
||||||
UserSettingsFile.Read(UserSettingsFile.DefaultPath));
|
|
||||||
return Task.Run(() => NestReportExport.Write(snapshot, path));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsReportFailure(Exception ex) =>
|
|
||||||
ex is InvalidOperationException
|
|
||||||
or NotSupportedException
|
|
||||||
or InvalidDataException
|
|
||||||
or IOException
|
|
||||||
or UnauthorizedAccessException
|
|
||||||
or ArgumentException;
|
|
||||||
|
|
||||||
private Task ReportFailureAsync(NestTab tab, Exception error) =>
|
|
||||||
ConfirmDialog.ShowAsync(this, "Nest Report",
|
|
||||||
$"Could not create the nest report for '{tab.Name}': {error.Message}");
|
|
||||||
|
|
||||||
// Says why the system could not open a file, once its opener has reported back.
|
|
||||||
private async Task ReportOpenFailureAsync(Task<string> opened, string title, string what)
|
|
||||||
{
|
|
||||||
string failure;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
failure = await opened;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
failure = ex.Message;
|
|
||||||
}
|
|
||||||
if (failure != null)
|
|
||||||
await ConfirmDialog.ShowAsync(this, title, $"{what}: {failure}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IStorageFolder> NestFolderAsync(NestTab tab)
|
|
||||||
{
|
|
||||||
var folder = tab.Path != null ? Path.GetDirectoryName(tab.Path) : null;
|
|
||||||
return folder != null ? await StorageProvider.TryGetFolderFromPathAsync(folder) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using Avalonia.VisualTree;
|
|
||||||
using OpenNest.Desktop;
|
|
||||||
using OpenNest.Undo;
|
|
||||||
using Visual = global::Avalonia.Visual;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Edit > Undo and Redo of the active nest, through its tab's shared
|
|
||||||
/// <see cref="NestUndoRecorder"/>: Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z unless a text box has
|
|
||||||
/// focus, the menu naming the next step, and nothing while Auto Nest works on the nest.
|
|
||||||
/// Switching tabs rebinds the menu to the active nest's history.
|
|
||||||
/// </summary>
|
|
||||||
public partial class MainWindow
|
|
||||||
{
|
|
||||||
// Each open tab's history handler, so closing the tab unhooks it.
|
|
||||||
private readonly System.Collections.Generic.Dictionary<NestTab, EventHandler> undoHandlers = new();
|
|
||||||
|
|
||||||
/// <summary>Follows <paramref name="tab"/>'s history: its unsaved dot and, while active, the Edit menu.</summary>
|
|
||||||
private void WatchHistory(NestTab tab)
|
|
||||||
{
|
|
||||||
EventHandler handler = (_, _) => OnHistoryChanged(tab);
|
|
||||||
undoHandlers[tab] = handler;
|
|
||||||
tab.Undo.Changed += handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UnwatchHistory(NestTab tab)
|
|
||||||
{
|
|
||||||
if (undoHandlers.Remove(tab, out var handler))
|
|
||||||
tab.Undo.Changed -= handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnHistoryChanged(NestTab tab)
|
|
||||||
{
|
|
||||||
RebuildTabStrip();
|
|
||||||
if (tab == tabs.Active)
|
|
||||||
UpdateUndoMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z for the active nest, as the WinForms main
|
|
||||||
/// window does. A focused text editor keeps them for its own typing.
|
|
||||||
/// </summary>
|
|
||||||
private bool ProcessUndoKeys(KeyEventArgs e)
|
|
||||||
{
|
|
||||||
var modifiers = e.KeyModifiers & ~KeyModifiers.Shift;
|
|
||||||
if (modifiers != KeyModifiers.Control || e.Key is not (Key.Z or Key.Y))
|
|
||||||
return false;
|
|
||||||
var shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
|
|
||||||
if (e.Key == Key.Y && shift)
|
|
||||||
return false;
|
|
||||||
if (tabs.Active == null || KeepsOwnUndo(FocusManager?.GetFocusedElement() as Visual))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (e.Key == Key.Z && !shift)
|
|
||||||
TryUndo(tabs.Active);
|
|
||||||
else
|
|
||||||
TryRedo(tabs.Active);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True for controls that undo their own typing: a text box, including the one inside a
|
|
||||||
/// numeric box, an editable combo box or a grid cell being edited.
|
|
||||||
/// </summary>
|
|
||||||
internal static bool KeepsOwnUndo(Visual focused)
|
|
||||||
{
|
|
||||||
for (var visual = focused; visual != null; visual = visual.GetVisualParent())
|
|
||||||
{
|
|
||||||
if (visual is TextBox)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not while Auto Nest works on the nest: its result is applied to the nest it started on.
|
|
||||||
private bool CanUndoOrRedo(NestTab tab) => tab != null && tab != nestingTab;
|
|
||||||
|
|
||||||
private bool TryUndo(NestTab tab)
|
|
||||||
{
|
|
||||||
if (!CanUndoOrRedo(tab) || !tab.Undo.CanUndo)
|
|
||||||
return false;
|
|
||||||
return Step(tab, "undo", tab.Undo.Undo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryRedo(NestTab tab)
|
|
||||||
{
|
|
||||||
if (!CanUndoOrRedo(tab) || !tab.Undo.CanRedo)
|
|
||||||
return false;
|
|
||||||
return Step(tab, "redo", tab.Undo.Redo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool Step(NestTab tab, string what, Action step)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
step();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// A copy that cannot be read back leaves the nest and the history as they were.
|
|
||||||
ShowNotice($"Could not {what} in {tab.Name}: {ex.Message}");
|
|
||||||
UpdateUndoMenu();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
UpdateUndoMenu();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnUndo(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (tabs.Active != null)
|
|
||||||
TryUndo(tabs.Active);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnRedo(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (tabs.Active != null)
|
|
||||||
TryRedo(tabs.Active);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Busy states have no change event of their own; check them whenever the menu opens.
|
|
||||||
private void OnEditMenuOpened(object sender, RoutedEventArgs e) => UpdateUndoMenu();
|
|
||||||
|
|
||||||
/// <summary>Shows the active nest's next Undo and Redo steps, enabled when they can run.</summary>
|
|
||||||
private void UpdateUndoMenu()
|
|
||||||
{
|
|
||||||
var tab = tabs.Active;
|
|
||||||
var available = CanUndoOrRedo(tab);
|
|
||||||
UndoMenuItem.IsEnabled = available && tab.Undo.CanUndo;
|
|
||||||
RedoMenuItem.IsEnabled = available && tab.Undo.CanRedo;
|
|
||||||
UndoMenuItem.Header = AccessKeyed(tab?.Undo.UndoText ?? "Undo");
|
|
||||||
RedoMenuItem.Header = AccessKeyed(tab?.Undo.RedoText ?? "Redo");
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Undo Rotate" as "_Undo Rotate": U and R stay the access keys, a label's own
|
|
||||||
// underscores are shown as typed.
|
|
||||||
private static string AccessKeyed(string text) => "_" + text.Replace("_", "__");
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
private DispatcherTimer unrecordedCheck;
|
|
||||||
|
|
||||||
// Every edit should open a change scope; one that does not folds into the next entry.
|
|
||||||
private void StartUnrecordedChangeCheck()
|
|
||||||
{
|
|
||||||
unrecordedCheck = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Background,
|
|
||||||
(_, _) =>
|
|
||||||
{
|
|
||||||
var tab = tabs.Active;
|
|
||||||
if (tab != null && tab != nestingTab && tab.Undo.TakeUnrecordedChangeWarning())
|
|
||||||
Debug.WriteLine($"Undo: '{tab.Name}' changed outside a change scope; the edit is not recorded yet.");
|
|
||||||
});
|
|
||||||
unrecordedCheck.Start();
|
|
||||||
Closed += (_, _) => unrecordedCheck.Stop();
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using OpenNest.Data;
|
|
||||||
using OpenNest.Desktop.Rendering;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// View > Draw Rapids, Pierce Points, Bounds, Offset, Cut Direction and Support: toggles of the
|
|
||||||
/// active nest's plate view, as each WinForms nest window keeps its own. Rapids and bounds are
|
|
||||||
/// read from the shared user settings when a nest opens and written back when it closes.
|
|
||||||
/// </summary>
|
|
||||||
public partial class MainWindow
|
|
||||||
{
|
|
||||||
private void RestoreViewToggles(NestView view) =>
|
|
||||||
view.PlateView.Options = snapshotMode
|
|
||||||
? new PlateViewOptions()
|
|
||||||
: PlateViewOptions.Read(UserSettingsFile.Read(UserSettingsFile.DefaultPath));
|
|
||||||
|
|
||||||
// A snapshot must not depend on the user's settings.
|
|
||||||
private void UseDefaultViewToggles()
|
|
||||||
{
|
|
||||||
foreach (var view in views.Values)
|
|
||||||
{
|
|
||||||
view.PlateView.Options = new PlateViewOptions();
|
|
||||||
view.PlateView.InvalidateVisual();
|
|
||||||
}
|
|
||||||
UpdateViewToggles();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SaveViewToggles(NestView view)
|
|
||||||
{
|
|
||||||
if (snapshotMode)
|
|
||||||
return;
|
|
||||||
UserSettingsFile.Update(UserSettingsFile.DefaultPath, view.PlateView.Options.WriteTo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// As the WinForms nest windows closing in turn: the last one's toggles are kept.
|
|
||||||
private void SaveAllViewToggles()
|
|
||||||
{
|
|
||||||
foreach (var tab in tabs.Tabs)
|
|
||||||
{
|
|
||||||
if (views.TryGetValue(tab, out var view))
|
|
||||||
SaveViewToggles(view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateViewToggles()
|
|
||||||
{
|
|
||||||
var options = ActiveView?.PlateView.Options;
|
|
||||||
foreach (var (item, value) in new (MenuItem, Func<PlateViewOptions, bool>)[]
|
|
||||||
{
|
|
||||||
(DrawRapidsMenuItem, o => o.DrawRapid),
|
|
||||||
(DrawPiercePointsMenuItem, o => o.DrawPiercePoints),
|
|
||||||
(DrawBoundsMenuItem, o => o.DrawBounds),
|
|
||||||
(DrawOffsetMenuItem, o => o.DrawOffset),
|
|
||||||
(DrawCutDirectionMenuItem, o => o.DrawCutDirection),
|
|
||||||
(DrawSupportMenuItem, o => o.DrawSupport),
|
|
||||||
})
|
|
||||||
{
|
|
||||||
item.IsEnabled = options != null;
|
|
||||||
item.IsChecked = options != null && value(options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Toggle(Action<PlateViewOptions> toggle)
|
|
||||||
{
|
|
||||||
if (ActiveView is { } view)
|
|
||||||
{
|
|
||||||
toggle(view.PlateView.Options);
|
|
||||||
view.PlateView.InvalidateVisual();
|
|
||||||
}
|
|
||||||
UpdateViewToggles();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnToggleDrawRapids(object sender, RoutedEventArgs e) => Toggle(o => o.DrawRapid = !o.DrawRapid);
|
|
||||||
|
|
||||||
private void OnToggleDrawPiercePoints(object sender, RoutedEventArgs e) =>
|
|
||||||
Toggle(o => o.DrawPiercePoints = !o.DrawPiercePoints);
|
|
||||||
|
|
||||||
private void OnToggleDrawBounds(object sender, RoutedEventArgs e) => Toggle(o => o.DrawBounds = !o.DrawBounds);
|
|
||||||
|
|
||||||
private void OnToggleDrawOffset(object sender, RoutedEventArgs e) => Toggle(o => o.DrawOffset = !o.DrawOffset);
|
|
||||||
|
|
||||||
private void OnToggleDrawCutDirection(object sender, RoutedEventArgs e) =>
|
|
||||||
Toggle(o => o.DrawCutDirection = !o.DrawCutDirection);
|
|
||||||
|
|
||||||
private void OnToggleDrawSupport(object sender, RoutedEventArgs e) =>
|
|
||||||
Toggle(o => o.DrawSupport = !o.DrawSupport);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
x:Class="OpenNest.Avalonia.MainWindow"
|
|
||||||
Title="OpenNest (Avalonia prototype)"
|
|
||||||
Width="1400" Height="860">
|
|
||||||
<DockPanel>
|
|
||||||
<Menu DockPanel.Dock="Top">
|
|
||||||
<MenuItem Header="_File">
|
|
||||||
<MenuItem x:Name="NewMenuItem" Header="_New" InputGesture="Ctrl+N" Click="OnNew" />
|
|
||||||
<MenuItem x:Name="OpenMenuItem" Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
|
|
||||||
<Separator />
|
|
||||||
<MenuItem x:Name="SaveMenuItem" Header="_Save" InputGesture="Ctrl+S" Click="OnSave" />
|
|
||||||
<MenuItem x:Name="SaveAsMenuItem" Header="Save _As..." Click="OnSaveAs" />
|
|
||||||
<MenuItem x:Name="ExportNestMenuItem" Header="Save _Copy as .nest File..." Click="OnExportNest" />
|
|
||||||
<Separator />
|
|
||||||
<MenuItem x:Name="ExportPlateMenuItem" Header="_Export Plate..." Click="OnExportPlate" />
|
|
||||||
<MenuItem x:Name="ExportAllPlatesMenuItem" Header="Export All _Plates..." Click="OnExportAllPlates" />
|
|
||||||
<Separator />
|
|
||||||
<MenuItem x:Name="ViewNestReportMenuItem" Header="Nest _Report..." Click="OnViewNestReport" />
|
|
||||||
<Separator />
|
|
||||||
<MenuItem x:Name="CloseMenuItem" Header="_Close" Click="OnCloseNest" />
|
|
||||||
<MenuItem x:Name="CloseAllMenuItem" Header="Close A_ll" Click="OnCloseAll" />
|
|
||||||
<Separator />
|
|
||||||
<MenuItem Header="E_xit" InputGesture="Ctrl+Q" Click="OnExit" />
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem x:Name="EditMenu" Header="_Edit" SubmenuOpened="OnEditMenuOpened">
|
|
||||||
<!-- Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z are routed in MainWindow.Undo.cs, so text boxes keep theirs. -->
|
|
||||||
<MenuItem x:Name="UndoMenuItem" Header="_Undo" InputGesture="Ctrl+Z" Click="OnUndo" />
|
|
||||||
<MenuItem x:Name="RedoMenuItem" Header="_Redo" InputGesture="Ctrl+Y" Click="OnRedo" />
|
|
||||||
<Separator />
|
|
||||||
<MenuItem x:Name="SelectAllMenuItem" Header="Select _All" InputGesture="Ctrl+A" Click="OnSelectAll" />
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem Header="_Nest">
|
|
||||||
<MenuItem x:Name="AutoNestMenuItem" Header="_Auto Nest" Click="OnAutoNest" />
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem Header="_Plate">
|
|
||||||
<MenuItem x:Name="ViewInCadMenuItem" Header="View in _CAD" Click="OnViewInCad" />
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem Header="_View">
|
|
||||||
<MenuItem x:Name="DrawRapidsMenuItem" Header="Draw _Rapids" ToggleType="CheckBox"
|
|
||||||
Click="OnToggleDrawRapids" />
|
|
||||||
<MenuItem x:Name="DrawPiercePointsMenuItem" Header="Draw _Pierce Points" ToggleType="CheckBox"
|
|
||||||
Click="OnToggleDrawPiercePoints" />
|
|
||||||
<MenuItem x:Name="DrawBoundsMenuItem" Header="Draw _Bounds" ToggleType="CheckBox"
|
|
||||||
Click="OnToggleDrawBounds" />
|
|
||||||
<MenuItem x:Name="DrawOffsetMenuItem" Header="Draw _Offset" ToggleType="CheckBox"
|
|
||||||
Click="OnToggleDrawOffset" />
|
|
||||||
<MenuItem x:Name="DrawCutDirectionMenuItem" Header="Draw _Cut Direction" ToggleType="CheckBox"
|
|
||||||
Click="OnToggleDrawCutDirection" />
|
|
||||||
<MenuItem x:Name="DrawSupportMenuItem" Header="Draw _Support" ToggleType="CheckBox"
|
|
||||||
Click="OnToggleDrawSupport" />
|
|
||||||
<Separator />
|
|
||||||
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
|
|
||||||
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
|
|
||||||
<MenuItem Header="_Next Plate" InputGesture="PageDown" Click="OnNextPlate" />
|
|
||||||
<Separator />
|
|
||||||
<MenuItem Header="Next N_est" InputGesture="Ctrl+Tab" Click="OnNextNest" />
|
|
||||||
<MenuItem Header="Previous Ne_st" InputGesture="Ctrl+Shift+Tab" Click="OnPreviousNest" />
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem Header="_Tools">
|
|
||||||
<MenuItem x:Name="OptionsMenuItem" Header="_Options..." Click="OnOptions" />
|
|
||||||
<Separator />
|
|
||||||
<MenuItem x:Name="SetOffsetIncrementMenuItem" Header="Set Offset Increment" InputGesture="F5" Click="OnSetOffsetIncrement" />
|
|
||||||
<MenuItem x:Name="SetRotationIncrementMenuItem" Header="Set Rotation Increment" InputGesture="F6" Click="OnSetRotationIncrement" />
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
|
||||||
<Button x:Name="NewButton" Content="New" Click="OnNew" />
|
|
||||||
<Button x:Name="OpenButton" Content="Open" Click="OnOpen" />
|
|
||||||
<Button x:Name="SaveButton" Content="Save" Click="OnSave" />
|
|
||||||
<Button x:Name="SaveAsButton" Content="Save As" Click="OnSaveAs" />
|
|
||||||
<Button Content="Fit" Click="OnFit" />
|
|
||||||
<Button Content="Previous" Click="OnPreviousPlate" />
|
|
||||||
<Button Content="Next" Click="OnNextPlate" />
|
|
||||||
<Button x:Name="AutoNestButton" Content="Auto Nest" Click="OnAutoNest" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Border DockPanel.Dock="Bottom" Padding="6,3" Background="#F0F0F0">
|
|
||||||
<TextBlock x:Name="Status" Text="Create a new nest or open a .nest file to begin." />
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- One tab per open nest, along the bottom of the window. -->
|
|
||||||
<Border DockPanel.Dock="Bottom" Background="#E4E7EA">
|
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
|
||||||
<StackPanel x:Name="TabStrip" Orientation="Horizontal" Margin="6,0,6,6" />
|
|
||||||
</ScrollViewer>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Panel>
|
|
||||||
<!-- The empty workspace, as in the WinForms app: the Windows AppWorkspace gray, with the OpenNest logo centered. -->
|
|
||||||
<Panel x:Name="EmptyHint" Background="#ABABAB">
|
|
||||||
<Image Source="avares://OpenNest.Avalonia/Assets/watermark.png" Stretch="None"
|
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
|
||||||
</Panel>
|
|
||||||
<ContentControl x:Name="NestHost" />
|
|
||||||
</Panel>
|
|
||||||
</DockPanel>
|
|
||||||
</Window>
|
|
||||||
@@ -1,951 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Avalonia.Layout;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.Platform.Storage;
|
|
||||||
using OpenNest.Desktop;
|
|
||||||
using OpenNest.Engine;
|
|
||||||
using OpenNest.Engine.Jobs;
|
|
||||||
using OpenNest.Forms;
|
|
||||||
using OpenNest.IO;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
|
||||||
{
|
|
||||||
public partial class MainWindow : Window
|
|
||||||
{
|
|
||||||
private static readonly IBrush Accent = Brush.Parse("#3368A0");
|
|
||||||
private static readonly IBrush Muted = Brush.Parse("#6B7280");
|
|
||||||
private static readonly IBrush InactiveTab = Brush.Parse("#DDE1E5");
|
|
||||||
|
|
||||||
private readonly NestTabs tabs = new();
|
|
||||||
private readonly Dictionary<NestTab, NestView> views = new();
|
|
||||||
private string notice;
|
|
||||||
private CancellationTokenSource nesting;
|
|
||||||
private NestTab nestingTab;
|
|
||||||
private Task autoNest = Task.CompletedTask;
|
|
||||||
private bool closeConfirmed;
|
|
||||||
// A File > New waiting on the machines or the nest number.
|
|
||||||
private bool creatingNest;
|
|
||||||
private bool snapshotMode;
|
|
||||||
// A save or export waiting on its file picker or write; another one waits for it.
|
|
||||||
private bool writingFile;
|
|
||||||
// True while Exit asks about unsaved nests, so a second close request is ignored.
|
|
||||||
private bool askingToExit;
|
|
||||||
// Load and start-up warnings shown one dialog at a time, once the window is open.
|
|
||||||
private readonly Queue<(string Title, string Message)> pendingWarnings = new();
|
|
||||||
private bool showingWarnings;
|
|
||||||
private Task warningsShown = Task.CompletedTask;
|
|
||||||
// The window is closing (its close went ahead) or closed.
|
|
||||||
private bool closed;
|
|
||||||
|
|
||||||
public MainWindow()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
// As the desktop app: plug-in engines first, then the saved engine selection.
|
|
||||||
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
|
|
||||||
var engineWarning = EngineSelection.LoadSavedSelection();
|
|
||||||
if (!string.IsNullOrEmpty(engineWarning))
|
|
||||||
notice = engineWarning;
|
|
||||||
// Post processors from Posts/ (their materials registered), then the machines,
|
|
||||||
// reported in the status line as the WinForms app reports them in dialogs.
|
|
||||||
if (DesktopSettings.LoadPosts(Path.Combine(AppContext.BaseDirectory, "Posts")) is { } postWarning)
|
|
||||||
notice = notice == null ? postWarning : $"{notice} {postWarning}";
|
|
||||||
MachinesLoaded = LoadMachinesAsync();
|
|
||||||
// The colour scheme and fill strategies chosen in Tools > Options (either app's), from
|
|
||||||
// the shared user settings; applying a scheme later recolours the open nests.
|
|
||||||
var userSettings = Data.UserSettingsFile.Read(Data.UserSettingsFile.DefaultPath);
|
|
||||||
ColorSchemeRegistry.ApplySaved(userSettings);
|
|
||||||
FillStrategyOptions.ApplySaved(userSettings);
|
|
||||||
ColorSchemeRegistry.Applied += OnColorSchemeApplied;
|
|
||||||
Closed += (_, _) => ColorSchemeRegistry.Applied -= OnColorSchemeApplied;
|
|
||||||
Closed += (_, _) => closed = true;
|
|
||||||
tabs.Changed += (_, _) => ShowActiveTab();
|
|
||||||
KeyBindings.Add(new KeyBinding
|
|
||||||
{
|
|
||||||
Gesture = new KeyGesture(Key.N, KeyModifiers.Control),
|
|
||||||
Command = new Command(() => OnNew(null, null)),
|
|
||||||
});
|
|
||||||
KeyBindings.Add(new KeyBinding
|
|
||||||
{
|
|
||||||
Gesture = new KeyGesture(Key.O, KeyModifiers.Control),
|
|
||||||
Command = new Command(() => OnOpen(null, null)),
|
|
||||||
});
|
|
||||||
KeyBindings.Add(new KeyBinding
|
|
||||||
{
|
|
||||||
Gesture = new KeyGesture(Key.S, KeyModifiers.Control),
|
|
||||||
Command = new Command(() => OnSave(null, null)),
|
|
||||||
});
|
|
||||||
KeyBindings.Add(new KeyBinding
|
|
||||||
{
|
|
||||||
Gesture = new KeyGesture(Key.Q, KeyModifiers.Control),
|
|
||||||
Command = new Command(() => OnExit(null, null)),
|
|
||||||
});
|
|
||||||
KeyBindings.Add(new KeyBinding
|
|
||||||
{
|
|
||||||
Gesture = new KeyGesture(Key.A, KeyModifiers.Control),
|
|
||||||
Command = new Command(() => OnSelectAll(null, null)),
|
|
||||||
});
|
|
||||||
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageUp), Command = new Command(() => StepPlate(-1)) });
|
|
||||||
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageDown), Command = new Command(() => StepPlate(1)) });
|
|
||||||
KeyBindings.Add(new KeyBinding
|
|
||||||
{
|
|
||||||
Gesture = new KeyGesture(Key.F5),
|
|
||||||
Command = new Command(() => OnSetOffsetIncrement(null, null)),
|
|
||||||
});
|
|
||||||
KeyBindings.Add(new KeyBinding
|
|
||||||
{
|
|
||||||
Gesture = new KeyGesture(Key.F6),
|
|
||||||
Command = new Command(() => OnSetRotationIncrement(null, null)),
|
|
||||||
});
|
|
||||||
// Ctrl+Tab before focus navigation takes the Tab key.
|
|
||||||
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
|
|
||||||
// .nest files dropped anywhere on the window, the nest view included, open in tabs.
|
|
||||||
DragDrop.SetAllowDrop(this, true);
|
|
||||||
AddHandler(DragDrop.DragEnterEvent, OnDragOver);
|
|
||||||
AddHandler(DragDrop.DragOverEvent, OnDragOver);
|
|
||||||
AddHandler(DragDrop.DropEvent, OnDrop);
|
|
||||||
Closing += OnWindowClosing;
|
|
||||||
#if DEBUG
|
|
||||||
StartUnrecordedChangeCheck();
|
|
||||||
#endif
|
|
||||||
Opened += (_, _) =>
|
|
||||||
{
|
|
||||||
if (!snapshotMode)
|
|
||||||
_ = ShowLoadWarningsAsync();
|
|
||||||
};
|
|
||||||
ShowActiveTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Null while a new tab's view is still being created.
|
|
||||||
private NestView ActiveView =>
|
|
||||||
tabs.Active != null && views.TryGetValue(tabs.Active, out var view) ? view : null;
|
|
||||||
|
|
||||||
/// <summary>Opens the nest in a new tab, or switches to its tab if open.</summary>
|
|
||||||
public void OpenNest(string path)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
IReadOnlyList<string> warnings = Array.Empty<string>();
|
|
||||||
var tab = tabs.Open(path, file =>
|
|
||||||
{
|
|
||||||
var reader = new NestReader(file);
|
|
||||||
var document = new Document { Nest = reader.Read() };
|
|
||||||
warnings = reader.Warnings;
|
|
||||||
// The view follows the application's scheme, not the one the file was saved under.
|
|
||||||
ColorSchemeRegistry.RecolorDrawings(document.Nest);
|
|
||||||
return document;
|
|
||||||
});
|
|
||||||
if (!views.ContainsKey(tab))
|
|
||||||
AddView(tab);
|
|
||||||
if (warnings.Count > 0)
|
|
||||||
{
|
|
||||||
var count = warnings.Count == 1
|
|
||||||
? "1 load warning"
|
|
||||||
: $"{warnings.Count} load warnings";
|
|
||||||
ShowNotice($"{tab.Name}: {count}");
|
|
||||||
pendingWarnings.Enqueue(("Nest Load Warnings",
|
|
||||||
$"{tab.Name}:\n\n" + string.Join(Environment.NewLine, warnings)));
|
|
||||||
if (IsVisible && !snapshotMode)
|
|
||||||
_ = ShowLoadWarningsAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
ShowNotice($"Could not open {Path.GetFileName(path)}: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens a new nest from the nest defaults, the default machine with its clamps and
|
|
||||||
/// support grid, and the year's next name, in a new tab, as File > New does in WinForms.
|
|
||||||
/// </summary>
|
|
||||||
public async Task NewNestAsync()
|
|
||||||
{
|
|
||||||
// One at a time, so two nests never take the same number.
|
|
||||||
if (creatingNest)
|
|
||||||
return;
|
|
||||||
creatingNest = true;
|
|
||||||
NewNest created;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// The machines finish loading first; reading them and the nest number may wait
|
|
||||||
// on the nest server, so neither runs on the UI thread.
|
|
||||||
await MachinesLoaded;
|
|
||||||
created = await Task.Run(() => DesktopSettings.NewNests.CreateAsync());
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
ShowNotice($"Could not create a new nest: {ex.Message}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
creatingNest = false;
|
|
||||||
}
|
|
||||||
AddView(tabs.Add(new Document { Nest = created.Nest }));
|
|
||||||
if (created.Warning != null)
|
|
||||||
{
|
|
||||||
// Through the queue, so it never stacks on a load warning already showing.
|
|
||||||
pendingWarnings.Enqueue(("Nest Defaults", created.Warning));
|
|
||||||
await ShowLoadWarningsAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddView(NestTab tab)
|
|
||||||
{
|
|
||||||
var view = new NestView(tab);
|
|
||||||
RestoreViewToggles(view);
|
|
||||||
view.ViewChanged += (_, _) => { if (view == ActiveView) UpdateStatus(); };
|
|
||||||
views[tab] = view;
|
|
||||||
WatchHistory(tab);
|
|
||||||
notice = null;
|
|
||||||
ShowActiveTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Restores the window's size, position and maximized state from the user settings the
|
|
||||||
/// WinForms app shares, and saves them on close. Not used for --snapshot, whose image
|
|
||||||
/// must not depend on the user's settings.
|
|
||||||
/// </summary>
|
|
||||||
public void RememberPlacement() =>
|
|
||||||
WindowPlacementBinding.Remember(
|
|
||||||
this, WindowPlacement.MainWindow, Data.UserSettingsFile.DefaultPath);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Once the window opens, converts a legacy nest template into the nest defaults and
|
|
||||||
/// opens a new nest when Tools > Options asks for one, as the WinForms main window does
|
|
||||||
/// on load. No new nest beside nests already open (from the command line, or opened or
|
|
||||||
/// dropped while the template converted). Not used for --snapshot, which must neither
|
|
||||||
/// change settings nor take a nest number.
|
|
||||||
/// </summary>
|
|
||||||
public void RunStartupOnOpen() => Opened += async (_, _) => await RunStartupAsync();
|
|
||||||
|
|
||||||
// The window is closing, or asking about unsaved nests before exit: start-up stops.
|
|
||||||
private bool StartupInterrupted => closed || closeConfirmed || askingToExit;
|
|
||||||
|
|
||||||
private async Task RunStartupAsync()
|
|
||||||
{
|
|
||||||
DesktopStartupResult startup;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Reads the template's nest and writes the defaults file: off the UI thread.
|
|
||||||
startup = await Task.Run(() => DesktopStartup.Run(
|
|
||||||
Data.UserSettingsFile.DefaultPath, Data.NestDefaults.DefaultPath,
|
|
||||||
Engine.BestFit.BestFitCacheStore.Shared));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
ShowNotice(
|
|
||||||
$"Start-up could not finish: {ex.Message} Use File > New to start a nest.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// On the UI thread, as the window's other user-settings writers are.
|
|
||||||
if (startup.ClearTemplateSetting)
|
|
||||||
DesktopStartup.ClearTemplateSetting(Data.UserSettingsFile.DefaultPath);
|
|
||||||
if (StartupInterrupted)
|
|
||||||
return;
|
|
||||||
if (startup.TemplateWarning != null)
|
|
||||||
{
|
|
||||||
pendingWarnings.Enqueue(("Nest Template", startup.TemplateWarning));
|
|
||||||
// Waits for every queued warning, including ones already showing.
|
|
||||||
await ShowLoadWarningsAsync();
|
|
||||||
}
|
|
||||||
if (!startup.CreateNewNest)
|
|
||||||
return;
|
|
||||||
// The machines load first, so the check below is close to the nest-number request.
|
|
||||||
await MachinesLoaded;
|
|
||||||
// Closed meanwhile (a window closed in code takes its dialogs with it), or nests
|
|
||||||
// opened from the command line, or opened or dropped meanwhile: no new nest.
|
|
||||||
if (StartupInterrupted || tabs.Tabs.Count > 0)
|
|
||||||
return;
|
|
||||||
await NewNestAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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)
|
|
||||||
{
|
|
||||||
// No dialogs: the image is taken and the window closes unattended.
|
|
||||||
snapshotMode = true;
|
|
||||||
UseDefaultViewToggles();
|
|
||||||
Opened += async (_, _) =>
|
|
||||||
{
|
|
||||||
ActiveView?.ShowPlate(plateNumber - 1);
|
|
||||||
await autoNest;
|
|
||||||
await Task.Delay(500);
|
|
||||||
var size = new PixelSize((int)Bounds.Width, (int)Bounds.Height);
|
|
||||||
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
|
|
||||||
bitmap.Render(this);
|
|
||||||
bitmap.Save(path);
|
|
||||||
// The status line beside the image, so headless checks can read it.
|
|
||||||
File.WriteAllText(path + ".txt", Status.Text);
|
|
||||||
closeConfirmed = true;
|
|
||||||
Close();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowActiveTab()
|
|
||||||
{
|
|
||||||
var view = ActiveView;
|
|
||||||
NestHost.Content = view;
|
|
||||||
EmptyHint.IsVisible = view == null;
|
|
||||||
CloseMenuItem.IsEnabled = view != null;
|
|
||||||
SaveMenuItem.IsEnabled = view != null;
|
|
||||||
SaveAsMenuItem.IsEnabled = view != null;
|
|
||||||
ExportNestMenuItem.IsEnabled = view != null;
|
|
||||||
SaveButton.IsEnabled = view != null;
|
|
||||||
SaveAsButton.IsEnabled = view != null;
|
|
||||||
CloseAllMenuItem.IsEnabled = view != null;
|
|
||||||
SetOffsetIncrementMenuItem.IsEnabled = view != null;
|
|
||||||
SetRotationIncrementMenuItem.IsEnabled = view != null;
|
|
||||||
UpdateExportCommands(view != null);
|
|
||||||
Title = view == null
|
|
||||||
? "OpenNest (Avalonia prototype)"
|
|
||||||
: $"{view.Tab.Name} - OpenNest (Avalonia prototype)";
|
|
||||||
RebuildTabStrip();
|
|
||||||
UpdateViewToggles();
|
|
||||||
// The Edit menu follows the active nest's history.
|
|
||||||
UpdateUndoMenu();
|
|
||||||
UpdateStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RebuildTabStrip()
|
|
||||||
{
|
|
||||||
TabStrip.Children.Clear();
|
|
||||||
for (var i = 0; i < tabs.Tabs.Count; i++)
|
|
||||||
TabStrip.Children.Add(TabButton(tabs.Tabs[i], i == tabs.ActiveIndex));
|
|
||||||
|
|
||||||
var add = new Button
|
|
||||||
{
|
|
||||||
Content = "+",
|
|
||||||
FontSize = 15,
|
|
||||||
Padding = new Thickness(10, 2),
|
|
||||||
Margin = new Thickness(4, 4, 0, 0),
|
|
||||||
Background = Brushes.Transparent,
|
|
||||||
VerticalAlignment = VerticalAlignment.Top,
|
|
||||||
};
|
|
||||||
ToolTip.SetTip(add, "New nest");
|
|
||||||
add.Click += OnNew;
|
|
||||||
TabStrip.Children.Add(add);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Control TabButton(NestTab tab, bool active)
|
|
||||||
{
|
|
||||||
var name = new TextBlock
|
|
||||||
{
|
|
||||||
Text = tab.Modified ? tab.Name + " \u2022" : tab.Name,
|
|
||||||
FontSize = 13,
|
|
||||||
FontWeight = active ? FontWeight.SemiBold : FontWeight.Normal,
|
|
||||||
Foreground = active ? Brushes.Black : Muted,
|
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
|
||||||
};
|
|
||||||
var close = new Button
|
|
||||||
{
|
|
||||||
Content = "\u2715",
|
|
||||||
FontSize = 10,
|
|
||||||
Padding = new Thickness(5, 1),
|
|
||||||
Background = Brushes.Transparent,
|
|
||||||
Foreground = Muted,
|
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
|
||||||
};
|
|
||||||
ToolTip.SetTip(close, "Close");
|
|
||||||
close.Click += async (_, e) =>
|
|
||||||
{
|
|
||||||
e.Handled = true;
|
|
||||||
await CloseTabAsync(tab);
|
|
||||||
};
|
|
||||||
|
|
||||||
var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
|
|
||||||
row.Children.Add(name);
|
|
||||||
row.Children.Add(close);
|
|
||||||
var border = new Border
|
|
||||||
{
|
|
||||||
Child = row,
|
|
||||||
Padding = new Thickness(14, 5, 6, 5),
|
|
||||||
Margin = new Thickness(0, 0, 2, 0),
|
|
||||||
CornerRadius = new CornerRadius(0, 0, 6, 6),
|
|
||||||
Background = active ? Brushes.White : InactiveTab,
|
|
||||||
BorderBrush = active ? Accent : Brushes.Transparent,
|
|
||||||
BorderThickness = new Thickness(0, 0, 0, 2),
|
|
||||||
Cursor = new Cursor(StandardCursorType.Hand),
|
|
||||||
};
|
|
||||||
ToolTip.SetTip(border, tab.Path);
|
|
||||||
border.PointerPressed += (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.GetCurrentPoint(border).Properties.IsLeftButtonPressed)
|
|
||||||
tabs.Activate(tabs.Tabs.ToList().IndexOf(tab));
|
|
||||||
};
|
|
||||||
return border;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> CloseTabAsync(NestTab tab)
|
|
||||||
{
|
|
||||||
if (tab == nestingTab)
|
|
||||||
{
|
|
||||||
ShowNotice($"Auto Nest is running on {tab.Name}. Stop it before closing the nest.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (writingFile)
|
|
||||||
{
|
|
||||||
ShowNotice("A nest is being saved or exported. Close the nest once that finishes.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!await ConfirmUnsavedAsync(tab))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
UnwatchHistory(tab);
|
|
||||||
tabs.Close(tab);
|
|
||||||
if (views.Remove(tab, out var closed))
|
|
||||||
SaveViewToggles(closed);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnCloseNest(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (tabs.Active != null)
|
|
||||||
await CloseTabAsync(tabs.Active);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnCloseAll(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
foreach (var tab in tabs.Tabs.Reverse().ToList())
|
|
||||||
{
|
|
||||||
if (!await CloseTabAsync(tab))
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnWindowClosing(object sender, WindowClosingEventArgs e)
|
|
||||||
{
|
|
||||||
if (closeConfirmed || (!tabs.AnyModified && !writingFile))
|
|
||||||
{
|
|
||||||
// Set before Avalonia closes the owned dialogs, whose awaiters then resume.
|
|
||||||
closed = true;
|
|
||||||
nesting?.Cancel();
|
|
||||||
SaveAllViewToggles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Cancel = true;
|
|
||||||
if (askingToExit)
|
|
||||||
return;
|
|
||||||
if (writingFile)
|
|
||||||
{
|
|
||||||
ShowNotice("A nest is being saved or exported. Exit again once that finishes.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// As closing each WinForms nest window: ask about every unsaved nest in turn,
|
|
||||||
// showing it; Cancel, or a save that does not happen, keeps the app open. The list
|
|
||||||
// is read again after each answer: Auto Nest can finish while a question is open.
|
|
||||||
askingToExit = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var answered = new HashSet<NestTab>();
|
|
||||||
while (tabs.Tabs.FirstOrDefault(t => t.Modified && !answered.Contains(t))
|
|
||||||
is { } tab)
|
|
||||||
{
|
|
||||||
tabs.Activate(tabs.Tabs.ToList().IndexOf(tab));
|
|
||||||
if (tab == nestingTab)
|
|
||||||
{
|
|
||||||
await ConfirmDialog.ShowAsync(this, "Exit OpenNest",
|
|
||||||
$"Auto Nest is running on {tab.Name}, which has unsaved changes. "
|
|
||||||
+ "Stop Auto Nest (Nest > Stop Auto Nest) or let it finish, then "
|
|
||||||
+ "exit again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!await ConfirmUnsavedAsync(tab))
|
|
||||||
return;
|
|
||||||
answered.Add(tab);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
askingToExit = false;
|
|
||||||
}
|
|
||||||
closeConfirmed = true;
|
|
||||||
Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For a nest with unsaved changes, asks Save / Don't Save / Cancel. True when it may
|
|
||||||
/// close: saved, or the user chose not to save.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<bool> ConfirmUnsavedAsync(NestTab tab)
|
|
||||||
{
|
|
||||||
if (!tab.Modified)
|
|
||||||
return true;
|
|
||||||
var choice = await ConfirmDialog.ChooseAsync(this, "Unsaved Changes",
|
|
||||||
$"Save changes to '{tab.Name}' before closing?", "Save", "Don't Save");
|
|
||||||
return choice switch
|
|
||||||
{
|
|
||||||
0 => await SaveAsync(tab, askForFile: false),
|
|
||||||
1 => true,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnSave(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (tabs.Active != null)
|
|
||||||
await SaveAsync(tabs.Active, askForFile: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnSaveAs(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (tabs.Active != null)
|
|
||||||
await SaveAsync(tabs.Active, askForFile: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Saves the nest to the file it was saved to this session, else (or for Save As) to a
|
|
||||||
/// file the user picks. True when it was saved.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<bool> SaveAsync(NestTab tab, bool askForFile)
|
|
||||||
{
|
|
||||||
if (!CanWrite(tab, "saving"))
|
|
||||||
return false;
|
|
||||||
writingFile = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var path = askForFile ? null : tab.SavePath;
|
|
||||||
path ??= await PickNestFileAsync(tab, "Save Nest As");
|
|
||||||
// Auto Nest may have started on it while the file picker was open.
|
|
||||||
if (path == null || IsNesting(tab, "saving"))
|
|
||||||
return false;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
tabs.SaveAs(tab, path);
|
|
||||||
}
|
|
||||||
catch (SaveTargetRefusedException ex)
|
|
||||||
{
|
|
||||||
await ConfirmDialog.ShowAsync(
|
|
||||||
this, "Save Nest", $"The nest was not saved. {ex.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await ConfirmDialog.ShowAsync(this, "Save Nest",
|
|
||||||
"The nest was not saved. Any copy already at that path was left as it "
|
|
||||||
+ $"was.\n\n{path}\n\n{ex.Message}\n\nChoose File > Save As to save "
|
|
||||||
+ "it to another folder or name.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
ShowNotice($"Saved {tab.Name} to {path}");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
writingFile = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnExportNest(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var tab = tabs.Active;
|
|
||||||
if (tab == null || !CanWrite(tab, "exporting"))
|
|
||||||
return;
|
|
||||||
writingFile = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var path = await PickNestFileAsync(tab, "Save Copy as .nest File");
|
|
||||||
if (path == null || IsNesting(tab, "exporting"))
|
|
||||||
return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Export never changes the nest's file, name or unsaved mark.
|
|
||||||
tabs.ExportCopy(tab, path);
|
|
||||||
ShowNotice($"Saved a copy to {path}");
|
|
||||||
}
|
|
||||||
catch (SaveTargetRefusedException ex)
|
|
||||||
{
|
|
||||||
await ConfirmDialog.ShowAsync(
|
|
||||||
this, "Save Copy", $"The copy was not saved. {ex.Message}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await ConfirmDialog.ShowAsync(this, "Save Copy",
|
|
||||||
$"The copy was not saved.\n\n{path}\n\n{ex.Message}\n\nChoose "
|
|
||||||
+ "File > Save Copy as .nest File again to pick another folder or name.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
writingFile = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whether the tab's nest can be written now: not while Auto Nest works on it, and one
|
|
||||||
// save or export at a time.
|
|
||||||
private bool CanWrite(NestTab tab, string doing)
|
|
||||||
{
|
|
||||||
if (IsNesting(tab, doing))
|
|
||||||
return false;
|
|
||||||
if (writingFile)
|
|
||||||
{
|
|
||||||
ShowNotice("A nest is already being saved or exported. "
|
|
||||||
+ $"Finish that before {doing}.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsNesting(NestTab tab, string doing)
|
|
||||||
{
|
|
||||||
if (tab != nestingTab)
|
|
||||||
return false;
|
|
||||||
ShowNotice($"Auto Nest is running on {tab.Name}. "
|
|
||||||
+ $"Stop it or let it finish before {doing}.");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A .nest file to write, named after the nest, starting in the folder of its file.
|
|
||||||
private async Task<string> PickNestFileAsync(NestTab tab, string title)
|
|
||||||
{
|
|
||||||
var folder = tab.Path != null ? Path.GetDirectoryName(tab.Path) : null;
|
|
||||||
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
|
||||||
{
|
|
||||||
Title = title,
|
|
||||||
SuggestedFileName = tab.Name,
|
|
||||||
DefaultExtension = "nest",
|
|
||||||
ShowOverwritePrompt = true,
|
|
||||||
FileTypeChoices = new[] { NestFiles },
|
|
||||||
SuggestedStartLocation = folder != null
|
|
||||||
? await StorageProvider.TryGetFolderFromPathAsync(folder)
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
return file?.TryGetLocalPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly FilePickerFileType NestFiles =
|
|
||||||
new("Nest files") { Patterns = new[] { "*.nest" } };
|
|
||||||
|
|
||||||
/// <summary>Shows the queued warnings one at a time; finishes when the queue is empty,
|
|
||||||
/// also when called while they are already showing.</summary>
|
|
||||||
private Task ShowLoadWarningsAsync()
|
|
||||||
{
|
|
||||||
if (!showingWarnings)
|
|
||||||
warningsShown = ShowQueuedWarningsAsync();
|
|
||||||
return warningsShown;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ShowQueuedWarningsAsync()
|
|
||||||
{
|
|
||||||
showingWarnings = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// None once the window has closed: a dialog needs a live owner.
|
|
||||||
while (!closed && pendingWarnings.TryDequeue(out var item))
|
|
||||||
await ConfirmDialog.ShowAsync(this, item.Title, item.Message);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
showingWarnings = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
|
||||||
{
|
|
||||||
if (ProcessUndoKeys(e))
|
|
||||||
{
|
|
||||||
e.Handled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.Key == Key.Tab && e.KeyModifiers.HasFlag(KeyModifiers.Control))
|
|
||||||
{
|
|
||||||
StepNest(e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? -1 : 1);
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnNextNest(object sender, RoutedEventArgs e) => StepNest(1);
|
|
||||||
|
|
||||||
private void OnPreviousNest(object sender, RoutedEventArgs e) => StepNest(-1);
|
|
||||||
|
|
||||||
private void StepNest(int step)
|
|
||||||
{
|
|
||||||
var count = tabs.Tabs.Count;
|
|
||||||
if (count > 1)
|
|
||||||
tabs.Activate(((tabs.ActiveIndex + step) % count + count) % count);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnNew(object sender, RoutedEventArgs e) => await NewNestAsync();
|
|
||||||
|
|
||||||
private async void OnOpen(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
|
||||||
{
|
|
||||||
Title = "Open Nest",
|
|
||||||
AllowMultiple = true,
|
|
||||||
FileTypeFilter = new[] { NestFiles },
|
|
||||||
});
|
|
||||||
foreach (var path in files.Select(f => f.TryGetLocalPath()).Where(p => p != null))
|
|
||||||
OpenNest(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// As the WinForms main window: a drop is taken only when it holds a .nest file.
|
|
||||||
private void OnDragOver(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
e.DragEffects = DroppedNestFiles.Any(DroppedPaths(e))
|
|
||||||
? DragDropEffects.Copy
|
|
||||||
: DragDropEffects.None;
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each dropped .nest file opens in a tab (or shows its tab); other files are ignored.
|
|
||||||
// As File > Open, this works while Auto Nest runs.
|
|
||||||
private void OnDrop(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
var paths = DroppedNestFiles.Select(DroppedPaths(e));
|
|
||||||
e.DragEffects = paths.Count > 0 ? DragDropEffects.Copy : DragDropEffects.None;
|
|
||||||
e.Handled = true;
|
|
||||||
foreach (var path in paths)
|
|
||||||
OpenNest(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<string> DroppedPaths(DragEventArgs e) =>
|
|
||||||
e.Data.GetFiles()?.Select(file => file.TryGetLocalPath())
|
|
||||||
?? Enumerable.Empty<string>();
|
|
||||||
|
|
||||||
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 of the active nest on sheets like its shown
|
|
||||||
/// plate, through the shared pipeline off the UI thread. Other nests can be opened and
|
|
||||||
/// viewed meanwhile; the nest being nested cannot be closed until it finishes.
|
|
||||||
/// </summary>
|
|
||||||
private async Task RunAutoNestAsync()
|
|
||||||
{
|
|
||||||
var view = ActiveView;
|
|
||||||
if (view == null || nesting != null)
|
|
||||||
return;
|
|
||||||
var tab = view.Tab;
|
|
||||||
var nest = tab.Document.Nest;
|
|
||||||
var engineName = EngineSelection.EngineName;
|
|
||||||
var template = view.PlateView.Plate
|
|
||||||
?? nest.Plates.FirstOrDefault()
|
|
||||||
?? nest.PlateDefaults.CreateNew();
|
|
||||||
var request = AutoNestJob.ForRemaining(nest, template, engineName);
|
|
||||||
if (request == null)
|
|
||||||
{
|
|
||||||
ShowNotice("Auto Nest: nothing has a remaining quantity to nest.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
nesting = new CancellationTokenSource();
|
|
||||||
nestingTab = tab;
|
|
||||||
// As WinForms behind its modal progress window: the nest's plate takes no edits
|
|
||||||
// while Auto Nest works on it.
|
|
||||||
var lockedView = views.GetValueOrDefault(tab);
|
|
||||||
if (lockedView != null)
|
|
||||||
lockedView.PlateView.EditsLocked = true;
|
|
||||||
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);
|
|
||||||
// One undo step for the whole result, as in the WinForms app; the history change
|
|
||||||
// updates the tab's unsaved mark.
|
|
||||||
NestRenestResult kept;
|
|
||||||
using (var change = tab.Undo.BeginChange("Auto Nest"))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
kept = AutoNestJob.Keep(nest, result);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// A commit that failed part way is rolled back.
|
|
||||||
change.MarkChanged();
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
if (kept.Nested)
|
|
||||||
{
|
|
||||||
change.MarkChanged();
|
|
||||||
view.Refresh(firstNew);
|
|
||||||
}
|
|
||||||
change.Complete();
|
|
||||||
}
|
|
||||||
ShowNotice(kept.Nested
|
|
||||||
? $"{tab.Name}: {engineName}: {kept.Note}"
|
|
||||||
: AutoNestJob.RefusalReason(result));
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
ShowNotice("Auto Nest stopped; nothing was changed.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
ShowNotice($"Auto Nest failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
nesting.Dispose();
|
|
||||||
nesting = null;
|
|
||||||
nestingTab = null;
|
|
||||||
if (lockedView != null)
|
|
||||||
lockedView.PlateView.EditsLocked = false;
|
|
||||||
SetNesting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetNesting(bool running)
|
|
||||||
{
|
|
||||||
AutoNestButton.Content = running ? "Stop" : "Auto Nest";
|
|
||||||
AutoNestMenuItem.Header = running ? "_Stop Auto Nest" : "_Auto Nest";
|
|
||||||
UpdateUndoMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Completes once the machines are read (see <see cref="DesktopSettings.LoadMachinesAsync"/>).</summary>
|
|
||||||
internal Task MachinesLoaded { get; }
|
|
||||||
|
|
||||||
private async Task LoadMachinesAsync()
|
|
||||||
{
|
|
||||||
string warning;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
warning = await DesktopSettings.LoadMachinesAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
warning = $"The machines could not be read: {ex.Message}";
|
|
||||||
}
|
|
||||||
if (warning != null)
|
|
||||||
ShowNotice(warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowNotice(string text)
|
|
||||||
{
|
|
||||||
notice = text;
|
|
||||||
UpdateStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnFit(object sender, RoutedEventArgs e) => ActiveView?.PlateView.ZoomToFit();
|
|
||||||
|
|
||||||
private void OnSelectAll(object sender, RoutedEventArgs e) => ActiveView?.PlateView.SelectAll();
|
|
||||||
|
|
||||||
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
|
|
||||||
|
|
||||||
private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
|
|
||||||
|
|
||||||
private void StepPlate(int step) => ActiveView?.StepPlate(step);
|
|
||||||
|
|
||||||
// Tools > Set Offset Increment (F5), as WinForms: how far an arrow key nudges the active
|
|
||||||
// nest's selection, or the calculated step (selection size plus part spacing). Each nest
|
|
||||||
// keeps its own increments for the session, as each WinForms nest window does.
|
|
||||||
private async void OnSetOffsetIncrement(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (ActiveView is not { } view)
|
|
||||||
return;
|
|
||||||
var edits = view.PlateView.Edits;
|
|
||||||
var result = await SetValueDialog.ShowAsync(this, "Set Offset Increment",
|
|
||||||
edits.OffsetIncrement,
|
|
||||||
optionText: "Calculated distance (selection size + part spacing)",
|
|
||||||
optionChecked: edits.CalculatedOffset);
|
|
||||||
if (result == null)
|
|
||||||
return;
|
|
||||||
edits.OffsetIncrement = result.Value;
|
|
||||||
edits.CalculatedOffset = result.OptionChecked;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tools > Set Rotation Increment (F6): the Shift+wheel turn, at most 360 degrees.
|
|
||||||
private async void OnSetRotationIncrement(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (ActiveView is not { } view)
|
|
||||||
return;
|
|
||||||
var edits = view.PlateView.Edits;
|
|
||||||
var result = await SetValueDialog.ShowAsync(this, "Set Rotation Increment",
|
|
||||||
edits.RotateIncrementDegrees, maximum: 360);
|
|
||||||
if (result == null)
|
|
||||||
return;
|
|
||||||
edits.RotateIncrementDegrees = result.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tools > Options. Settings for features the app does not have yet (Database mode, the
|
|
||||||
// update feed) are still saved, for the WinForms app and later milestones.
|
|
||||||
private async void OnOptions(object sender, RoutedEventArgs e) =>
|
|
||||||
await OptionsDialog.ShowAsync(this, Data.UserSettingsFile.DefaultPath);
|
|
||||||
|
|
||||||
// As the WinForms nest windows: every open nest takes the new scheme's drawing colours.
|
|
||||||
private void OnColorSchemeApplied(object sender, ColorScheme scheme)
|
|
||||||
{
|
|
||||||
foreach (var (tab, view) in views)
|
|
||||||
{
|
|
||||||
ColorSchemeRegistry.RecolorDrawings(tab.Document.Nest);
|
|
||||||
view.PlateView.InvalidateVisual();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateStatus()
|
|
||||||
{
|
|
||||||
var view = ActiveView;
|
|
||||||
var canvas = view?.PlateView;
|
|
||||||
if (canvas?.Plate == null)
|
|
||||||
{
|
|
||||||
var empty = view == null
|
|
||||||
? "Create a new nest or open a .nest file to begin."
|
|
||||||
: $"{view.Tab.Name} No plates";
|
|
||||||
Status.Text = notice ?? empty;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The active tool's name and hint first, as the WinForms status label shows them.
|
|
||||||
var text = $"{canvas.Tools.Status} {view.Tab.Name} Plate {view.PlateIndex + 1} of {view.PlateCount}"
|
|
||||||
+ $" Zoom {canvas.Zoom:0.##} px/unit";
|
|
||||||
if (canvas.CursorWorld is { } at)
|
|
||||||
text += $" X {at.X:0.000} Y {at.Y:0.000}";
|
|
||||||
var selected = canvas.Selection.Parts.Where(canvas.Plate.Parts.Contains).ToList();
|
|
||||||
if (selected.Count > 0)
|
|
||||||
text += " " + Desktop.Interaction.SelectionStatus.Text(selected);
|
|
||||||
if (canvas.HoveredPart is { } part)
|
|
||||||
text += $" Over: {part.BaseDrawing.Name}";
|
|
||||||
if (notice != null)
|
|
||||||
text += $" | {notice}";
|
|
||||||
Status.Text = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class Command : System.Windows.Input.ICommand
|
|
||||||
{
|
|
||||||
private readonly Action action;
|
|
||||||
|
|
||||||
public Command(Action action) => this.action = action;
|
|
||||||
|
|
||||||
public event EventHandler CanExecuteChanged { add { } remove { } }
|
|
||||||
|
|
||||||
public bool CanExecute(object parameter) => true;
|
|
||||||
|
|
||||||
public void Execute(object parameter) => action();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:c="using:OpenNest.Avalonia.Controls"
|
|
||||||
xmlns:local="using:OpenNest.Avalonia"
|
|
||||||
x:Class="OpenNest.Avalonia.NestView">
|
|
||||||
<Grid ColumnDefinitions="290,4,*">
|
|
||||||
<Border Grid.Column="0" Background="White">
|
|
||||||
<TabControl x:Name="SideTabs" Padding="0">
|
|
||||||
<TabItem Header="Plates" FontSize="14" MinHeight="34">
|
|
||||||
<ListBox x:Name="PlateList" SelectionChanged="OnPlateSelected" />
|
|
||||||
</TabItem>
|
|
||||||
<TabItem Header="Drawings" FontSize="14" MinHeight="34">
|
|
||||||
<DataGrid x:Name="DrawingGrid" x:DataType="local:DrawingRow" IsReadOnly="True"
|
|
||||||
FontSize="13" AutoGenerateColumns="False"
|
|
||||||
GridLinesVisibility="Horizontal" CanUserSortColumns="True">
|
|
||||||
<DataGrid.Columns>
|
|
||||||
<DataGridTextColumn Header="Drawing" Binding="{Binding Name}" Width="*" />
|
|
||||||
<DataGridTextColumn Header="Nested" Binding="{Binding Nested}" />
|
|
||||||
<DataGridTextColumn Header="Required" Binding="{Binding Required}" />
|
|
||||||
</DataGrid.Columns>
|
|
||||||
</DataGrid>
|
|
||||||
</TabItem>
|
|
||||||
</TabControl>
|
|
||||||
</Border>
|
|
||||||
<GridSplitter Grid.Column="1" ResizeDirection="Columns" Background="#D0D4D8" />
|
|
||||||
<DockPanel Grid.Column="2">
|
|
||||||
<Border DockPanel.Dock="Top" Padding="8,4" Background="White"
|
|
||||||
BorderBrush="#D0D4D8" BorderThickness="0,0,0,1">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
||||||
<Button Content="⏮" ToolTip.Tip="First plate" Click="OnFirstPlate" />
|
|
||||||
<Button Content="◀" ToolTip.Tip="Previous plate (Page Up)"
|
|
||||||
Click="OnPreviousPlate" />
|
|
||||||
<TextBlock x:Name="PlateLabel" VerticalAlignment="Center" FontSize="13" Margin="6,0" />
|
|
||||||
<Button Content="▶" ToolTip.Tip="Next plate (Page Down)" Click="OnNextPlate" />
|
|
||||||
<Button Content="⏭" ToolTip.Tip="Last plate" Click="OnLastPlate" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
<c:PlateCanvas x:Name="Canvas" />
|
|
||||||
</DockPanel>
|
|
||||||
</Grid>
|
|
||||||
</UserControl>
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using OpenNest.Avalonia.Controls;
|
|
||||||
using OpenNest.Desktop;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
|
||||||
{
|
|
||||||
public sealed record DrawingRow(string Name, int Nested, int Required);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One open nest: Plates and Drawings tabs on the left, the plate header and plate view on
|
|
||||||
/// the right. Each tab keeps its own view, so switching nests keeps each one's plate and zoom.
|
|
||||||
/// </summary>
|
|
||||||
public partial class NestView : UserControl
|
|
||||||
{
|
|
||||||
public NestView()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
public NestView(NestTab tab)
|
|
||||||
: this()
|
|
||||||
{
|
|
||||||
Tab = tab;
|
|
||||||
Canvas.ViewChanged += (_, _) => ViewChanged?.Invoke(this, EventArgs.Empty);
|
|
||||||
Canvas.BeginChangeHandler = (label, key) => tab.Undo.BeginChange(label, key);
|
|
||||||
tab.Undo.SwapIn = SwapIn;
|
|
||||||
// Deleting parts changes the plate and drawing counts the lists show.
|
|
||||||
tab.Undo.Changed += (_, _) => RefreshCounts();
|
|
||||||
Canvas.CutOffSettings = CutOffSettingsFor(tab.Document.Nest);
|
|
||||||
plates = new PlateManager(tab.Document.Nest);
|
|
||||||
// Starts watching the last two plates (the tab already put the empty plate there).
|
|
||||||
plates.EnsureSentinel();
|
|
||||||
Refresh(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Undo and Redo: puts the copy read back from the history in the document and shows it
|
|
||||||
// on the same plate, keeping the zoom, pan and selection (by part index while the part
|
|
||||||
// count is unchanged). Every list is rebuilt: each held the old nest's objects.
|
|
||||||
private void SwapIn(Nest restored)
|
|
||||||
{
|
|
||||||
var plateIndex = System.Math.Max(0, PlateList.SelectedIndex);
|
|
||||||
var shown = Canvas.Plate;
|
|
||||||
keptSelection = shown != null
|
|
||||||
? (Canvas.Selection.PartIndices, shown.Parts.Count)
|
|
||||||
: (Array.Empty<int>(), -1);
|
|
||||||
// Read first: a throw here leaves the live nest in place, as the recorder expects.
|
|
||||||
var cutOffSettings = CutOffSettingsFor(restored);
|
|
||||||
Tab.Document.Nest = restored;
|
|
||||||
Canvas.CutOffSettings = cutOffSettings;
|
|
||||||
plates.Dispose();
|
|
||||||
plates = new PlateManager(restored);
|
|
||||||
plates.EnsureSentinel();
|
|
||||||
// Reading recolours drawings from the file; the view keeps the application's scheme.
|
|
||||||
// Cut-offs stay as reading regenerated them, as when the nest was opened.
|
|
||||||
ColorSchemeRegistry.RecolorDrawings(restored);
|
|
||||||
keepView = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Refresh(plateIndex);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
keepView = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// While an Undo or Redo swaps its copy in: the plate is shown without zooming to fit,
|
|
||||||
// and the parts selected before (their indices, the plate's part count then) are selected again.
|
|
||||||
private bool keepView;
|
|
||||||
|
|
||||||
// Keeps one empty plate at the end of the nest as edits add or remove parts on the last
|
|
||||||
// two plates, inside the edit's own undo step, as the WinForms nest window's manager does.
|
|
||||||
private PlateManager plates;
|
|
||||||
private (IReadOnlyList<int> Indices, int PartCount) keptSelection = (Array.Empty<int>(), -1);
|
|
||||||
|
|
||||||
/// <summary>Raised when the shown plate, zoom, hover or selection changes.</summary>
|
|
||||||
public event EventHandler ViewChanged;
|
|
||||||
|
|
||||||
public NestTab Tab { get; }
|
|
||||||
|
|
||||||
public PlateCanvas PlateView => Canvas;
|
|
||||||
|
|
||||||
public int PlateIndex => PlateList.SelectedIndex;
|
|
||||||
|
|
||||||
public int PlateCount => Tab.Document.Nest.Plates.Count;
|
|
||||||
|
|
||||||
/// <summary>Rebuilds both lists and shows plate <paramref name="selectIndex"/>.</summary>
|
|
||||||
public void Refresh(int selectIndex)
|
|
||||||
{
|
|
||||||
var nest = Tab.Document.Nest;
|
|
||||||
// As the WinForms nest window: one empty plate stays at the end to nest onto, so a
|
|
||||||
// new nest shows Plate 1. Files leave empty plates out, so saving is unchanged. The
|
|
||||||
// tab put it there on opening and the manager keeps it, so this only reads.
|
|
||||||
Canvas.ClearCache();
|
|
||||||
PlateList.ItemsSource = nest.Plates
|
|
||||||
.Select((plate, i) =>
|
|
||||||
$"Plate {i + 1} {SizeText(plate)} ({plate.Parts.Count} parts)")
|
|
||||||
.ToList();
|
|
||||||
PlateList.SelectedIndex = nest.Plates.Count > 0
|
|
||||||
? System.Math.Clamp(selectIndex, 0, nest.Plates.Count - 1)
|
|
||||||
: -1;
|
|
||||||
DrawingGrid.ItemsSource = nest.Drawings
|
|
||||||
.Where(d => !d.IsCutOff)
|
|
||||||
.Select(d => new DrawingRow(d.Name, d.Quantity.Nested, d.Quantity.Required))
|
|
||||||
.ToList();
|
|
||||||
shownCounts = CountsText(nest);
|
|
||||||
ShowPlate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// As the WinForms nest window binding a nest: the saved head-lock defaults in the nest's
|
|
||||||
// units, then the machine's cut-off defaults for its material and thickness.
|
|
||||||
private static CutOffs.CutOffSettings CutOffSettingsFor(Nest nest)
|
|
||||||
{
|
|
||||||
var settings = new CutOffs.CutOffSettings();
|
|
||||||
SavedCutOffHeadLock.Read(Data.UserSettingsFile.Read(Data.UserSettingsFile.DefaultPath))
|
|
||||||
.ApplyTo(settings, nest.Units);
|
|
||||||
DesktopSettings.Machines.ApplyCutOffDefaults(settings, nest);
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
// After an edit: rebuilds the lists only when their counts changed, keeping the shown
|
|
||||||
// plate, its zoom and the selection, as an Undo does.
|
|
||||||
private void RefreshCounts()
|
|
||||||
{
|
|
||||||
var nest = Tab.Document.Nest;
|
|
||||||
var counts = CountsText(nest);
|
|
||||||
if (counts == shownCounts)
|
|
||||||
return;
|
|
||||||
var shown = Canvas.Plate;
|
|
||||||
keptSelection = shown != null
|
|
||||||
? (Canvas.Selection.PartIndices, shown.Parts.Count)
|
|
||||||
: (Array.Empty<int>(), -1);
|
|
||||||
keepView = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Refresh(System.Math.Max(0, PlateList.SelectedIndex));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
keepView = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string shownCounts;
|
|
||||||
|
|
||||||
private static string CountsText(Nest nest) =>
|
|
||||||
string.Join(",", nest.Plates.Select(p => p.Parts.Count)) + "|"
|
|
||||||
+ string.Join(",", nest.Drawings.Select(d => d.Quantity.Nested));
|
|
||||||
|
|
||||||
public void ShowPlate(int index)
|
|
||||||
{
|
|
||||||
if (PlateCount > 0)
|
|
||||||
PlateList.SelectedIndex = System.Math.Clamp(index, 0, PlateCount - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void StepPlate(int step) => ShowPlate(PlateList.SelectedIndex + step);
|
|
||||||
|
|
||||||
private void OnPlateSelected(object sender, SelectionChangedEventArgs e) => ShowPlate();
|
|
||||||
|
|
||||||
private void ShowPlate()
|
|
||||||
{
|
|
||||||
var nest = Tab.Document.Nest;
|
|
||||||
var index = PlateList.SelectedIndex;
|
|
||||||
var plate = index >= 0 && index < nest.Plates.Count ? nest.Plates[index] : null;
|
|
||||||
if (keepView)
|
|
||||||
Canvas.ReplacePlate(plate,
|
|
||||||
plate?.Parts.Count == keptSelection.PartCount ? keptSelection.Indices : Array.Empty<int>());
|
|
||||||
else
|
|
||||||
Canvas.Plate = plate;
|
|
||||||
PlateLabel.Text = plate == null
|
|
||||||
? "No plates"
|
|
||||||
: $"Plate {index + 1} of {nest.Plates.Count} "
|
|
||||||
+ $"{SizeText(plate)} qty {plate.Quantity}";
|
|
||||||
ViewChanged?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string SizeText(Plate plate) =>
|
|
||||||
$"{plate.Size.Length:0.###} x {plate.Size.Width:0.###}";
|
|
||||||
|
|
||||||
private void OnFirstPlate(object sender, RoutedEventArgs e) => ShowPlate(0);
|
|
||||||
|
|
||||||
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
|
|
||||||
|
|
||||||
private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
|
|
||||||
|
|
||||||
private void OnLastPlate(object sender, RoutedEventArgs e) => ShowPlate(PlateCount - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>WinExe</OutputType>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<RootNamespace>OpenNest.Avalonia</RootNamespace>
|
|
||||||
<AssemblyName>OpenNest.Avalonia</AssemblyName>
|
|
||||||
<Nullable>disable</Nullable>
|
|
||||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
|
||||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
|
||||||
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.csproj" />
|
|
||||||
<PackageReference Include="Avalonia" Version="11.3.0" />
|
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
|
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.0" />
|
|
||||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- The WinForms app's empty-workspace logo, shared rather than copied. -->
|
|
||||||
<AvaloniaResource Include="..\OpenNest\Resources\watermark.png" Link="Assets\watermark.png" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- The posts the WinForms app ships, built with this app and copied to its Posts/ directory. -->
|
|
||||||
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" ReferenceOutputAssembly="false" IsPost="true" />
|
|
||||||
<ProjectReference Include="..\Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj" ReferenceOutputAssembly="false" IsPost="true" />
|
|
||||||
</ItemGroup>
|
|
||||||
<!-- As for the WinForms app: each post DLL, and its shipped settings JSON unless one is already
|
|
||||||
deployed (an edited copy is kept), go to Posts/ beside the app, which loads them at startup. -->
|
|
||||||
<Target Name="CopyPostsToOutput" AfterTargets="Build">
|
|
||||||
<MSBuild Projects="@(ProjectReference)" Condition="'%(ProjectReference.IsPost)' == 'true'" Targets="GetTargetPath">
|
|
||||||
<Output TaskParameter="TargetOutputs" ItemName="PostAssembly" />
|
|
||||||
</MSBuild>
|
|
||||||
<ItemGroup>
|
|
||||||
<PostConfig Include="@(PostAssembly->'%(RootDir)%(Directory)%(Filename).json')" />
|
|
||||||
<PostConfigToCopy Include="@(PostConfig)" Condition="Exists('%(FullPath)') and !Exists('$(OutDir)Posts\%(Filename)%(Extension)')" />
|
|
||||||
</ItemGroup>
|
|
||||||
<MakeDir Directories="$(OutDir)Posts" />
|
|
||||||
<Copy SourceFiles="@(PostAssembly)" DestinationFolder="$(OutDir)Posts" SkipUnchangedFiles="true" />
|
|
||||||
<Copy SourceFiles="@(PostConfigToCopy)" DestinationFolder="$(OutDir)Posts" SkipUnchangedFiles="true" />
|
|
||||||
</Target>
|
|
||||||
</Project>
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Layout;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using OpenNest.Data;
|
|
||||||
using OpenNest.Desktop;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Tools > Options, as the WinForms Options form: Save writes the general options and the
|
|
||||||
/// fill strategies to the user settings both apps share and applies them; the nest server URL
|
|
||||||
/// (storage.json) and the update feed URL have their own buttons that save at once. The
|
|
||||||
/// MaterialLedger connection is not here: its API key is kept with Windows data protection,
|
|
||||||
/// so it is set in the WinForms app.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class OptionsDialog : Window
|
|
||||||
{
|
|
||||||
private static readonly IBrush Muted = Brush.Parse("#6B7280");
|
|
||||||
|
|
||||||
private readonly string settingsPath;
|
|
||||||
private readonly CheckBox createNewNest = new() { Content = "Create a new nest on startup" };
|
|
||||||
private readonly NumericUpDown autoSizeFactor = new()
|
|
||||||
{
|
|
||||||
Minimum = (decimal)DesktopOptions.AutoSizePlateFactorMinimum,
|
|
||||||
Maximum = (decimal)DesktopOptions.AutoSizePlateFactorMaximum,
|
|
||||||
Increment = (decimal)DesktopOptions.AutoSizePlateFactorIncrement,
|
|
||||||
FormatString = "0.####",
|
|
||||||
Width = 150,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Left,
|
|
||||||
};
|
|
||||||
private readonly ComboBox colorScheme = new() { Width = 200 };
|
|
||||||
private readonly NumericUpDown planningEffort = new()
|
|
||||||
{
|
|
||||||
Minimum = (decimal)DesktopOptions.CuttingPlanEffortMinimum,
|
|
||||||
Maximum = (decimal)DesktopOptions.CuttingPlanEffortMaximum,
|
|
||||||
Increment = (decimal)DesktopOptions.CuttingPlanEffortIncrement,
|
|
||||||
FormatString = "0.0",
|
|
||||||
Width = 150,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Left,
|
|
||||||
};
|
|
||||||
private readonly ComboBox clonePlacement = new() { Width = 200 };
|
|
||||||
private readonly TextBox serverUrl = new() { Watermark = "http://server:port" };
|
|
||||||
private readonly TextBox updateFeedUrl = new();
|
|
||||||
private readonly CheckBox includeDevBuilds = new()
|
|
||||||
{
|
|
||||||
Content = "Include development builds (latest master; takes effect after restart)",
|
|
||||||
};
|
|
||||||
private readonly List<(CheckBox Enabled, string Name)> strategies = new();
|
|
||||||
|
|
||||||
private OptionsDialog(string settingsPath)
|
|
||||||
{
|
|
||||||
this.settingsPath = settingsPath;
|
|
||||||
Title = "Options";
|
|
||||||
Width = 720;
|
|
||||||
SizeToContent = SizeToContent.Height;
|
|
||||||
CanResize = false;
|
|
||||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
|
||||||
|
|
||||||
var general = new Grid
|
|
||||||
{
|
|
||||||
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
|
||||||
RowDefinitions = new RowDefinitions("Auto,Auto,Auto,Auto,Auto"),
|
|
||||||
ColumnSpacing = 12,
|
|
||||||
RowSpacing = 8,
|
|
||||||
};
|
|
||||||
AddRow(general, 0, "Auto-size plate factor:", autoSizeFactor,
|
|
||||||
"The amount to round the plate size up.");
|
|
||||||
AddRow(general, 1, "Color scheme:", colorScheme, null);
|
|
||||||
AddRow(general, 2, "Cut planning effort:", planningEffort,
|
|
||||||
"How long Plan Cutting searches for a route; 1 is normal.");
|
|
||||||
AddRow(general, 3, "Clone push placement:", clonePlacement,
|
|
||||||
"Which way Shift+click and Ctrl+click copies are pushed.");
|
|
||||||
Grid.SetRow(createNewNest, 4);
|
|
||||||
Grid.SetColumnSpan(createNewNest, 2);
|
|
||||||
general.Children.Add(createNewNest);
|
|
||||||
|
|
||||||
var setServer = new Button { Content = "Set / Change Server" };
|
|
||||||
setServer.Click += async (_, _) => await SetServerAsync();
|
|
||||||
var server = Group(
|
|
||||||
"Nest server URL (File / Database mode is selected under File > Storage Mode)",
|
|
||||||
UrlRow(serverUrl, setServer));
|
|
||||||
|
|
||||||
var saveFeed = new Button { Content = "Save Feed URL" };
|
|
||||||
saveFeed.Click += async (_, _) => await SaveFeedAsync();
|
|
||||||
var updates = Group(
|
|
||||||
"Auto-update feed URL (blank: nest server mirror in Database mode, else GitHub Releases)",
|
|
||||||
UrlRow(updateFeedUrl, saveFeed), includeDevBuilds);
|
|
||||||
|
|
||||||
var ledger = new TextBlock
|
|
||||||
{
|
|
||||||
Text = "MaterialLedger is set in the Windows app: its API key is saved with Windows data protection.",
|
|
||||||
Foreground = Muted,
|
|
||||||
TextWrapping = TextWrapping.Wrap,
|
|
||||||
};
|
|
||||||
|
|
||||||
var save = new Button { Content = "Save", IsDefault = true, MinWidth = 80 };
|
|
||||||
save.Click += async (_, _) => await SaveAsync();
|
|
||||||
var cancel = new Button { Content = "Cancel", IsCancel = true, MinWidth = 80 };
|
|
||||||
cancel.Click += (_, _) => Close(false);
|
|
||||||
var buttons = new StackPanel
|
|
||||||
{
|
|
||||||
Orientation = Orientation.Horizontal,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Right,
|
|
||||||
Spacing = 8,
|
|
||||||
Children = { save, cancel },
|
|
||||||
};
|
|
||||||
|
|
||||||
var panel = new StackPanel
|
|
||||||
{
|
|
||||||
Margin = new Thickness(18, 18, 18, 8),
|
|
||||||
Spacing = 14,
|
|
||||||
Children =
|
|
||||||
{
|
|
||||||
general,
|
|
||||||
server,
|
|
||||||
ledger,
|
|
||||||
updates,
|
|
||||||
Group("Fill Strategies", StrategyGrid()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// Save and Cancel stay in view when a small screen makes the options scroll.
|
|
||||||
buttons.Margin = new Thickness(18, 8, 18, 18);
|
|
||||||
DockPanel.SetDock(buttons, Dock.Bottom);
|
|
||||||
Content = new DockPanel
|
|
||||||
{
|
|
||||||
Children = { buttons, new ScrollViewer { Content = panel } },
|
|
||||||
};
|
|
||||||
Load();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shows the dialog over <paramref name="owner"/>; true when the options were saved.
|
|
||||||
/// <paramref name="settingsPath"/> is the shared user-settings.json.
|
|
||||||
/// </summary>
|
|
||||||
public static async Task<bool> ShowAsync(Window owner, string settingsPath) =>
|
|
||||||
await new OptionsDialog(settingsPath).ShowDialog<bool?>(owner) == true;
|
|
||||||
|
|
||||||
private void Load()
|
|
||||||
{
|
|
||||||
var settings = UserSettingsFile.Read(settingsPath);
|
|
||||||
var options = DesktopOptions.Read(settings);
|
|
||||||
serverUrl.Text = DesktopSettings.ServerUrl;
|
|
||||||
updateFeedUrl.Text = UpdateFeedOption.Read(settings);
|
|
||||||
includeDevBuilds.IsChecked = options.UpdateIncludeDevBuilds;
|
|
||||||
createNewNest.IsChecked = options.CreateNewNestOnOpen;
|
|
||||||
autoSizeFactor.Value = (decimal)options.AutoSizePlateFactorForEditing;
|
|
||||||
planningEffort.Value = (decimal)options.CuttingPlanEffortForEditing;
|
|
||||||
|
|
||||||
var schemes = DesktopOptions.ColorSchemeNames;
|
|
||||||
colorScheme.ItemsSource = schemes;
|
|
||||||
colorScheme.SelectedIndex = options.ColorSchemeIndex(schemes);
|
|
||||||
|
|
||||||
clonePlacement.ItemsSource = ClonePlacement.Choices.Select(c => c.Text).ToList();
|
|
||||||
clonePlacement.SelectedIndex = ClonePlacement.IndexOf(options.ClonePlacementOrder);
|
|
||||||
|
|
||||||
var disabled = new HashSet<string>(options.DisabledStrategies, StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (var (enabled, name) in strategies)
|
|
||||||
enabled.IsChecked = !disabled.Contains(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
private DesktopOptions FromControls() => new()
|
|
||||||
{
|
|
||||||
CreateNewNestOnOpen = createNewNest.IsChecked == true,
|
|
||||||
UpdateIncludeDevBuilds = includeDevBuilds.IsChecked == true,
|
|
||||||
// A cleared box keeps the value it had, as a WinForms numeric box does.
|
|
||||||
AutoSizePlateFactor = (double)(autoSizeFactor.Value ?? (decimal)DesktopOptions.Defaults.AutoSizePlateFactor),
|
|
||||||
CuttingPlanEffort = (double)(planningEffort.Value ?? (decimal)DesktopOptions.Defaults.CuttingPlanEffort),
|
|
||||||
ColorScheme = colorScheme.SelectedItem as string ?? ColorSchemeRegistry.DefaultSchemeName,
|
|
||||||
ClonePlacementOrder = ClonePlacement.At(clonePlacement.SelectedIndex),
|
|
||||||
DisabledStrategies = strategies.Where(s => s.Enabled.IsChecked != true).Select(s => s.Name).ToList(),
|
|
||||||
};
|
|
||||||
|
|
||||||
private async Task SaveAsync()
|
|
||||||
{
|
|
||||||
var options = FromControls();
|
|
||||||
if (!UserSettingsFile.Update(settingsPath, options.WriteTo))
|
|
||||||
{
|
|
||||||
await ConfirmDialog.ShowAsync(this, "Options",
|
|
||||||
$"Could not save the options to {settingsPath}. Nothing was changed.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
options.Apply();
|
|
||||||
Close(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SetServerAsync()
|
|
||||||
{
|
|
||||||
var (notice, url) = NestServerUrlOption.Save(serverUrl.Text, DesktopSettings.SaveServerUrl);
|
|
||||||
if (url != null)
|
|
||||||
serverUrl.Text = DesktopSettings.ServerUrl;
|
|
||||||
await ConfirmDialog.ShowAsync(this, notice.Title, notice.Text);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveFeedAsync()
|
|
||||||
{
|
|
||||||
var (notice, url) = UpdateFeedOption.Save(updateFeedUrl.Text, next =>
|
|
||||||
{
|
|
||||||
if (!UserSettingsFile.Update(settingsPath, values => values[UpdateFeedOption.SettingName] = next))
|
|
||||||
throw new System.IO.IOException($"{settingsPath} could not be written.");
|
|
||||||
});
|
|
||||||
if (url != null)
|
|
||||||
updateFeedUrl.Text = url;
|
|
||||||
await ConfirmDialog.ShowAsync(this, notice.Title, notice.Text);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Grid StrategyGrid()
|
|
||||||
{
|
|
||||||
var rows = FillStrategyOptions.Rows;
|
|
||||||
var grid = new Grid
|
|
||||||
{
|
|
||||||
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto"),
|
|
||||||
ColumnSpacing = 16,
|
|
||||||
RowSpacing = 4,
|
|
||||||
};
|
|
||||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
|
||||||
var header = 1;
|
|
||||||
foreach (var (text, column) in new[] { ("Strategy", 1), ("Phase", 2), ("Order", 3) })
|
|
||||||
{
|
|
||||||
var cell = new TextBlock { Text = text, FontWeight = FontWeight.SemiBold };
|
|
||||||
Grid.SetColumn(cell, column);
|
|
||||||
grid.Children.Add(cell);
|
|
||||||
}
|
|
||||||
for (var i = 0; i < rows.Count; i++)
|
|
||||||
{
|
|
||||||
var row = rows[i];
|
|
||||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
|
||||||
var enabled = new CheckBox { IsChecked = true, MinWidth = 0, MinHeight = 0 };
|
|
||||||
strategies.Add((enabled, row.Name));
|
|
||||||
var cells = new Control[]
|
|
||||||
{
|
|
||||||
enabled,
|
|
||||||
Cell(row.Name),
|
|
||||||
Cell(row.Phase.ToString()),
|
|
||||||
Cell(row.Order.ToString()),
|
|
||||||
};
|
|
||||||
for (var column = 0; column < cells.Length; column++)
|
|
||||||
{
|
|
||||||
Grid.SetRow(cells[column], i + header);
|
|
||||||
Grid.SetColumn(cells[column], column);
|
|
||||||
grid.Children.Add(cells[column]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TextBlock Cell(string text) =>
|
|
||||||
new() { Text = text, VerticalAlignment = VerticalAlignment.Center };
|
|
||||||
|
|
||||||
private static void AddRow(Grid grid, int row, string label, Control editor, string tip)
|
|
||||||
{
|
|
||||||
var text = new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center };
|
|
||||||
Grid.SetRow(text, row);
|
|
||||||
Grid.SetRow(editor, row);
|
|
||||||
Grid.SetColumn(editor, 1);
|
|
||||||
if (tip != null)
|
|
||||||
ToolTip.SetTip(editor, tip);
|
|
||||||
grid.Children.Add(text);
|
|
||||||
grid.Children.Add(editor);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Grid UrlRow(TextBox box, Button button)
|
|
||||||
{
|
|
||||||
var row = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 8 };
|
|
||||||
Grid.SetColumn(button, 1);
|
|
||||||
button.MinWidth = 150;
|
|
||||||
row.Children.Add(box);
|
|
||||||
row.Children.Add(button);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static StackPanel Group(string header, params Control[] children)
|
|
||||||
{
|
|
||||||
var group = new StackPanel { Spacing = 8 };
|
|
||||||
group.Children.Add(new TextBlock
|
|
||||||
{
|
|
||||||
Text = header,
|
|
||||||
FontWeight = FontWeight.SemiBold,
|
|
||||||
TextWrapping = TextWrapping.Wrap,
|
|
||||||
});
|
|
||||||
foreach (var child in children)
|
|
||||||
group.Children.Add(child);
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Avalonia;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
|
||||||
{
|
|
||||||
internal static class Program
|
|
||||||
{
|
|
||||||
[STAThread]
|
|
||||||
public static void Main(string[] args) =>
|
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
|
||||||
|
|
||||||
public static AppBuilder BuildAvaloniaApp() =>
|
|
||||||
AppBuilder.Configure<App>()
|
|
||||||
.UsePlatformDetect()
|
|
||||||
.WithInterFont()
|
|
||||||
.LogToTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using OpenNest.Desktop.Rendering;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using SkiaSharp;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia.Rendering
|
|
||||||
{
|
|
||||||
/// <summary>Converts the shared plate-view figures to Skia paths.</summary>
|
|
||||||
internal static class SkiaFigures
|
|
||||||
{
|
|
||||||
public static SKPath ToPath(IEnumerable<PathFigure> figures, SKPathFillType fillType = SKPathFillType.EvenOdd)
|
|
||||||
{
|
|
||||||
var path = new SKPath { FillType = fillType };
|
|
||||||
foreach (var figure in figures)
|
|
||||||
Add(path, figure);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Add(SKPath path, PathFigure figure)
|
|
||||||
{
|
|
||||||
path.MoveTo((float)figure.Start.X, (float)figure.Start.Y);
|
|
||||||
foreach (var segment in figure.Segments)
|
|
||||||
{
|
|
||||||
switch (segment)
|
|
||||||
{
|
|
||||||
case LineTo line:
|
|
||||||
path.LineTo((float)line.End.X, (float)line.End.Y);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ArcTo arc:
|
|
||||||
{
|
|
||||||
var r = (float)arc.Radius;
|
|
||||||
var oval = new SKRect((float)arc.Center.X - r, (float)arc.Center.Y - r,
|
|
||||||
(float)arc.Center.X + r, (float)arc.Center.Y + r);
|
|
||||||
if (arc.IsFullCircle)
|
|
||||||
{
|
|
||||||
// Skia drops a whole-turn sweep; two halves keep the contour joined.
|
|
||||||
var half = (float)(arc.SweepAngle / 2);
|
|
||||||
path.ArcTo(oval, (float)arc.StartAngle, half, false);
|
|
||||||
path.ArcTo(oval, (float)arc.StartAngle + half, half, false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
path.ArcTo(oval, (float)arc.StartAngle, (float)arc.SweepAngle, false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (figure.IsClosed && figure.Segments.Count > 0)
|
|
||||||
path.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static SKPath Polygons(IEnumerable<IReadOnlyList<Vector>> polygons)
|
|
||||||
{
|
|
||||||
var path = new SKPath { FillType = SKPathFillType.EvenOdd };
|
|
||||||
foreach (var points in polygons)
|
|
||||||
{
|
|
||||||
if (points.Count == 0)
|
|
||||||
continue;
|
|
||||||
path.MoveTo((float)points[0].X, (float)points[0].Y);
|
|
||||||
for (var i = 1; i < points.Count; i++)
|
|
||||||
path.LineTo((float)points[i].X, (float)points[i].Y);
|
|
||||||
path.Close();
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A part's shared display geometry as Skia paths in its program frame, before placement.
|
|
||||||
/// Built on the UI thread and only read afterwards, so the render thread may draw it.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class PartPaths
|
|
||||||
{
|
|
||||||
private PartPaths(PartDisplayGeometry geometry)
|
|
||||||
{
|
|
||||||
Geometry = geometry;
|
|
||||||
Cut = SkiaFigures.ToPath(geometry.Cut);
|
|
||||||
Lead = SkiaFigures.ToPath(geometry.Lead);
|
|
||||||
Etch = SkiaFigures.ToPath(geometry.Etch);
|
|
||||||
Material = SkiaFigures.ToPath(geometry.Material);
|
|
||||||
Display = geometry.Display.Count > 0 ? SkiaFigures.ToPath(geometry.Display) : null;
|
|
||||||
Selection = geometry.Cut.Count > 0 ? Material : Etch;
|
|
||||||
StrokeBounds = geometry.StrokeBounds();
|
|
||||||
}
|
|
||||||
|
|
||||||
public PartDisplayGeometry Geometry { get; }
|
|
||||||
|
|
||||||
public SKPath Cut { get; }
|
|
||||||
|
|
||||||
public SKPath Lead { get; }
|
|
||||||
|
|
||||||
public SKPath Etch { get; }
|
|
||||||
|
|
||||||
public SKPath Material { get; }
|
|
||||||
|
|
||||||
/// <summary>Display-only geometry; null when the drawing has none.</summary>
|
|
||||||
public SKPath Display { get; }
|
|
||||||
|
|
||||||
/// <summary>What picks the part: its material, or an etch-only part's marks.</summary>
|
|
||||||
public SKPath Selection { get; }
|
|
||||||
|
|
||||||
/// <summary>The bounds of every stroke in the program frame; null when nothing is drawn.</summary>
|
|
||||||
public Box StrokeBounds { get; }
|
|
||||||
|
|
||||||
public static PartPaths Build(Part part) => new(PartDisplayGeometry.Build(part));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using SkiaSharp;
|
|
||||||
using DrawingColor = System.Drawing.Color;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia.Rendering
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A colour scheme's colours as Skia colours, for the plate canvas. Immutable, so the render
|
|
||||||
/// thread may read one while the UI thread changes the scheme; <see cref="For"/> returns the
|
|
||||||
/// cached palette until the scheme's colours change.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class SkiaSchemePalette
|
|
||||||
{
|
|
||||||
// The WinForms view's translucent selection fill, used when the scheme sets none.
|
|
||||||
private static readonly DrawingColor LegacySelectedColor = DrawingColor.FromArgb(90, 150, 200, 255);
|
|
||||||
|
|
||||||
private static readonly ConditionalWeakTable<ColorScheme, SkiaSchemePalette> Cache = new();
|
|
||||||
|
|
||||||
private readonly DrawingColor partOutline;
|
|
||||||
private readonly ConcurrentDictionary<SKColor, SKColor> outlines = new();
|
|
||||||
|
|
||||||
private SkiaSchemePalette(ColorScheme scheme)
|
|
||||||
{
|
|
||||||
partOutline = scheme.PartOutlineColor;
|
|
||||||
Version = scheme.Version;
|
|
||||||
Background = ToSk(scheme.BackgroundColor);
|
|
||||||
PlateFill = ToSk(scheme.LayoutFillColor);
|
|
||||||
PlateOutline = ToSk(scheme.LayoutOutlineColor);
|
|
||||||
EdgeSpacing = ToSk(scheme.EdgeSpacingColor);
|
|
||||||
Etch = ToSk(scheme.EtchColor);
|
|
||||||
Origin = ToSk(scheme.OriginColor);
|
|
||||||
Rapid = ToSk(scheme.RapidColor);
|
|
||||||
BoundingBox = ToSk(scheme.BoundingBoxColor);
|
|
||||||
var selected = scheme.SelectedPartColor.IsEmpty ? LegacySelectedColor : scheme.SelectedPartColor;
|
|
||||||
SelectedFill = ToSk(selected);
|
|
||||||
// As WinForms: the scheme's outline, else the legacy selection fill darkened.
|
|
||||||
SelectedOutline = ToSk(scheme.PartOutlineColor.IsEmpty
|
|
||||||
? ColorShade.Dark(LegacySelectedColor)
|
|
||||||
: scheme.PartOutlineColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int Version { get; }
|
|
||||||
|
|
||||||
public SKColor Background { get; }
|
|
||||||
|
|
||||||
public SKColor PlateFill { get; }
|
|
||||||
|
|
||||||
public SKColor PlateOutline { get; }
|
|
||||||
|
|
||||||
public SKColor EdgeSpacing { get; }
|
|
||||||
|
|
||||||
public SKColor Etch { get; }
|
|
||||||
|
|
||||||
public SKColor Origin { get; }
|
|
||||||
|
|
||||||
public SKColor Rapid { get; }
|
|
||||||
|
|
||||||
public SKColor BoundingBox { get; }
|
|
||||||
|
|
||||||
public SKColor SelectedFill { get; }
|
|
||||||
|
|
||||||
public SKColor SelectedOutline { get; }
|
|
||||||
|
|
||||||
/// <summary>The palette for <paramref name="scheme"/>'s current colours.</summary>
|
|
||||||
public static SkiaSchemePalette For(ColorScheme scheme)
|
|
||||||
{
|
|
||||||
lock (Cache)
|
|
||||||
{
|
|
||||||
if (!Cache.TryGetValue(scheme, out var palette) || palette.Version != scheme.Version)
|
|
||||||
{
|
|
||||||
palette = new SkiaSchemePalette(scheme);
|
|
||||||
Cache.AddOrUpdate(scheme, palette);
|
|
||||||
}
|
|
||||||
|
|
||||||
return palette;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A part's outline for its fill: the scheme's outline colour, or the fill darkened.</summary>
|
|
||||||
public SKColor PartOutline(SKColor fill) =>
|
|
||||||
outlines.GetOrAdd(fill, f => ToSk(partOutline.IsEmpty
|
|
||||||
? ColorShade.Dark(DrawingColor.FromArgb(f.Alpha, f.Red, f.Green, f.Blue))
|
|
||||||
: partOutline));
|
|
||||||
|
|
||||||
public static SKColor ToSk(DrawingColor color) => new(color.R, color.G, color.B, color.A);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Media.Imaging;
|
|
||||||
using OpenNest.Avalonia.Controls;
|
|
||||||
using SkiaSharp;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia.Rendering
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// File > Export's "image as displayed": the plate view drawn as it is shown, at the
|
|
||||||
/// screen's pixel density, by the same Skia drawing that paints it on screen. A .jpg or .jpeg
|
|
||||||
/// file is written as JPEG (as the WinForms export writes), any other as PNG.
|
|
||||||
/// </summary>
|
|
||||||
internal static class ViewImage
|
|
||||||
{
|
|
||||||
private const int JpegQuality = 90;
|
|
||||||
|
|
||||||
public static void Save(PlateCanvas canvas, string path)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(canvas);
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
|
||||||
if (canvas.Bounds.Width < 1 || canvas.Bounds.Height < 1)
|
|
||||||
throw new InvalidOperationException("The plate view is not shown, so there is no image to save.");
|
|
||||||
|
|
||||||
var scaling = TopLevel.GetTopLevel(canvas)?.RenderScaling ?? 1;
|
|
||||||
var size = new PixelSize(
|
|
||||||
System.Math.Max(1, (int)System.Math.Ceiling(canvas.Bounds.Width * scaling)),
|
|
||||||
System.Math.Max(1, (int)System.Math.Ceiling(canvas.Bounds.Height * scaling)));
|
|
||||||
using var bitmap = new RenderTargetBitmap(size, new Vector(96 * scaling, 96 * scaling));
|
|
||||||
// The canvas draws itself (it has no child controls) at the origin: RenderTargetBitmap
|
|
||||||
// .Render would place it where it sits in its parent, shifting it out of the image.
|
|
||||||
using (var context = bitmap.CreateDrawingContext())
|
|
||||||
canvas.Render(context);
|
|
||||||
|
|
||||||
if (!IsJpeg(path))
|
|
||||||
{
|
|
||||||
bitmap.Save(path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avalonia saves PNG only; Skia re-encodes it as JPEG.
|
|
||||||
using var png = new MemoryStream();
|
|
||||||
bitmap.Save(png);
|
|
||||||
png.Position = 0;
|
|
||||||
using var decoded = SKBitmap.Decode(png)
|
|
||||||
?? throw new InvalidOperationException("The plate view image could not be encoded.");
|
|
||||||
using var image = SKImage.FromBitmap(decoded);
|
|
||||||
using var jpeg = image.Encode(SKEncodedImageFormat.Jpeg, JpegQuality)
|
|
||||||
?? throw new InvalidOperationException("The plate view image could not be encoded as JPEG.");
|
|
||||||
using var file = File.Create(path);
|
|
||||||
jpeg.SaveTo(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsJpeg(string path) =>
|
|
||||||
Path.GetExtension(path).ToLowerInvariant() is ".jpg" or ".jpeg";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Avalonia.Layout;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// One number to set, as the WinForms SetValueForm: an "Increment :" box from 0 to a maximum
|
|
||||||
/// shown with four decimals, an optional check box that disables the box, and a Set button
|
|
||||||
/// that is also Enter. Esc or closing the window changes nothing.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class SetValueDialog : Window
|
|
||||||
{
|
|
||||||
public const double DefaultMaximum = 999999;
|
|
||||||
|
|
||||||
/// <summary>What the user set: the value and the option check box.</summary>
|
|
||||||
public sealed record Result(double Value, bool OptionChecked);
|
|
||||||
|
|
||||||
private readonly NumericUpDown value;
|
|
||||||
private readonly CheckBox option;
|
|
||||||
// The last value the box held, which an emptied box keeps, as the WinForms box does.
|
|
||||||
private double lastValid;
|
|
||||||
private readonly double maximum;
|
|
||||||
|
|
||||||
private SetValueDialog(
|
|
||||||
string title, double initialValue, double maximum, string optionText,
|
|
||||||
bool optionChecked)
|
|
||||||
{
|
|
||||||
Title = title;
|
|
||||||
// Wide enough for the option's text on one line, as the WinForms form widens.
|
|
||||||
MinWidth = 300;
|
|
||||||
SizeToContent = SizeToContent.WidthAndHeight;
|
|
||||||
CanResize = false;
|
|
||||||
ShowInTaskbar = false;
|
|
||||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
|
||||||
this.maximum = maximum;
|
|
||||||
lastValid = System.Math.Clamp(initialValue, 0, maximum);
|
|
||||||
|
|
||||||
value = new NumericUpDown
|
|
||||||
{
|
|
||||||
Name = "Value",
|
|
||||||
Minimum = 0,
|
|
||||||
Maximum = (decimal)maximum,
|
|
||||||
Increment = 1,
|
|
||||||
// A typed value out of range goes to the nearest limit, as in WinForms, rather
|
|
||||||
// than being refused.
|
|
||||||
ClipValueToMinMax = true,
|
|
||||||
FormatString = "0.0000",
|
|
||||||
Value = (decimal)lastValid,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
|
||||||
};
|
|
||||||
value.ValueChanged += (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.NewValue is { } v)
|
|
||||||
lastValid = (double)v;
|
|
||||||
};
|
|
||||||
var set = new Button { Name = "Set", Content = "Set", IsDefault = true };
|
|
||||||
set.Click += (_, _) => Close(new Result(Current, option.IsChecked == true));
|
|
||||||
|
|
||||||
var row = new Grid
|
|
||||||
{
|
|
||||||
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
|
||||||
ColumnSpacing = 8,
|
|
||||||
};
|
|
||||||
var label = new TextBlock
|
|
||||||
{
|
|
||||||
Text = "Increment :",
|
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
|
||||||
};
|
|
||||||
row.Children.Add(label);
|
|
||||||
Grid.SetColumn(value, 1);
|
|
||||||
row.Children.Add(value);
|
|
||||||
Grid.SetColumn(set, 2);
|
|
||||||
row.Children.Add(set);
|
|
||||||
|
|
||||||
option = new CheckBox
|
|
||||||
{
|
|
||||||
Name = "Option",
|
|
||||||
Content = optionText,
|
|
||||||
IsChecked = optionChecked,
|
|
||||||
IsVisible = !string.IsNullOrEmpty(optionText),
|
|
||||||
};
|
|
||||||
value.IsEnabled = !optionChecked;
|
|
||||||
option.IsCheckedChanged += (_, _) => value.IsEnabled = option.IsChecked != true;
|
|
||||||
|
|
||||||
var panel = new StackPanel { Margin = new Thickness(12), Spacing = 10 };
|
|
||||||
panel.Children.Add(row);
|
|
||||||
panel.Children.Add(option);
|
|
||||||
Content = panel;
|
|
||||||
|
|
||||||
AddHandler(KeyDownEvent, (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.Key != Key.Escape)
|
|
||||||
return;
|
|
||||||
e.Handled = true;
|
|
||||||
Close(null);
|
|
||||||
}, RoutingStrategies.Tunnel);
|
|
||||||
Opened += (_, _) => value.Focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// A value out of range is taken to the nearest limit, as the WinForms box does.
|
|
||||||
private double Current => System.Math.Clamp(lastValid, 0, maximum);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The value and option the user set, or null when they closed the dialog without Set.
|
|
||||||
/// </summary>
|
|
||||||
public static Task<Result> ShowAsync(
|
|
||||||
Window owner, string title, double value, double maximum = DefaultMaximum,
|
|
||||||
string optionText = null, bool optionChecked = false) =>
|
|
||||||
new SetValueDialog(title, value, maximum, optionText, optionChecked)
|
|
||||||
.ShowDialog<Result>(owner);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using OpenNest.Data;
|
|
||||||
using OpenNest.Desktop;
|
|
||||||
|
|
||||||
namespace OpenNest.Avalonia
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Restores a window's size, position and maximized state from the shared user-settings
|
|
||||||
/// file and saves them when it closes, under the same names the WinForms app uses.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Sizes are stored in screen pixels. Avalonia sets the client area, while WinForms stores
|
|
||||||
/// the outer window, so a size saved by one app opens a frame's width different in the
|
|
||||||
/// other; each app's own round trip is exact.
|
|
||||||
/// </remarks>
|
|
||||||
internal sealed class WindowPlacementBinding
|
|
||||||
{
|
|
||||||
private readonly Window window;
|
|
||||||
private readonly string name;
|
|
||||||
private readonly string settingsPath;
|
|
||||||
private PixelPoint? normalPosition;
|
|
||||||
private (int Width, int Height)? normalSize;
|
|
||||||
|
|
||||||
private WindowPlacementBinding(Window window, string name, string settingsPath)
|
|
||||||
{
|
|
||||||
this.window = window;
|
|
||||||
this.name = name;
|
|
||||||
this.settingsPath = settingsPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Call before the window is shown.</summary>
|
|
||||||
public static void Remember(Window window, string name, string settingsPath)
|
|
||||||
{
|
|
||||||
var binding = new WindowPlacementBinding(window, name, settingsPath);
|
|
||||||
binding.Restore();
|
|
||||||
window.Opened += (_, _) => binding.Track();
|
|
||||||
window.PositionChanged += (_, _) => binding.Track();
|
|
||||||
window.PropertyChanged += (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.Property == TopLevel.ClientSizeProperty
|
|
||||||
|| e.Property == Window.WindowStateProperty)
|
|
||||||
binding.Track();
|
|
||||||
};
|
|
||||||
window.Closing += (_, _) => binding.Save();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Restore()
|
|
||||||
{
|
|
||||||
var saved = WindowPlacement.Read(UserSettingsFile.Read(settingsPath), name);
|
|
||||||
var screens = window.Screens?.All;
|
|
||||||
if (saved == null || screens == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var areas = screens
|
|
||||||
.Select(s => s.WorkingArea)
|
|
||||||
.Select(a => new OpenNest.Desktop.PixelRect(a.X, a.Y, a.Width, a.Height))
|
|
||||||
.ToList();
|
|
||||||
var placement = saved.FitTo(areas);
|
|
||||||
var screen = placement.Location is { } at
|
|
||||||
? screens.First(s => s.WorkingArea.Contains(new PixelPoint(at.X, at.Y)))
|
|
||||||
: screens.FirstOrDefault(s => s.IsPrimary) ?? screens.FirstOrDefault();
|
|
||||||
|
|
||||||
if (placement.Location is { } location)
|
|
||||||
{
|
|
||||||
window.WindowStartupLocation = WindowStartupLocation.Manual;
|
|
||||||
window.Position = new PixelPoint(location.X, location.Y);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (placement.Size is { } size)
|
|
||||||
{
|
|
||||||
var scaling = screen?.Scaling ?? 1;
|
|
||||||
window.Width = size.Width / scaling;
|
|
||||||
window.Height = size.Height / scaling;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (placement.Maximized)
|
|
||||||
window.WindowState = WindowState.Maximized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Remembers the normal bounds, which maximizing keeps.</summary>
|
|
||||||
private void Track()
|
|
||||||
{
|
|
||||||
if (window.WindowState != WindowState.Normal || !window.IsVisible)
|
|
||||||
return;
|
|
||||||
|
|
||||||
normalPosition = window.Position;
|
|
||||||
var scaling = window.DesktopScaling;
|
|
||||||
normalSize = ((int)System.Math.Round(window.ClientSize.Width * scaling),
|
|
||||||
(int)System.Math.Round(window.ClientSize.Height * scaling));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Save()
|
|
||||||
{
|
|
||||||
Track();
|
|
||||||
var maximized = window.WindowState == WindowState.Maximized;
|
|
||||||
UserSettingsFile.Update(settingsPath, values =>
|
|
||||||
{
|
|
||||||
// A window never shown normal this session keeps the bounds saved before.
|
|
||||||
var previous = WindowPlacement.Read(values, name);
|
|
||||||
var placement = new WindowPlacement(
|
|
||||||
normalPosition is { } p ? (p.X, p.Y) : previous?.Location,
|
|
||||||
normalSize ?? previous?.Size,
|
|
||||||
maximized);
|
|
||||||
placement.WriteTo(values, name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.Clamps;
|
|
||||||
using OpenNest.CutOffs;
|
|
||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.Engine.Jobs.Adapters;
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
@@ -40,19 +37,9 @@ namespace OpenNest.Benchmark
|
|||||||
public double PartSpacing { get; init; }
|
public double PartSpacing { get; init; }
|
||||||
public int Quadrant { get; init; }
|
public int Quadrant { get; init; }
|
||||||
|
|
||||||
/// <summary>Source plate's machine clamp rail, resolved for each candidate size as Auto Nest does; null when none.</summary>
|
|
||||||
public ClampRail ClampRail { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Source plate's explicit clamp zones when it has no rail, offered unchanged on every size.</summary>
|
|
||||||
public IReadOnlyList<ClampZone> ClampZones { get; init; } = Array.Empty<ClampZone>();
|
|
||||||
|
|
||||||
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
|
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
|
||||||
public double SalvageRate { get; init; }
|
public double SalvageRate { get; init; }
|
||||||
|
|
||||||
/// <summary>Manifest "offcutCut": which edge strips count as a sheet's offcut;
|
|
||||||
/// null for all.</summary>
|
|
||||||
public CutOffAxis? OffcutCut { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
|
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
|
||||||
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
|
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
|
||||||
public List<DrawingRequest> Requests { get; init; }
|
public List<DrawingRequest> Requests { get; init; }
|
||||||
@@ -73,8 +60,7 @@ namespace OpenNest.Benchmark
|
|||||||
/// (unlimited quantity - the engine under test decides how many of each
|
/// (unlimited quantity - the engine under test decides how many of each
|
||||||
/// size it actually uses, and how demand splits across plates). The
|
/// size it actually uses, and how demand splits across plates). The
|
||||||
/// engine owns its own multi-plate/size strategy; this harness no
|
/// engine owns its own multi-plate/size strategy; this harness no
|
||||||
/// longer picks plate sizes on the engine's behalf. Stock comes from
|
/// longer picks plate sizes on the engine's behalf.
|
||||||
/// the same template builder as desktop Auto Nest, so clamps match it.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public NestJob BuildNestJob(
|
public NestJob BuildNestJob(
|
||||||
int maxPlates,
|
int maxPlates,
|
||||||
@@ -85,41 +71,24 @@ namespace OpenNest.Benchmark
|
|||||||
var parts = Requests.Select(r =>
|
var parts = Requests.Select(r =>
|
||||||
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
|
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
|
||||||
);
|
);
|
||||||
// No options would make the builder offer the template itself; an empty pool stays empty.
|
var stock = CandidateSizes.Select(size => new NestPlateStock(
|
||||||
var stock = CandidateSizes.Count == 0
|
size.ToString(1),
|
||||||
? Array.Empty<NestPlateStock>()
|
size,
|
||||||
: NestStockBuilder.FromTemplate(
|
null,
|
||||||
StockTemplate(),
|
PartSpacing,
|
||||||
CandidateSizes.Select(size => new PlateOption { Width = size.Width, Length = size.Length }).ToList()
|
EdgeSpacing,
|
||||||
);
|
Quadrant
|
||||||
|
));
|
||||||
return new NestJob(
|
return new NestJob(
|
||||||
parts,
|
parts,
|
||||||
stock,
|
stock,
|
||||||
new NestJobOptions(
|
new NestJobOptions(
|
||||||
"Lattice",
|
"Fill",
|
||||||
maxPlates,
|
maxPlates,
|
||||||
salvageRate ?? SalvageRate,
|
salvageRate ?? SalvageRate,
|
||||||
minimumSalvageDimension ?? 0,
|
minimumSalvageDimension ?? 0
|
||||||
offcutCut: OffcutCut
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A plate carrying this job's spacing, quadrant and clamps, sized to the largest candidate.</summary>
|
|
||||||
private Plate StockTemplate()
|
|
||||||
{
|
|
||||||
var template = new Plate(CandidateSizes.MaxBy(size => size.Width * size.Length))
|
|
||||||
{
|
|
||||||
PartSpacing = PartSpacing,
|
|
||||||
EdgeSpacing = EdgeSpacing,
|
|
||||||
Quadrant = Quadrant,
|
|
||||||
};
|
|
||||||
if (ClampRail != null)
|
|
||||||
template.SetClampRail(ClampRail);
|
|
||||||
else
|
|
||||||
foreach (var zone in ClampZones)
|
|
||||||
template.ClampZones.Add(zone);
|
|
||||||
return template;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ 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
|
||||||
{
|
{
|
||||||
@@ -29,15 +28,6 @@ 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,
|
||||||
@@ -45,9 +35,7 @@ namespace OpenNest.Benchmark
|
|||||||
double? minimumSalvageDimension = null,
|
double? minimumSalvageDimension = null,
|
||||||
string outputDirectory = null,
|
string outputDirectory = null,
|
||||||
int maxParallelism = 1,
|
int maxParallelism = 1,
|
||||||
System.IO.TextWriter progressLog = null,
|
System.IO.TextWriter progressLog = null
|
||||||
TimeSpan? timeLimit = null,
|
|
||||||
CommonLineSetting commonLines = null
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
|
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
|
||||||
@@ -83,9 +71,7 @@ namespace OpenNest.Benchmark
|
|||||||
salvageRate,
|
salvageRate,
|
||||||
minimumSalvageDimension,
|
minimumSalvageDimension,
|
||||||
outputDirectory,
|
outputDirectory,
|
||||||
progressLog,
|
progressLog
|
||||||
timeLimit,
|
|
||||||
commonLines
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -140,7 +126,14 @@ namespace OpenNest.Benchmark
|
|||||||
.BaselinePlateRuns.Select(
|
.BaselinePlateRuns.Select(
|
||||||
(run, index) =>
|
(run, index) =>
|
||||||
{
|
{
|
||||||
var stock = DrawingJobMapper.FromPlate($"baseline-{index}", run.Plate, 1);
|
var stock = new NestPlateStock(
|
||||||
|
$"baseline-{index}",
|
||||||
|
run.Plate.Size,
|
||||||
|
1,
|
||||||
|
run.Plate.PartSpacing,
|
||||||
|
run.Plate.EdgeSpacing,
|
||||||
|
run.Plate.Quadrant
|
||||||
|
);
|
||||||
var placements = run
|
var placements = run
|
||||||
.Parts.Select(part =>
|
.Parts.Select(part =>
|
||||||
{
|
{
|
||||||
@@ -228,22 +221,13 @@ namespace OpenNest.Benchmark
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The harness's own limit on a solve: five minutes, or a --time-limit plus a
|
|
||||||
/// grace period, so the engine's limit always fires first.</summary>
|
|
||||||
private static TimeSpan HarnessTimeout(TimeSpan? timeLimit) =>
|
|
||||||
timeLimit is { } limit && limit + TimeLimitGrace > SolveTimeout
|
|
||||||
? limit + TimeLimitGrace
|
|
||||||
: SolveTimeout;
|
|
||||||
|
|
||||||
private static JobResult RunOne(
|
private static JobResult RunOne(
|
||||||
BenchmarkJob job,
|
BenchmarkJob job,
|
||||||
NestingEngineInfo engineInfo,
|
NestingEngineInfo engineInfo,
|
||||||
double? salvageRate,
|
double? salvageRate,
|
||||||
double? minimumSalvageDimension,
|
double? minimumSalvageDimension,
|
||||||
string outputDirectory,
|
string outputDirectory,
|
||||||
System.IO.TextWriter progressLog,
|
System.IO.TextWriter progressLog
|
||||||
TimeSpan? timeLimit,
|
|
||||||
CommonLineSetting commonLines = null
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var requested = job.TotalRequestedQuantity;
|
var requested = job.TotalRequestedQuantity;
|
||||||
@@ -257,22 +241,8 @@ 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();
|
||||||
if (timeLimit is { } limit)
|
using var cts = new CancellationTokenSource(SolveTimeout);
|
||||||
engine = new DeadlineNestingEngine(engine, limit);
|
var jobResult = engine.Solve(nestJob, log, cts.Token);
|
||||||
using var cts = new CancellationTokenSource(HarnessTimeout(timeLimit));
|
|
||||||
NestJobResult jobResult;
|
|
||||||
if (commonLines != null)
|
|
||||||
{
|
|
||||||
// Through the desktop's pipeline, so its common-line trial runs; every part
|
|
||||||
// may pair. "off" is the like-for-like baseline for the levels.
|
|
||||||
nestJob = commonLines.Apply(nestJob);
|
|
||||||
var pipeline = NestPipeline.Run(engine, engineInfo.Name, nestJob,
|
|
||||||
job.Requests.ToDictionary(r => r.Drawing.Id.ToString(), r => r.Drawing), log, cts.Token);
|
|
||||||
nestJob = pipeline.Job;
|
|
||||||
jobResult = pipeline.Raw;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
jobResult = engine.Solve(nestJob, log, cts.Token);
|
|
||||||
log?.Finished(jobResult, sw.ElapsedMilliseconds);
|
log?.Finished(jobResult, sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
|
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
|
||||||
@@ -393,7 +363,7 @@ namespace OpenNest.Benchmark
|
|||||||
ElapsedMs = sw.ElapsedMilliseconds,
|
ElapsedMs = sw.ElapsedMilliseconds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is OperationCanceledException || ex is TimeoutException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
log?.Failed("timed out", sw.ElapsedMilliseconds);
|
log?.Failed("timed out", sw.ElapsedMilliseconds);
|
||||||
@@ -405,9 +375,7 @@ namespace OpenNest.Benchmark
|
|||||||
PartsRequested = requested,
|
PartsRequested = requested,
|
||||||
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||||
ElapsedMs = sw.ElapsedMilliseconds,
|
ElapsedMs = sw.ElapsedMilliseconds,
|
||||||
Error = ex is TimeoutException
|
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
|
||||||
? ex.Message
|
|
||||||
: $"Timed out after {HarnessTimeout(timeLimit).TotalMinutes:0.#} minute(s)",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
using System.Linq;
|
|
||||||
using OpenNest.CommonLines;
|
|
||||||
using OpenNest.Engine.Jobs;
|
|
||||||
|
|
||||||
namespace OpenNest.Benchmark
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// <c>--common-lines</c>: solve every job through <see cref="NestPipeline"/> at this usage
|
|
||||||
/// level and kerf, with every part allowed to share a common line.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record CommonLineSetting(CommonLineUsage Usage, double Kerf)
|
|
||||||
{
|
|
||||||
public NestJob Apply(NestJob job)
|
|
||||||
{
|
|
||||||
var o = job.Options;
|
|
||||||
var options = new NestJobOptions(o.PlacementStrategy, o.MaxPlates, o.SalvageRate,
|
|
||||||
o.MinimumSalvageDimension, o.SearchUntilCancelled, o.OffcutCut)
|
|
||||||
{
|
|
||||||
CommonLineUsage = Usage,
|
|
||||||
CommonLineKerf = Kerf,
|
|
||||||
};
|
|
||||||
var parts = job.Parts.Select(p => new NestJobPart(p.Id, p.Geometry, p.Quantity, p.Priority, p.Rotation)
|
|
||||||
{
|
|
||||||
AllowCommonLine = true,
|
|
||||||
});
|
|
||||||
return new NestJob(parts, job.Plates, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ using System.Collections.Generic;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using OpenNest.CutOffs;
|
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
|
|
||||||
@@ -53,24 +52,9 @@ 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
|
||||||
@@ -151,18 +135,12 @@ 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.
|
||||||
// Quarter turns are a 90° sweep from 0 to 270°; automatic rotation allows any angle.
|
if (!part.AllowRotation)
|
||||||
var rotations = RotationSet(part);
|
|
||||||
if (rotations != RotationChoice.Any)
|
|
||||||
{
|
{
|
||||||
var quarter = rotations == RotationChoice.QuarterTurns;
|
|
||||||
drawing.Constraints ??= new NestConstraints();
|
drawing.Constraints ??= new NestConstraints();
|
||||||
drawing.Constraints.StepAngle = quarter
|
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI;
|
||||||
? OpenNest.Math.Angle.HalfPI
|
|
||||||
: OpenNest.Math.Angle.TwoPI;
|
|
||||||
drawing.Constraints.StartAngle = 0;
|
drawing.Constraints.StartAngle = 0;
|
||||||
drawing.Constraints.EndAngle = quarter ? 3 * OpenNest.Math.Angle.HalfPI : 0;
|
drawing.Constraints.EndAngle = 0;
|
||||||
drawing.Constraints.Allow180Equivalent = rotations == RotationChoice.HalfTurns;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var constraints = drawing.Constraints;
|
var constraints = drawing.Constraints;
|
||||||
@@ -178,51 +156,12 @@ 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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +170,6 @@ 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; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.Clamps;
|
|
||||||
using OpenNest.Engine.BestFit;
|
using OpenNest.Engine.BestFit;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
@@ -75,8 +74,6 @@ namespace OpenNest.Benchmark
|
|||||||
EdgeSpacing = template.EdgeSpacing,
|
EdgeSpacing = template.EdgeSpacing,
|
||||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||||
Quadrant = template.Quadrant,
|
Quadrant = template.Quadrant,
|
||||||
ClampRail = template.ClampRail,
|
|
||||||
ClampZones = template.ClampZones,
|
|
||||||
SalvageRate = nest.SalvageRate,
|
SalvageRate = nest.SalvageRate,
|
||||||
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
|
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
|
||||||
Requests = requests,
|
Requests = requests,
|
||||||
@@ -185,45 +182,23 @@ namespace OpenNest.Benchmark
|
|||||||
plate.EdgeSpacing.Top
|
plate.EdgeSpacing.Top
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
// Clamps are part of the sheet the layout was made on.
|
|
||||||
if (plate.ClampRail != null)
|
|
||||||
validationPlate.SetClampRail(plate.ClampRail, plate.RemovedClamps);
|
|
||||||
else
|
|
||||||
foreach (var zone in plate.ClampZones)
|
|
||||||
validationPlate.ClampZones.Add(zone);
|
|
||||||
for (var copy = 0; copy < plate.Quantity; copy++)
|
for (var copy = 0; copy < plate.Quantity; copy++)
|
||||||
runs.Add((validationPlate, parts));
|
runs.Add((validationPlate, parts));
|
||||||
}
|
}
|
||||||
return runs.Count > 0 ? runs : null;
|
return runs.Count > 0 ? runs : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (
|
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
|
||||||
Spacing EdgeSpacing,
|
Nest nest
|
||||||
double PartSpacing,
|
)
|
||||||
int Quadrant,
|
|
||||||
ClampRail ClampRail,
|
|
||||||
IReadOnlyList<ClampZone> ClampZones
|
|
||||||
) ResolvePlateTemplate(Nest nest)
|
|
||||||
{
|
{
|
||||||
var source = nest.Plates?.FirstOrDefault();
|
var source = nest.Plates?.FirstOrDefault();
|
||||||
|
|
||||||
if (source != null)
|
if (source != null)
|
||||||
return (
|
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
|
||||||
source.EdgeSpacing,
|
|
||||||
source.PartSpacing,
|
|
||||||
source.Quadrant,
|
|
||||||
source.ClampRail,
|
|
||||||
source.ClampRail == null ? source.ClampZones.ToList() : Array.Empty<ClampZone>()
|
|
||||||
);
|
|
||||||
|
|
||||||
var defaults = nest.PlateDefaults;
|
var defaults = nest.PlateDefaults;
|
||||||
return (
|
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
|
||||||
defaults.EdgeSpacing,
|
|
||||||
defaults.PartSpacing,
|
|
||||||
defaults.Quadrant,
|
|
||||||
defaults.ClampRail,
|
|
||||||
Array.Empty<ClampZone>()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Size> ResolveSheetSizes(Nest nest)
|
private static List<Size> ResolveSheetSizes(Nest nest)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest;
|
using OpenNest;
|
||||||
using OpenNest.Benchmark;
|
using OpenNest.Benchmark;
|
||||||
using OpenNest.CommonLines;
|
|
||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
@@ -19,9 +18,6 @@ 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();
|
||||||
@@ -128,9 +124,7 @@ static class BenchmarkConsole
|
|||||||
options.MinimumSalvageDimension,
|
options.MinimumSalvageDimension,
|
||||||
options.OutputDirectory,
|
options.OutputDirectory,
|
||||||
options.Parallel,
|
options.Parallel,
|
||||||
options.Progress ? Console.Out : null,
|
options.Progress ? Console.Out : null
|
||||||
options.TimeLimit,
|
|
||||||
options.CommonLines
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Report.PrintDetailed(results);
|
Report.PrintDetailed(results);
|
||||||
@@ -203,31 +197,10 @@ static class BenchmarkConsole
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "--time-limit":
|
|
||||||
o.TimeLimit = i + 1 < args.Length ? ParseTimeLimit(args[++i]) : null;
|
|
||||||
if (o.TimeLimit == null)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
"Error: --time-limit needs a number of seconds from 0.001 to "
|
|
||||||
+ $"{BenchmarkRunner.MaxTimeLimit.TotalSeconds:0}"
|
|
||||||
);
|
|
||||||
o.Invalid = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "--progress":
|
case "--progress":
|
||||||
o.Progress = true;
|
o.Progress = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "--common-lines" when i + 1 < args.Length:
|
|
||||||
o.CommonLineLevel = args[++i];
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "--kerf" when i + 1 < args.Length:
|
|
||||||
o.Kerf = double.TryParse(args[++i], System.Globalization.NumberStyles.Float,
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture, out var kerf) ? kerf : double.NaN;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "--help":
|
case "--help":
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
return null;
|
return null;
|
||||||
@@ -239,25 +212,6 @@ static class BenchmarkConsole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (o.CommonLineLevel != null)
|
|
||||||
{
|
|
||||||
CommonLineUsage? usage = o.CommonLineLevel.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"off" => CommonLineUsage.Off,
|
|
||||||
"when-it-adds-parts" => CommonLineUsage.WhenItAddsParts,
|
|
||||||
"whenever" => CommonLineUsage.Whenever,
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
if (usage == null || o.Kerf is not { } kerf || !double.IsFinite(kerf) || kerf <= 0)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
"Error: --common-lines needs off, when-it-adds-parts or whenever, and a positive --kerf"
|
|
||||||
);
|
|
||||||
o.Invalid = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
o.CommonLines = new CommonLineSetting(usage.Value, kerf);
|
|
||||||
}
|
|
||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,22 +235,6 @@ 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(
|
||||||
@@ -392,24 +330,6 @@ static class BenchmarkConsole
|
|||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
" --progress Log each solve's start, engine progress and finish"
|
" --progress Log each solve's start, engine progress and finish"
|
||||||
);
|
);
|
||||||
Console.Error.WriteLine(
|
|
||||||
" --time-limit <seconds> Stop each solve at this wall-clock limit and score"
|
|
||||||
);
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
" the best valid plan reported; none = timed out"
|
|
||||||
);
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
" --common-lines <level> Solve through the Auto Nest pipeline with every part allowed"
|
|
||||||
);
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
" to share a common line: off, when-it-adds-parts or whenever"
|
|
||||||
);
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
" (needs --kerf; off is the baseline to compare levels with)"
|
|
||||||
);
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
" --kerf <value> Common-line kerf in job units (with --common-lines)"
|
|
||||||
);
|
|
||||||
Console.Error.WriteLine(" --help Show this message");
|
Console.Error.WriteLine(" --help Show this message");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,10 +345,5 @@ static class BenchmarkConsole
|
|||||||
public double? MinimumSalvageDimension;
|
public double? MinimumSalvageDimension;
|
||||||
public int Parallel = 3;
|
public int Parallel = 3;
|
||||||
public bool Progress;
|
public bool Progress;
|
||||||
public TimeSpan? TimeLimit;
|
|
||||||
public string CommonLineLevel;
|
|
||||||
public double? Kerf;
|
|
||||||
public CommonLineSetting CommonLines;
|
|
||||||
public bool Invalid;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+27
-126
@@ -24,9 +24,6 @@ return NestConsole.Run(args);
|
|||||||
|
|
||||||
static class NestConsole
|
static class NestConsole
|
||||||
{
|
{
|
||||||
private const string OutlinedSheetMessage =
|
|
||||||
"Error: Fill strategies need a rectangular sheet. Use --autonest --engine Irregular.";
|
|
||||||
|
|
||||||
public static int Run(string[] args)
|
public static int Run(string[] args)
|
||||||
{
|
{
|
||||||
using var cancellation = new CancellationTokenSource();
|
using var cancellation = new CancellationTokenSource();
|
||||||
@@ -38,7 +35,7 @@ static class NestConsole
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Nesting canceled; nothing saved.");
|
Console.Error.WriteLine("Nesting cancelled; nothing saved.");
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -118,7 +115,7 @@ static class NestConsole
|
|||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
engine.IsJobEngine
|
engine.IsJobEngine
|
||||||
? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
||||||
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as Irregular require --autonest)"
|
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as StockLadder require --autonest)"
|
||||||
);
|
);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -135,26 +132,14 @@ 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.OutlineFile != null || options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
|
|| 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, --outline or --template."
|
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size 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();
|
||||||
@@ -212,12 +197,6 @@ static class NestConsole
|
|||||||
ApplyTemplate(plate, options);
|
ApplyTemplate(plate, options);
|
||||||
ApplyOverrides(plate, options);
|
ApplyOverrides(plate, options);
|
||||||
|
|
||||||
if (!options.AutoNest && PlateFillService.Refusal(plate) != null)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine(OutlinedSheetMessage);
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
var drawing = ResolveDrawing(nest, options);
|
var drawing = ResolveDrawing(nest, options);
|
||||||
|
|
||||||
if (drawing == null)
|
if (drawing == null)
|
||||||
@@ -278,9 +257,6 @@ 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;
|
||||||
@@ -300,9 +276,6 @@ 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;
|
||||||
@@ -411,10 +384,9 @@ static class NestConsole
|
|||||||
return stock;
|
return stock;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool IsCadFile(string path) => CadFormats.IsCadFile(path);
|
static bool IsCadFile(string path) =>
|
||||||
|
path.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||||
static Units DrawingUnits(Options options) =>
|
|| path.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase);
|
||||||
options.CadUnits == BendRepairUnits.Millimeters ? Units.Millimeters : Units.Inches;
|
|
||||||
|
|
||||||
static Nest LoadOrCreateNest(Options options)
|
static Nest LoadOrCreateNest(Options options)
|
||||||
{
|
{
|
||||||
@@ -460,7 +432,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/.svg) files specified");
|
Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg) files specified");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,12 +479,8 @@ 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})"
|
||||||
@@ -558,22 +526,6 @@ 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)
|
||||||
@@ -637,13 +589,17 @@ static class NestConsole
|
|||||||
|
|
||||||
if (options.AutoNest)
|
if (options.AutoNest)
|
||||||
{
|
{
|
||||||
var nestItems = AutoNestItems(nest, plate, drawing, options);
|
var nestItems = new List<NestItem>();
|
||||||
if (nestItems.Count == 0)
|
var qty = options.Quantity > 0 ? options.Quantity : 1;
|
||||||
|
|
||||||
|
if (options.DrawingName != null)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
nestItems.Add(new NestItem { Drawing = drawing, Quantity = qty });
|
||||||
"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(
|
||||||
@@ -681,38 +637,6 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -789,14 +713,14 @@ static class NestConsole
|
|||||||
var total = 0;
|
var total = 0;
|
||||||
foreach (var plate in plates.Where(p => p.Parts.Count > 0))
|
foreach (var plate in plates.Where(p => p.Parts.Count > 0))
|
||||||
{
|
{
|
||||||
var overlapping = PartOverlapChecker.FindOverlappingPairs(plate.Parts);
|
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
||||||
var label = plates.Count > 1 ? $"Plate {nest.Plates.IndexOf(plate)}: " : "";
|
var label = plates.Count > 1 ? $"Plate {nest.Plates.IndexOf(plate)}: " : "";
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
overlapping.Count > 0
|
hasOverlaps
|
||||||
? $"{label}OVERLAPS DETECTED: {overlapping.Count} overlapping pair(s)"
|
? $"{label}OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
||||||
: $"{label}Overlap check: PASS"
|
: $"{label}Overlap check: PASS"
|
||||||
);
|
);
|
||||||
total += overlapping.Count;
|
total += overlapPts.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
return total;
|
return total;
|
||||||
@@ -900,7 +824,6 @@ static class NestConsole
|
|||||||
{
|
{
|
||||||
CuttingPlanPhase.Reordering => "finding a part order...",
|
CuttingPlanPhase.Reordering => "finding a part order...",
|
||||||
CuttingPlanPhase.KeepingOrder => "keeping the current order...",
|
CuttingPlanPhase.KeepingOrder => "keeping the current order...",
|
||||||
CuttingPlanPhase.SafestRoute => "finding the safest route...",
|
|
||||||
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
|
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
|
||||||
_ => value.Phase + "...",
|
_ => value.Phase + "...",
|
||||||
});
|
});
|
||||||
@@ -1013,17 +936,6 @@ static class NestConsole
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A common line cut once needs a post that writes it; none falls back to cutting it twice.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
OpenNest.CNC.CommonLineCuts.EnsurePostable(nest, post);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"Error: {ex.Message} No output was written.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var verification = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
|
var verification = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
|
||||||
Console.WriteLine(verification.ToDisplayText());
|
Console.WriteLine(verification.ToDisplayText());
|
||||||
if (!verification.CanPost(options.AcknowledgePostRisks))
|
if (!verification.CanPost(options.AcknowledgePostRisks))
|
||||||
@@ -1068,7 +980,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/.svg drawing files
|
input-files One or more .nest nest files or .dxf/.dwg drawing files
|
||||||
|
|
||||||
Modes:
|
Modes:
|
||||||
<nest.nest> Load nest and fill (existing behavior)
|
<nest.nest> Load nest and fill (existing behavior)
|
||||||
@@ -1077,19 +989,12 @@ 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). With --autonest: copies of
|
--quantity <n> Max parts to place (default: 0 = unlimited)
|
||||||
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
|
||||||
@@ -1098,10 +1003,8 @@ static class NestConsole
|
|||||||
--stock <WxL[:cost],...> With --autonest: sheet sizes to offer, each unlimited (default: the
|
--stock <WxL[:cost],...> With --autonest: sheet sizes to offer, each unlimited (default: the
|
||||||
target plate's size); spacing, quadrant and clamps follow the target
|
target plate's size); spacing, quadrant and clamps follow the target
|
||||||
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
|
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
|
||||||
--engine <name> With --autonest: jobs engine (default: Default; also Irregular,
|
--engine <name> With --autonest: jobs engine (default: Default; also Irregular, Rectangles, Fill, ...).
|
||||||
Rectangles, Lattice, ...). Without --autonest: fill strategy
|
Without --autonest: fill strategy (Fill (default), Strip, Vertical Remnant, Horizontal Remnant)
|
||||||
(Lattice (default), Strip, Vertical Remnant,
|
|
||||||
Horizontal Remnant)
|
|
||||||
--keep-parts Don't clear existing parts before filling
|
--keep-parts Don't clear existing parts before filling
|
||||||
--no-fill Keep the loaded .nest as it is (no fill); plan, save or post it
|
--no-fill Keep the loaded .nest as it is (no fill); plan, save or post it
|
||||||
--plan-cutting After filling (or loading with --no-fill), plan cutting (part order,
|
--plan-cutting After filling (or loading with --no-fill), plan cutting (part order,
|
||||||
@@ -1133,7 +1036,6 @@ 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;
|
||||||
@@ -1156,6 +1058,5 @@ static class NestConsole
|
|||||||
public bool AllPlates;
|
public bool AllPlates;
|
||||||
public double? RepairBendsMillimeters;
|
public double? RepairBendsMillimeters;
|
||||||
public BendRepairUnits CadUnits;
|
public BendRepairUnits CadUnits;
|
||||||
public double? SvgUnitsPerInch;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.CutOffs;
|
|
||||||
|
|
||||||
namespace OpenNest;
|
namespace OpenNest;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
namespace OpenNest.CutOffs;
|
namespace OpenNest;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plans and applies the same automatic-cutoff settings to a stable, ordered set of plates.
|
/// Plans and applies the same automatic-cutoff settings to a stable, ordered set of plates.
|
||||||
+34
-235
@@ -1,13 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.Clamps;
|
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.CutOffs;
|
namespace OpenNest;
|
||||||
|
|
||||||
public sealed class AutomaticCutOffOptions
|
public sealed class AutomaticCutOffOptions
|
||||||
{
|
{
|
||||||
@@ -28,14 +27,6 @@ public sealed class AutomaticCutOffOptions
|
|||||||
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
|
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double? MinimumStripLength { get; set; }
|
public double? MinimumStripLength { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to propose a full-length horizontal separator that keeps the unused band beyond the
|
|
||||||
/// parts along Y (away from the origin) when that band is a larger piece than the X tail.
|
|
||||||
/// The band must be at least MinimumTailLength wide. False preserves vertical-only proposals.
|
|
||||||
/// An existing full-length horizontal cut-off clear of the parts is honored either way.
|
|
||||||
/// </summary>
|
|
||||||
public bool KeepHorizontalRemnant { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AutomaticCutOffDiagnosticCode
|
public enum AutomaticCutOffDiagnosticCode
|
||||||
@@ -48,9 +39,6 @@ public enum AutomaticCutOffDiagnosticCode
|
|||||||
NoSafeTailSeparator,
|
NoSafeTailSeparator,
|
||||||
TailBelowMinimum,
|
TailBelowMinimum,
|
||||||
ShiftedCut,
|
ShiftedCut,
|
||||||
HorizontalRemnant,
|
|
||||||
NoSafeHorizontalSeparator,
|
|
||||||
ClampMovedTailSeparator,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record AutomaticCutOffDiagnostic(
|
public sealed record AutomaticCutOffDiagnostic(
|
||||||
@@ -89,15 +77,6 @@ public sealed class AutomaticCutOffPlan
|
|||||||
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
|
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
|
||||||
public bool HasSeparatedTail => TailSeparatorX.HasValue;
|
public bool HasSeparatedTail => TailSeparatorX.HasValue;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Signed Y coordinate of the new or existing full-length horizontal separator the vertical
|
|
||||||
/// lines stop at, keeping the band beyond it; null when the lines cross the full sheet width.
|
|
||||||
/// </summary>
|
|
||||||
public double? HorizontalSeparatorY { get; internal set; }
|
|
||||||
|
|
||||||
/// <summary>Width along Y of the band kept beyond HorizontalSeparatorY, otherwise zero.</summary>
|
|
||||||
public double HorizontalRemnantWidth { get; internal set; }
|
|
||||||
|
|
||||||
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
|
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
|
||||||
public double StripCutLength { get; internal set; }
|
public double StripCutLength { get; internal set; }
|
||||||
|
|
||||||
@@ -111,15 +90,6 @@ public sealed class AutomaticCutOffPlan
|
|||||||
public static class AutomaticCutOffPlanner
|
public static class AutomaticCutOffPlanner
|
||||||
{
|
{
|
||||||
public const double GeometryTolerance = Tolerance.Epsilon;
|
public const double GeometryTolerance = Tolerance.Epsilon;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How far a display perimeter (a supplied blank's never-cut outline) may extend past the
|
|
||||||
/// physical sheet, in model units. Imported blanks equal their sheet but carry the source CAM's
|
|
||||||
/// rounding (PEP: a 0.000006 outline skew plus placements rounded to 0.001), a few hundred-
|
|
||||||
/// thousandths of an inch. Nothing cuts that outline, so it is accepted; anything cut, or a
|
|
||||||
/// larger overhang that signals a real placement error, is still refused.
|
|
||||||
/// </summary>
|
|
||||||
public const double DisplayPerimeterSheetAllowance = 1e-4;
|
|
||||||
public const double MinimumSpacing = 2 * GeometryTolerance;
|
public const double MinimumSpacing = 2 * GeometryTolerance;
|
||||||
public const int MaximumCandidateCount = 10000;
|
public const int MaximumCandidateCount = 10000;
|
||||||
public const int MaximumShiftCandidateCount = 20000;
|
public const int MaximumShiftCandidateCount = 20000;
|
||||||
@@ -145,9 +115,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
|
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
|
||||||
|
|
||||||
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
|
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
|
||||||
var ySign = plate.Quadrant is 3 or 4 ? -1 : 1;
|
|
||||||
var occupied = 0.0;
|
var occupied = 0.0;
|
||||||
var occupiedY = 0.0;
|
|
||||||
var hasParts = false;
|
var hasParts = false;
|
||||||
foreach (var part in plate.Parts)
|
foreach (var part in plate.Parts)
|
||||||
{
|
{
|
||||||
@@ -155,9 +123,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
if (part.BaseDrawing.IsCutOff)
|
if (part.BaseDrawing.IsCutOff)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var (partX, partY) = ValidatePart(part, bounds, sign, ySign);
|
occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign));
|
||||||
occupied = System.Math.Max(occupied, partX);
|
|
||||||
occupiedY = System.Math.Max(occupiedY, partY);
|
|
||||||
hasParts = true;
|
hasParts = true;
|
||||||
}
|
}
|
||||||
if (!hasParts)
|
if (!hasParts)
|
||||||
@@ -165,76 +131,16 @@ public static class AutomaticCutOffPlanner
|
|||||||
|
|
||||||
// Use distances from the coordinate origin, not the sheet's lower-left corner.
|
// Use distances from the coordinate origin, not the sheet's lower-left corner.
|
||||||
var length = plate.Size.Length;
|
var length = plate.Size.Length;
|
||||||
var width = plate.Size.Width;
|
|
||||||
occupied = System.Math.Min(occupied, length);
|
occupied = System.Math.Min(occupied, length);
|
||||||
occupiedY = System.Math.Min(occupiedY, width);
|
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
|
||||||
var margin = System.Math.Max(plate.PartSpacing, settings.PartClearance) + GeometryTolerance;
|
+ GeometryTolerance;
|
||||||
var nominalSeparator = occupied + margin;
|
|
||||||
// A separator on a clamp moves just past it, away from the origin: the tail is a
|
|
||||||
// little shorter, but a clamp in the way no longer costs the whole remnant. Repeat
|
|
||||||
// for a clamp whose clearance the moved line now meets.
|
|
||||||
var separator = nominalSeparator;
|
|
||||||
ClampZone movedPast = null;
|
|
||||||
for (var pass = 0; pass <= clamps.Count && double.IsFinite(separator); pass++)
|
|
||||||
{
|
|
||||||
var blocking = clamps.FirstOrDefault(zone => CrossesClamp(zone, sign * separator));
|
|
||||||
if (blocking == null)
|
|
||||||
break;
|
|
||||||
movedPast = blocking;
|
|
||||||
var farEdge = sign > 0 ? blocking.Bounds.Right : -blocking.Bounds.Left;
|
|
||||||
separator = farEdge + plate.ClampClearance + GeometryTolerance;
|
|
||||||
}
|
|
||||||
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
|
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
|
||||||
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
|
||||||
|
|
||||||
// A band beyond the parts along Y is kept by a full-length horizontal separator; the
|
|
||||||
// vertical lines then stop at it instead of slicing the band. A user's line clear of the
|
|
||||||
// parts is honored; a new one is proposed only when its band beats the X tail.
|
|
||||||
var horizontalDistance = plate.CutOffs
|
|
||||||
.Where(c => c?.Axis == CutOffAxis.Horizontal && Finite(c.Position) && FullLengthLimits(c, bounds, settings))
|
|
||||||
.Select(c => ySign * c.Position.Y)
|
|
||||||
.Where(distance => distance > occupiedY + GeometryTolerance && distance < width - GeometryTolerance
|
|
||||||
&& width - distance >= options.MinimumTailLength)
|
|
||||||
.Select(distance => (double?)distance)
|
|
||||||
.Min();
|
|
||||||
var horizontalExists = horizontalDistance.HasValue;
|
|
||||||
if (!horizontalExists && options.KeepHorizontalRemnant)
|
|
||||||
{
|
|
||||||
var proposed = occupiedY + margin;
|
|
||||||
var tailArea = hasTailCandidate && length - separator >= options.MinimumTailLength
|
|
||||||
? (length - separator) * width : 0;
|
|
||||||
if (double.IsFinite(proposed) && proposed < width - GeometryTolerance
|
|
||||||
&& width - proposed >= options.MinimumTailLength && (width - proposed) * length > tailArea)
|
|
||||||
horizontalDistance = proposed;
|
|
||||||
}
|
|
||||||
if (horizontalDistance.HasValue && !horizontalExists)
|
|
||||||
{
|
|
||||||
var y = ySign * horizontalDistance.Value;
|
|
||||||
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
|
|
||||||
var clamp = clamps.FirstOrDefault(zone =>
|
|
||||||
y >= zone.Bounds.Bottom - plate.ClampClearance && y <= zone.Bounds.Top + plate.ClampClearance);
|
|
||||||
if (limited || clamp != null)
|
|
||||||
{
|
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
|
||||||
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
|
|
||||||
(clamp != null ? $"A horizontal separator at Y = {y:G8} would cross clamp '{clamp.Id}'"
|
|
||||||
: $"A same-line manual cut at Y = {y:G8} has different limits")
|
|
||||||
+ "; vertical lines cross the full sheet width instead."));
|
|
||||||
horizontalDistance = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A tail too short to keep stays attached to the skeleton, so the strip grid must
|
// A tail too short to keep stays attached to the skeleton, so the strip grid must
|
||||||
// continue to the sheet end; stopping at the skipped separator would leave a final
|
// continue to the sheet end; stopping at the skipped separator would leave a final
|
||||||
// strip longer than the spacing. Beside a kept horizontal band the tail is a corner
|
// strip longer than the spacing.
|
||||||
// piece, which must also be wide enough to keep.
|
var keepsTail = hasTailCandidate && length - separator >= options.MinimumTailLength;
|
||||||
bool KeepsTail(double? band) => hasTailCandidate && length - separator >= options.MinimumTailLength
|
|
||||||
&& (band == null || band.Value >= options.MinimumTailLength);
|
|
||||||
var keepsTail = KeepsTail(horizontalDistance);
|
|
||||||
var usedSpan = keepsTail ? separator : length;
|
var usedSpan = keepsTail ? separator : length;
|
||||||
// Bound the count for either outcome: the horizontal separator is verified only below.
|
var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
|
||||||
var candidateCount = System.Math.Ceiling(System.Math.Max(usedSpan, KeepsTail(null) ? separator : length)
|
|
||||||
/ options.Spacing);
|
|
||||||
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
|
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
|
||||||
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
|
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
|
||||||
|
|
||||||
@@ -242,31 +148,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
ValidateExisting(plate, bounds, settings);
|
ValidateExisting(plate, bounds, settings);
|
||||||
var cache = Plate.BuildPerimeterCache(plate);
|
var cache = Plate.BuildPerimeterCache(plate);
|
||||||
var definitions = new List<CutOff>();
|
var definitions = new List<CutOff>();
|
||||||
CutOff horizontalSeparator = null;
|
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
||||||
if (horizontalDistance.HasValue && !horizontalExists)
|
|
||||||
{
|
|
||||||
horizontalSeparator = new CutOff(new Vector(0, ySign * horizontalDistance.Value), CutOffAxis.Horizontal);
|
|
||||||
horizontalSeparator.Regenerate(plate, settings, cache);
|
|
||||||
if (!HasUsableSegments(horizontalSeparator.Drawing.Program, false) ||
|
|
||||||
!IsFullSpanProgram(horizontalSeparator.Drawing.Program, false, bounds.Left, bounds.Right))
|
|
||||||
{
|
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
|
||||||
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
|
|
||||||
"No full-length horizontal separator survives the current settings; "
|
|
||||||
+ "vertical lines cross the full sheet width instead."));
|
|
||||||
horizontalSeparator = null;
|
|
||||||
horizontalDistance = null;
|
|
||||||
keepsTail = KeepsTail(null);
|
|
||||||
usedSpan = keepsTail ? separator : length;
|
|
||||||
candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Vertical lines run from the origin-side edge up to a kept band's separator.
|
|
||||||
var horizontalY = horizontalDistance.HasValue ? ySign * horizontalDistance.Value : (double?)null;
|
|
||||||
var startLimit = ySign < 0 ? horizontalY : null;
|
|
||||||
var endLimit = ySign > 0 ? horizontalY : null;
|
|
||||||
var spanLow = startLimit ?? bounds.Bottom;
|
|
||||||
var spanHigh = endLimit ?? bounds.Top;
|
|
||||||
var separated = false;
|
var separated = false;
|
||||||
var separatorX = (double?)null;
|
var separatorX = (double?)null;
|
||||||
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
|
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
|
||||||
@@ -280,7 +162,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
// Full-span definitions fix the strip boundaries, even when their emitted
|
// Full-span definitions fix the strip boundaries, even when their emitted
|
||||||
// programs are segmented. Partial cuts remain obstacles, not boundaries.
|
// programs are segmented. Partial cuts remain obstacles, not boundaries.
|
||||||
var boundaries = plate.CutOffs
|
var boundaries = plate.CutOffs
|
||||||
.Where(c => c.Axis == CutOffAxis.Vertical && Spans(c))
|
.Where(c => c.Axis == CutOffAxis.Vertical && FullSpanLimits(c, bounds, settings))
|
||||||
.Select(c => sign * c.Position.X)
|
.Select(c => sign * c.Position.X)
|
||||||
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
|
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
|
||||||
.Distinct().OrderBy(distance => distance).Append(usedSpan);
|
.Distinct().OrderBy(distance => distance).Append(usedSpan);
|
||||||
@@ -322,14 +204,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (keepsTail)
|
if (keepsTail)
|
||||||
{
|
|
||||||
if (movedPast != null)
|
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
|
||||||
AutomaticCutOffDiagnosticCode.ClampMovedTailSeparator,
|
|
||||||
$"The tail separator moved from {sign * nominalSeparator:G8} to {sign * separator:G8} "
|
|
||||||
+ $"to clear clamp '{movedPast.Id}' (including its clearance).", X: sign * separator));
|
|
||||||
AddCandidate(sign * separator, true);
|
AddCandidate(sign * separator, true);
|
||||||
}
|
|
||||||
else if (hasTailCandidate)
|
else if (hasTailCandidate)
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
|
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
|
||||||
@@ -337,15 +212,6 @@ public static class AutomaticCutOffPlanner
|
|||||||
+ "and strip lines continue to the sheet end. Existing cut-offs are unchanged. No retained tail is claimed.",
|
+ "and strip lines continue to the sheet end. Existing cut-offs are unchanged. No retained tail is claimed.",
|
||||||
X: sign * separator));
|
X: sign * separator));
|
||||||
|
|
||||||
if (horizontalSeparator != null)
|
|
||||||
definitions.Add(horizontalSeparator);
|
|
||||||
if (horizontalY.HasValue)
|
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
|
||||||
AutomaticCutOffDiagnosticCode.HorizontalRemnant,
|
|
||||||
$"Vertical lines stop at the {(horizontalExists ? "existing" : "new")} horizontal separator at "
|
|
||||||
+ $"Y = {horizontalY.Value:G8}, keeping a {width - horizontalDistance.Value:G8}-wide band "
|
|
||||||
+ "along the sheet length."));
|
|
||||||
|
|
||||||
if (diagnostics.Any(d => d.IsBlocking))
|
if (diagnostics.Any(d => d.IsBlocking))
|
||||||
{
|
{
|
||||||
// A partial proposal must not accidentally be accepted around a manual conflict.
|
// A partial proposal must not accidentally be accepted around a manual conflict.
|
||||||
@@ -353,24 +219,12 @@ public static class AutomaticCutOffPlanner
|
|||||||
stripCutLength = 0;
|
stripCutLength = 0;
|
||||||
separated = false;
|
separated = false;
|
||||||
separatorX = null;
|
separatorX = null;
|
||||||
horizontalY = null;
|
|
||||||
}
|
}
|
||||||
// The legacy placement loop stays untouched. Its detached programs are already
|
// The legacy placement loop stays untouched. Its detached programs are already
|
||||||
// regenerated, so reporting their actual new strip cutting needs no new search.
|
// regenerated, so reporting their actual new strip cutting needs no new search.
|
||||||
if (!shifted)
|
if (!shifted)
|
||||||
fixedGridCutLength = stripCutLength;
|
fixedGridCutLength = stripCutLength;
|
||||||
|
|
||||||
// Lines are planned and measured without the head-locked overcut: it only extends the
|
|
||||||
// segment reaching the far edge past the sheet, where there is nothing to separate.
|
|
||||||
if (settings.NewCutOffHeadLockOvercut > 0)
|
|
||||||
{
|
|
||||||
foreach (var definition in definitions)
|
|
||||||
{
|
|
||||||
definition.HeadLockOvercut = settings.NewCutOffHeadLockOvercut;
|
|
||||||
definition.Regenerate(plate, settings, cache);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AutomaticCutOffPlan
|
return new AutomaticCutOffPlan
|
||||||
{
|
{
|
||||||
Definitions = definitions.AsReadOnly(),
|
Definitions = definitions.AsReadOnly(),
|
||||||
@@ -382,22 +236,8 @@ public static class AutomaticCutOffPlanner
|
|||||||
TailSeparatorX = separatorX,
|
TailSeparatorX = separatorX,
|
||||||
StripCutLength = stripCutLength,
|
StripCutLength = stripCutLength,
|
||||||
FixedGridCutLength = fixedGridCutLength,
|
FixedGridCutLength = fixedGridCutLength,
|
||||||
HorizontalSeparatorY = horizontalY,
|
|
||||||
HorizontalRemnantWidth = horizontalY.HasValue ? width - horizontalDistance.Value : 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
CutOff NewVertical(double x) => new(new Vector(x, 0), CutOffAxis.Vertical)
|
|
||||||
{
|
|
||||||
StartLimit = startLimit,
|
|
||||||
EndLimit = endLimit,
|
|
||||||
};
|
|
||||||
|
|
||||||
// A full-span line, or one with the limits this plan gives its own vertical lines.
|
|
||||||
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
|
|
||||||
horizontalY.HasValue && cut.Gaps.Count == 0 &&
|
|
||||||
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
|
|
||||||
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
|
|
||||||
|
|
||||||
void AddShiftedGap(double start, double end, int count, double step)
|
void AddShiftedGap(double start, double end, int count, double step)
|
||||||
{
|
{
|
||||||
var nominal = new List<double>(count);
|
var nominal = new List<double>(count);
|
||||||
@@ -450,7 +290,8 @@ public static class AutomaticCutOffPlanner
|
|||||||
RegisterCandidate(distance);
|
RegisterCandidate(distance);
|
||||||
if (!measurements.TryGetValue(distance, out var measured))
|
if (!measurements.TryGetValue(distance, out var measured))
|
||||||
{
|
{
|
||||||
measured = NewVertical(sign * distance).MeasureSegments(plate, settings, cache);
|
measured = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical)
|
||||||
|
.MeasureSegments(plate, settings, cache);
|
||||||
measurements.Add(distance, measured);
|
measurements.Add(distance, measured);
|
||||||
}
|
}
|
||||||
return measured;
|
return measured;
|
||||||
@@ -461,7 +302,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
|
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
|
||||||
Near(c.Position.X, x)).ToList();
|
Near(c.Position.X, x)).ToList();
|
||||||
|
|
||||||
if (matches.Any(c => !Spans(c)))
|
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
|
||||||
{
|
{
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
|
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
|
||||||
@@ -483,7 +324,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 its clearance); it was skipped.",
|
$"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.",
|
||||||
X: x));
|
X: x));
|
||||||
if (isSeparator)
|
if (isSeparator)
|
||||||
WarnNoSeparator(x);
|
WarnNoSeparator(x);
|
||||||
@@ -494,9 +335,9 @@ public static class AutomaticCutOffPlanner
|
|||||||
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
|
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
|
||||||
var existing = matches.FirstOrDefault();
|
var existing = matches.FirstOrDefault();
|
||||||
var candidate = existing == null
|
var candidate = existing == null
|
||||||
? NewVertical(x)
|
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
|
||||||
: new CutOff(existing.Position, existing.Axis)
|
: new CutOff(existing.Position, existing.Axis)
|
||||||
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit, Gaps = existing.Gaps };
|
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
|
||||||
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);
|
||||||
@@ -512,7 +353,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
|
|
||||||
if (isSeparator)
|
if (isSeparator)
|
||||||
{
|
{
|
||||||
if (!usable || !IsFullSpanProgram(program, true, spanLow, spanHigh))
|
if (!usable || !IsFullSpanProgram(program, bounds))
|
||||||
{
|
{
|
||||||
WarnNoSeparator(x);
|
WarnNoSeparator(x);
|
||||||
return;
|
return;
|
||||||
@@ -520,7 +361,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
separated = true;
|
separated = true;
|
||||||
separatorX = candidate.Position.X;
|
separatorX = candidate.Position.X;
|
||||||
}
|
}
|
||||||
else if (usable && !IsFullSpanProgram(program, true, spanLow, spanHigh))
|
else if (usable && !IsFullSpanProgram(program, bounds))
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
AutomaticCutOffDiagnosticCode.SegmentedCut,
|
AutomaticCutOffDiagnosticCode.SegmentedCut,
|
||||||
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
|
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
|
||||||
@@ -545,7 +386,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.ClampClearance && x <= zone.Bounds.Right + plate.ClampClearance;
|
x >= zone.Bounds.Left - plate.EdgeSpacing.Bottom && x <= zone.Bounds.Right + plate.EdgeSpacing.Bottom;
|
||||||
|
|
||||||
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
|
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
|
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
|
||||||
@@ -567,22 +408,18 @@ 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.ClampClearance)),
|
Nonnegative(plate.EdgeSpacing.Bottom)),
|
||||||
"Clamp zones and clamp clearance must be finite and valid.", nameof(plate));
|
"Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate));
|
||||||
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
||||||
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
|
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
|
||||||
Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
|
|
||||||
"A head-locked overcut must be finite and positive.", nameof(settings));
|
|
||||||
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
|
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
|
||||||
Require(plate.Parts != null && plate.CutOffs != null,
|
Require(plate.Parts != null && plate.CutOffs != null,
|
||||||
"Plate parts and cut-off collections are required.", nameof(plate));
|
"Plate parts and cut-off collections are required.", nameof(plate));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (double X, double Y) ValidatePart(Part part, Box sheet, int sign, int ySign)
|
private static double ValidatePart(Part part, Box sheet, int sign)
|
||||||
{
|
{
|
||||||
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
|
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
|
||||||
"Part pose must be finite.", "plate");
|
"Part pose must be finite.", "plate");
|
||||||
@@ -595,32 +432,19 @@ public static class AutomaticCutOffPlanner
|
|||||||
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
|
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
|
||||||
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
|
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
|
||||||
"Part geometry has stale bounds; update it before planning.", "plate");
|
"Part geometry has stale bounds; update it before planning.", "plate");
|
||||||
var hasDisplayPerimeter = DisplayPerimeterTail.HasDisplayPerimeterMotion(part.Program);
|
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
||||||
Require(Inside(box, sheet, hasDisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
|
|
||||||
"Part geometry extends outside the physical sheet.", "plate");
|
|
||||||
|
|
||||||
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
|
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
|
||||||
// Checking converted entities catches overflowing incremental moves and curve bounds.
|
// Checking converted entities catches overflowing incremental moves and curve bounds.
|
||||||
var hasMaterial = false;
|
var hasMaterial = false;
|
||||||
var occupied = sign > 0 ? box.Right : -box.Left;
|
var occupied = sign > 0 ? box.Right : -box.Left;
|
||||||
var occupiedY = ySign > 0 ? box.Top : -box.Bottom;
|
|
||||||
var roundoff = CutOff.GetBoundsRoundoff(part);
|
var roundoff = CutOff.GetBoundsRoundoff(part);
|
||||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
|
||||||
var outline = entities.Where(e => e.Layer == SpecialLayers.DisplayPerimeter).ToList();
|
|
||||||
foreach (var entity in entities)
|
|
||||||
{
|
{
|
||||||
var entityBox = entity.BoundingBox;
|
var entityBox = entity.BoundingBox;
|
||||||
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
|
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
|
||||||
if (!SpecialLayers.IsMaterial(entity.Layer))
|
if (!SpecialLayers.IsMaterial(entity.Layer))
|
||||||
{
|
|
||||||
// Travel and marks (a scribe is lasered) keep the ordinary tolerance. Only a rapid
|
|
||||||
// ending on the outline (its first corner, or an applied program's display tail)
|
|
||||||
// shares the outline's allowance.
|
|
||||||
Require(Inside(entityBox.Translate(part.Location), sheet)
|
|
||||||
|| hasDisplayPerimeter && IsRapidOntoOutline(entity, outline, part.Location, sheet),
|
|
||||||
"Part geometry extends outside the physical sheet.", "plate");
|
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
entityBox = entityBox.Translate(part.Location);
|
entityBox = entityBox.Translate(part.Location);
|
||||||
// Permit only bounded floating-point roundoff, with the same conservative
|
// Permit only bounded floating-point roundoff, with the same conservative
|
||||||
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
|
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
|
||||||
@@ -628,15 +452,12 @@ public static class AutomaticCutOffPlanner
|
|||||||
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
|
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
|
||||||
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
|
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
|
||||||
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
|
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
|
||||||
Require(Inside(entityBox, sheet,
|
Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
||||||
entity.Layer == SpecialLayers.DisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
|
|
||||||
"Part geometry extends outside the physical sheet.", "plate");
|
|
||||||
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
|
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
|
||||||
occupiedY = System.Math.Max(occupiedY, ySign > 0 ? entityBox.Top : -entityBox.Bottom);
|
|
||||||
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
|
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
|
||||||
}
|
}
|
||||||
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
|
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
|
||||||
return (occupied, occupiedY);
|
return occupied;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateProgram(Program program, HashSet<Program> path)
|
private static void ValidateProgram(Program program, HashSet<Program> path)
|
||||||
@@ -677,13 +498,11 @@ public static class AutomaticCutOffPlanner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A gapped line never runs full span, whatever its limits.
|
|
||||||
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||||
cut.Gaps.Count == 0 &&
|
|
||||||
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
|
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
|
||||||
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
|
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
|
||||||
|
|
||||||
private static bool HasUsableSegments(Program program, bool vertical = true)
|
private static bool HasUsableSegments(Program program)
|
||||||
{
|
{
|
||||||
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
|
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
|
||||||
return false;
|
return false;
|
||||||
@@ -691,50 +510,30 @@ public static class AutomaticCutOffPlanner
|
|||||||
{
|
{
|
||||||
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
|
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
|
||||||
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
|
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
|
||||||
(vertical ? !Near(from.EndPoint.X, to.EndPoint.X) : !Near(from.EndPoint.Y, to.EndPoint.Y)) ||
|
!Near(from.EndPoint.X, to.EndPoint.X) ||
|
||||||
(vertical ? System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y)
|
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance)
|
||||||
: System.Math.Abs(from.EndPoint.X - to.EndPoint.X)) <= GeometryTolerance)
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
private static bool IsFullSpanProgram(Program program, Box bounds)
|
||||||
cut.Gaps.Count == 0 &&
|
|
||||||
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
|
|
||||||
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
|
|
||||||
|
|
||||||
private static bool IsFullSpanProgram(Program program, bool vertical, double low, double high)
|
|
||||||
{
|
{
|
||||||
// No gaps, bridges, or filtered middle segments can separate the tail.
|
// No gaps, bridges, or filtered middle segments can separate the tail.
|
||||||
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
|
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
|
||||||
program.Codes[1] is not LinearMove to)
|
program.Codes[1] is not LinearMove to)
|
||||||
return false;
|
return false;
|
||||||
var start = vertical ? from.EndPoint.Y : from.EndPoint.X;
|
return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance &&
|
||||||
var end = vertical ? to.EndPoint.Y : to.EndPoint.X;
|
System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance;
|
||||||
return System.Math.Min(start, end) <= low + GeometryTolerance &&
|
|
||||||
System.Math.Max(start, end) >= high - GeometryTolerance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
|
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
|
||||||
Nonnegative(box.Length) && Nonnegative(box.Width) &&
|
Nonnegative(box.Length) && Nonnegative(box.Width) &&
|
||||||
double.IsFinite(box.Right) && double.IsFinite(box.Top);
|
double.IsFinite(box.Right) && double.IsFinite(box.Top);
|
||||||
|
|
||||||
// A rapid that leaves the sheet only at an end lying on the display perimeter, within its allowance.
|
private static bool Inside(Box box, Box sheet) =>
|
||||||
private static bool IsRapidOntoOutline(Entity entity, List<Entity> outline, Vector location, Box sheet)
|
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
|
||||||
{
|
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
|
||||||
if (entity.Layer != SpecialLayers.Rapid || entity is not Line rapid
|
|
||||||
|| !Inside(rapid.BoundingBox.Translate(location), sheet, DisplayPerimeterSheetAllowance))
|
|
||||||
return false;
|
|
||||||
bool Allowed(Vector point) =>
|
|
||||||
Inside(new Box(point.X + location.X, point.Y + location.Y, 0, 0), sheet)
|
|
||||||
|| outline.Any(e => e.ClosestPointTo(point).DistanceTo(point) <= GeometryTolerance);
|
|
||||||
return Allowed(rapid.StartPoint) && Allowed(rapid.EndPoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool Inside(Box box, Box sheet, double tolerance = GeometryTolerance) =>
|
|
||||||
box.Left >= sheet.Left - tolerance && box.Right <= sheet.Right + tolerance &&
|
|
||||||
box.Bottom >= sheet.Bottom - tolerance && box.Top <= sheet.Top + tolerance;
|
|
||||||
|
|
||||||
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||||
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
|
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
|
||||||
+1
-1
@@ -3,7 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
|
||||||
namespace OpenNest.CutOffs;
|
namespace OpenNest;
|
||||||
|
|
||||||
/// <summary>Exact layered search over sorted positions; each transition uses a sliding minimum.</summary>
|
/// <summary>Exact layered search over sorted positions; each transition uses a sliding minimum.</summary>
|
||||||
internal static class AutomaticCutOffStripSearch
|
internal static class AutomaticCutOffStripSearch
|
||||||
@@ -62,8 +62,8 @@ namespace OpenNest.Bending
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var angle = StartPoint.AngleTo(EndPoint);
|
var angle = StartPoint.AngleTo(EndPoint);
|
||||||
var dx = PortableMath.Cos(angle) * etchLength;
|
var dx = System.Math.Cos(angle) * etchLength;
|
||||||
var dy = PortableMath.Sin(angle) * etchLength;
|
var dy = System.Math.Sin(angle) * etchLength;
|
||||||
|
|
||||||
result.Add(
|
result.Add(
|
||||||
CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy))
|
CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy))
|
||||||
|
|||||||
@@ -21,17 +21,12 @@ namespace OpenNest.BestFit
|
|||||||
IReadOnlyList<double> HullAngles
|
IReadOnlyList<double> HullAngles
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>The saved best-fit results for one drawing at one plate size and spacing.</summary>
|
||||||
/// The saved best-fit results for one drawing at one plate size and spacing.
|
|
||||||
/// <paramref name="CanonicalAngle"/> is the drawing's canonical angle (radians) when the
|
|
||||||
/// set was saved: the frame its pair geometry is in. Null means the frame is unknown.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record SavedPairSet(
|
public sealed record SavedPairSet(
|
||||||
double PlateWidth,
|
double PlateWidth,
|
||||||
double PlateHeight,
|
double PlateHeight,
|
||||||
double Spacing,
|
double Spacing,
|
||||||
IReadOnlyList<SavedPairResult> Results,
|
IReadOnlyList<SavedPairResult> Results
|
||||||
double? CanonicalAngle = null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -50,10 +45,7 @@ namespace OpenNest.BestFit
|
|||||||
IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys
|
IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Restores saved sets for <paramref name="drawing"/>.</summary>
|
||||||
/// Restores saved sets for <paramref name="drawing"/>. Sets saved in a frame other
|
|
||||||
/// than the drawing's current canonical frame, or in an unknown one, are stale.
|
|
||||||
/// </summary>
|
|
||||||
void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets);
|
void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ namespace OpenNest.Breakoffs
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves and applies <see cref="Breakoff"/>s: the joining line becomes open cuts on the
|
/// Resolves and applies <see cref="Breakoff"/>s: the joining line becomes open cuts on the
|
||||||
/// break layer between tabs, each traveling with the scrap on its left so the cutting
|
/// break layer between tabs, each travelling 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 neighbors.
|
/// joining line and keeps a gap from its neighbours.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double ClampTabCenter(int index, double center)
|
public double ClampTabCenter(int index, double center)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ namespace OpenNest.Breakoffs
|
|||||||
}
|
}
|
||||||
var arc = (Arc)e;
|
var arc = (Arc)e;
|
||||||
var angle = ArcAngleAt(arc, local);
|
var angle = ArcAngleAt(arc, local);
|
||||||
return new Vector(arc.Center.X + arc.Radius * PortableMath.Cos(angle),
|
return new Vector(arc.Center.X + arc.Radius * System.Math.Cos(angle),
|
||||||
arc.Center.Y + arc.Radius * PortableMath.Sin(angle));
|
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector TangentOn(Entity e, double local)
|
private static Vector TangentOn(Entity e, double local)
|
||||||
@@ -168,8 +168,8 @@ namespace OpenNest.Breakoffs
|
|||||||
var arc = (Arc)e;
|
var arc = (Arc)e;
|
||||||
var angle = ArcAngleAt(arc, local);
|
var angle = ArcAngleAt(arc, local);
|
||||||
return arc.IsReversed
|
return arc.IsReversed
|
||||||
? new Vector(PortableMath.Sin(angle), -PortableMath.Cos(angle))
|
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
|
||||||
: new Vector(-PortableMath.Sin(angle), PortableMath.Cos(angle));
|
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double ArcAngleAt(Arc arc, double local) =>
|
private static double ArcAngleAt(Arc arc, double local) =>
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// How a planned program cuts a common line once (see docs/common-line-cutting.md). A shared cut
|
|
||||||
/// is a run of <see cref="LayerType.CommonLine"/> moves: an open centerline cut made without
|
|
||||||
/// kerf compensation, with straight leads on its own axis. A remainder (a member's perimeter
|
|
||||||
/// between its shared spans) is an open contour whose rapid is followed directly by one of the
|
|
||||||
/// <see cref="RemainderMarker(RotationType)"/> comments, naming the winding of the perimeter it
|
|
||||||
/// belongs to, so a post compensates it on the perimeter's side even though it encloses nothing.
|
|
||||||
/// </summary>
|
|
||||||
public static class CommonLineCuts
|
|
||||||
{
|
|
||||||
/// <summary>The comment after the rapid into a remainder of a clockwise perimeter.</summary>
|
|
||||||
public const string ClockwiseRemainder = "COMMON LINE REMAINDER CW";
|
|
||||||
|
|
||||||
/// <summary>The comment after the rapid into a remainder of a counterclockwise perimeter.</summary>
|
|
||||||
public const string CounterclockwiseRemainder = "COMMON LINE REMAINDER CCW";
|
|
||||||
|
|
||||||
/// <summary>The remainder comment for a perimeter wound <paramref name="winding"/>.</summary>
|
|
||||||
public static string RemainderMarker(RotationType winding) =>
|
|
||||||
winding == RotationType.CW ? ClockwiseRemainder : CounterclockwiseRemainder;
|
|
||||||
|
|
||||||
/// <summary>The perimeter winding a remainder comment names, or null for any other code.</summary>
|
|
||||||
public static RotationType? RemainderWinding(ICode code) => code switch
|
|
||||||
{
|
|
||||||
Comment { Value: ClockwiseRemainder } => RotationType.CW,
|
|
||||||
Comment { Value: CounterclockwiseRemainder } => RotationType.CCW,
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>Whether <paramref name="program"/>'s own codes cut a common line once.</summary>
|
|
||||||
public static bool IsSinglePass(Program program) =>
|
|
||||||
program?.Codes != null && program.Codes.Any(code =>
|
|
||||||
code is LinearMove { Layer: LayerType.CommonLine } or ArcMove { Layer: LayerType.CommonLine }
|
|
||||||
|| RemainderWinding(code) != null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Refuses, before any output, a nest with a common line cut once when
|
|
||||||
/// <paramref name="postProcessor"/> cannot write shared cuts and remainders: it would cut the
|
|
||||||
/// shared cut with compensation (into a part) or a remainder on the wrong side. Posts never
|
|
||||||
/// fall back to cutting the line twice; set the plate to cut its common lines twice instead.
|
|
||||||
/// </summary>
|
|
||||||
/// <exception cref="InvalidOperationException">The post cannot write the nest.</exception>
|
|
||||||
public static void EnsurePostable(Nest nest, IPostProcessor postProcessor)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(nest);
|
|
||||||
if (postProcessor is ICommonLinePostProcessor)
|
|
||||||
return;
|
|
||||||
for (var p = 0; p < nest.Plates.Count; p++)
|
|
||||||
{
|
|
||||||
var plate = nest.Plates[p];
|
|
||||||
for (var i = 0; i < plate.Parts.Count; i++)
|
|
||||||
if (IsSinglePass(plate.Parts[i]?.Program))
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Plate {p + 1}, part {i + 1} cuts a common line once, but the "
|
|
||||||
+ $"{postProcessor?.Name ?? "selected"} post cannot write single-pass common lines. "
|
|
||||||
+ "Set the plate to cut its common lines twice (Plate > Arrange > Cut Common Lines Twice), "
|
|
||||||
+ "plan cutting again, then post.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A post that writes common lines cut once (<see cref="CommonLineCuts"/>): each shared cut
|
|
||||||
/// without kerf compensation and each remainder compensated on its perimeter's side. Posts
|
|
||||||
/// without it refuse such a nest (<see cref="CommonLineCuts.EnsurePostable"/>).
|
|
||||||
/// </summary>
|
|
||||||
public interface ICommonLinePostProcessor : IPostProcessor
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -123,7 +123,7 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
var angle = StartAngle + Sweep * fraction;
|
var angle = StartAngle + Sweep * fraction;
|
||||||
var sign = System.Math.Sign(Sweep);
|
var sign = System.Math.Sign(Sweep);
|
||||||
return new Vector(-PortableMath.Sin(angle) * sign, PortableMath.Cos(angle) * sign);
|
return new Vector(-System.Math.Sin(angle) * sign, System.Math.Cos(angle) * sign);
|
||||||
}
|
}
|
||||||
return Length > 0 ? new Vector((End.X - Start.X) / Length, (End.Y - Start.Y) / Length) : Vector.Zero;
|
return Length > 0 ? new Vector((End.X - Start.X) / Length, (End.Y - Start.Y) / Length) : Vector.Zero;
|
||||||
}
|
}
|
||||||
@@ -137,8 +137,8 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
var angle = StartAngle + Sweep * fraction;
|
var angle = StartAngle + Sweep * fraction;
|
||||||
return new Vector(
|
return new Vector(
|
||||||
Center.X + Radius * PortableMath.Cos(angle),
|
Center.X + Radius * System.Math.Cos(angle),
|
||||||
Center.Y + Radius * PortableMath.Sin(angle));
|
Center.Y + Radius * System.Math.Sin(angle));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Vector(
|
return new Vector(
|
||||||
@@ -238,7 +238,7 @@ namespace OpenNest.CNC
|
|||||||
builder.PartIndex = i;
|
builder.PartIndex = i;
|
||||||
var mode = Mode.Absolute;
|
var mode = Mode.Absolute;
|
||||||
var pos = part.Location;
|
var pos = part.Location;
|
||||||
builder.AddProgram(DisplayPerimeterTail.WithoutTail(part.Program), part.Location, ref mode, ref pos);
|
builder.AddProgram(part.Program, part.Location, ref mode, ref pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
|
var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
|
||||||
@@ -379,7 +379,7 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
// Cutting strategies emit contour moves on Display; the posts cut them.
|
// Cutting strategies emit contour moves on Display; the posts cut them.
|
||||||
LayerType.Cut or LayerType.Display or LayerType.Leadin or LayerType.Leadout or LayerType.Break
|
LayerType.Cut or LayerType.Display or LayerType.Leadin or LayerType.Leadout or LayerType.Break
|
||||||
or LayerType.CommonLine => CutSimulationStepKind.Cut,
|
=> CutSimulationStepKind.Cut,
|
||||||
LayerType.Scribe => CutSimulationStepKind.Scribe,
|
LayerType.Scribe => CutSimulationStepKind.Scribe,
|
||||||
_ => null, // Tab spans are never cut.
|
_ => null, // Tab spans are never cut.
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,45 +36,14 @@ internal enum AutomaticEntryKind
|
|||||||
|
|
||||||
/// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary>
|
/// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary>
|
||||||
Pinned = 7,
|
Pinned = 7,
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The perimeter point where a tab best keeps the part level on its support bed; only a
|
|
||||||
/// perimeter tabbed for support has it (see <see cref="PreparedContours.WithSupportTab"/>).
|
|
||||||
/// </summary>
|
|
||||||
SupportTab = 8,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A cutout entry pierced where eroding the cutout leaves room (see
|
|
||||||
/// <see cref="PreparedContours.ErodedPierceEntries"/>): only offered once no configured
|
|
||||||
/// lead-in of the cutout fits.
|
|
||||||
/// </summary>
|
|
||||||
ErodedPierce = 9,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Which automatic entries a perimeter tabbed for support offers (see
|
|
||||||
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalog.
|
|
||||||
/// </summary>
|
|
||||||
internal enum SupportEntries
|
|
||||||
{
|
|
||||||
/// <summary>Entries whose tab steadies the part, clear of inside corners, plus its best tab point.</summary>
|
|
||||||
Steadying,
|
|
||||||
|
|
||||||
/// <summary>Every entry clear of inside corners: the fallback when no steadying entry fits.</summary>
|
|
||||||
ClearOfInsideCorners,
|
|
||||||
|
|
||||||
/// <summary>Every entry: the last fallback, when nothing clear of inside corners fits.</summary>
|
|
||||||
Any,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One automatic start candidate: the owned contour choice plus its preference kind and a
|
/// One automatic start candidate: the owned contour choice plus its preference kind and a
|
||||||
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
|
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
|
||||||
/// owner; nothing here exposes or mutates the underlying shape. <paramref name="CornerReach"/>
|
/// owner; nothing here exposes or mutates the underlying shape.
|
||||||
/// 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, double CornerReach = 0)
|
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
|
||||||
{
|
{
|
||||||
/// <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 catalog toward the next cut:
|
/// Pure deterministic ordering of the automatic entry catalogue 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 center, matching the source plan:
|
// Facing sides from the target relative to the centre, matching the source plan:
|
||||||
// horizontal right when the target is right of center else left; vertical likewise.
|
// horizontal right when the target is right of centre else left; vertical likewise.
|
||||||
var centerX = minX + (maxX - minX) * 0.5;
|
var centreX = minX + (maxX - minX) * 0.5;
|
||||||
var centerY = minY + (maxY - minY) * 0.5;
|
var centreY = minY + (maxY - minY) * 0.5;
|
||||||
var facingRight = target != null && target.Value.X > centerX;
|
var facingRight = target != null && target.Value.X > centreX;
|
||||||
var facingTop = target != null && target.Value.Y > centerY;
|
var facingTop = target != null && target.Value.Y > centreY;
|
||||||
|
|
||||||
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,18 +107,16 @@ 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, a fillet's joint within its reach of the corner it
|
// sides within tolerance); count how many of them are facing sides.
|
||||||
// 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));
|
||||||
var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
|
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||||
if (facingRight && right <= near) facing++;
|
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||||
if (!facingRight && left <= near) facing++;
|
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||||
if (facingTop && top <= near) facing++;
|
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||||
if (!facingTop && bottom <= near) facing++;
|
|
||||||
}
|
}
|
||||||
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
|
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
|
||||||
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
|
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
|
||||||
|
|||||||
@@ -47,8 +47,7 @@ internal static class ContourProgramVerifier
|
|||||||
if (material == null || !material.IsComplete) return false;
|
if (material == null || !material.IsComplete) return false;
|
||||||
if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false;
|
if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false;
|
||||||
var visited = new HashSet<int>();
|
var visited = new HashSet<int>();
|
||||||
// Per tabbed ring: what each run cut, as (span start, from, to) along its span.
|
var piecesCut = new Dictionary<int, HashSet<int>>();
|
||||||
var piecesCut = new Dictionary<int, List<(int Span, double From, double To)>>();
|
|
||||||
foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut))
|
foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut))
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -56,34 +55,32 @@ internal static class ContourProgramVerifier
|
|||||||
var first = run.Curves[0];
|
var first = run.Curves[0];
|
||||||
var ringIndex = -1;
|
var ringIndex = -1;
|
||||||
var entityIndex = -1;
|
var entityIndex = -1;
|
||||||
// The admitted slack is far below any extent margin, so only boundaries near the run's
|
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
|
||||||
// start can contain it; they come in ring order, as the full scan met them.
|
|
||||||
foreach (var (r, e) in material.PositionsNear(new(first.Start.X, first.Start.Y, first.Start.X, first.Start.Y)))
|
|
||||||
{
|
{
|
||||||
Query(token, ref budget);
|
|
||||||
var admitted = AdmittedClosureSlack(material.Rings[r]);
|
var admitted = AdmittedClosureSlack(material.Rings[r]);
|
||||||
var curve = material.Rings[r][e];
|
for (var e = 0; e < material.Rings[r].Length; e++)
|
||||||
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
|
|
||||||
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
|
||||||
{
|
{
|
||||||
ringIndex = r;
|
Query(token, ref budget);
|
||||||
entityIndex = e;
|
var curve = material.Rings[r][e];
|
||||||
break;
|
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
|
||||||
|
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
||||||
|
{
|
||||||
|
ringIndex = r;
|
||||||
|
entityIndex = e;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ringIndex < 0) return false;
|
if (ringIndex < 0) return false;
|
||||||
// A display perimeter is never cut: any run along it is not this plan.
|
|
||||||
if (ringIndex == 0 && material.HasDisplayPerimeter) return false;
|
|
||||||
var tabs = material.Tabs[ringIndex];
|
var tabs = material.Tabs[ringIndex];
|
||||||
if (tabs.Any(tab => tab))
|
if (tabs.Any(tab => tab))
|
||||||
{
|
{
|
||||||
// A tabbed ring: each run follows one span between tabs and never crosses a tab. A
|
// A tabbed cutout: each run cuts exactly one span between tabs, whole and once.
|
||||||
// span is usually one run; a common-line member that starts on its common line cuts
|
if (!CutsTabbedSpan(run.Curves, material.Rings[ringIndex], tabs, entityIndex, token, ref budget)
|
||||||
// the span holding that start in two, each from where the other began or ended.
|
|| !(piecesCut.TryGetValue(ringIndex, out var cut) ? cut : piecesCut[ringIndex] = new()).Add(entityIndex))
|
||||||
if (CutTabbedSpan(run.Curves, material.Rings[ringIndex], tabs, entityIndex, token, ref budget)
|
|
||||||
is not { } piece)
|
|
||||||
return false;
|
return false;
|
||||||
(piecesCut.TryGetValue(ringIndex, out var cut) ? cut : piecesCut[ringIndex] = new()).Add(piece);
|
if (piecesCut[ringIndex].Count == SpanStarts(tabs).Count)
|
||||||
|
visited.Add(ringIndex);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!visited.Add(ringIndex)) return false;
|
if (!visited.Add(ringIndex)) return false;
|
||||||
@@ -111,69 +108,26 @@ internal static class ContourProgramVerifier
|
|||||||
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
||||||
true, tolerance, token, ref budget)) return false;
|
true, tolerance, token, ref budget)) return false;
|
||||||
}
|
}
|
||||||
// Every span of a tabbed ring is cut exactly once: its pieces tile it with no gap or overlap.
|
return visited.Count == material.Rings.Count;
|
||||||
foreach (var (ringIndex, pieces) in piecesCut)
|
|
||||||
{
|
|
||||||
var ring = material.Rings[ringIndex];
|
|
||||||
var tabs = material.Tabs[ringIndex];
|
|
||||||
var tolerance = AdmittedClosureSlack(ring);
|
|
||||||
foreach (var start in SpanStarts(tabs))
|
|
||||||
{
|
|
||||||
var length = Span(ring, tabs, start).Sum(c => c.Length);
|
|
||||||
var at = 0.0;
|
|
||||||
foreach (var piece in pieces.Where(p => p.Span == start).OrderBy(p => p.From))
|
|
||||||
{
|
|
||||||
if (System.Math.Abs(piece.From - at) > tolerance)
|
|
||||||
return false;
|
|
||||||
at = piece.To;
|
|
||||||
}
|
|
||||||
if (System.Math.Abs(length - at) > tolerance)
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
visited.Add(ringIndex);
|
|
||||||
}
|
|
||||||
return visited.Count == material.Rings.Count - (material.HasDisplayPerimeter ? 1 : 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
|
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
|
||||||
private static List<int> SpanStarts(bool[] tabs) => Enumerable.Range(0, tabs.Length)
|
private static List<int> SpanStarts(bool[] tabs) => Enumerable.Range(0, tabs.Length)
|
||||||
.Where(i => !tabs[i] && tabs[(i + tabs.Length - 1) % tabs.Length]).ToList();
|
.Where(i => !tabs[i] && tabs[(i + tabs.Length - 1) % tabs.Length]).ToList();
|
||||||
|
|
||||||
// What a run along a tabbed ring cuts: its span (by the span's first curve) and where along the
|
// A run that starts exactly where a span between tabs starts and covers that span exactly,
|
||||||
// span it starts and ends. Null unless the run follows the ring from its start without
|
// stopping at the next tab.
|
||||||
// reaching past the span's next tab.
|
private static bool CutsTabbedSpan(IReadOnlyList<Curve> run, IReadOnlyList<Curve> ring, bool[] tabs,
|
||||||
private static (int Span, double From, double To)? CutTabbedSpan(IReadOnlyList<Curve> run,
|
int entityIndex, CancellationToken token, ref int budget)
|
||||||
IReadOnlyList<Curve> ring, bool[] tabs, int entityIndex, CancellationToken token, ref int budget)
|
|
||||||
{
|
{
|
||||||
if (tabs[entityIndex])
|
|
||||||
return null;
|
|
||||||
var tolerance = AdmittedClosureSlack(ring);
|
var tolerance = AdmittedClosureSlack(ring);
|
||||||
var count = tabs.Length;
|
if (!SpanStarts(tabs).Contains(entityIndex) || ring[entityIndex].DistanceAlong(run[0].Start) > tolerance)
|
||||||
var start = entityIndex;
|
return false;
|
||||||
var from = ring[entityIndex].DistanceAlong(run[0].Start);
|
|
||||||
for (var guard = 0; guard < count && !tabs[(start + count - 1) % count]; guard++)
|
|
||||||
{
|
|
||||||
start = (start + count - 1) % count;
|
|
||||||
from += ring[start].Length;
|
|
||||||
}
|
|
||||||
var rest = new List<Curve>();
|
|
||||||
for (var e = entityIndex; !tabs[e % count] && rest.Count < count; e++)
|
|
||||||
rest.Add(ring[e % count]);
|
|
||||||
var length = run.Sum(c => c.Length);
|
|
||||||
var offset = ring[entityIndex].DistanceAlong(run[0].Start);
|
|
||||||
if (length > rest.Sum(c => c.Length) - offset + tolerance
|
|
||||||
|| !Follows(run, rest, 0, offset, false, tolerance, token, ref budget, partial: true))
|
|
||||||
return null;
|
|
||||||
return (start, from, from + length);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The curves of the span between tabs that starts at curve start.
|
|
||||||
private static List<Curve> Span(IReadOnlyList<Curve> ring, bool[] tabs, int start)
|
|
||||||
{
|
|
||||||
var span = new List<Curve>();
|
var span = new List<Curve>();
|
||||||
for (var e = start; !tabs[e % tabs.Length] && span.Count < tabs.Length; e++)
|
for (var e = entityIndex; !tabs[e % tabs.Length]; e++)
|
||||||
span.Add(ring[e % tabs.Length]);
|
span.Add(ring[e % tabs.Length]);
|
||||||
return span;
|
return System.Math.Abs(run.Sum(c => c.Length) - span.Sum(c => c.Length)) <= tolerance
|
||||||
|
&& Follows(run, span, 0, 0, false, tolerance, token, ref budget);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zero for an exactly closed ring: a ring may lawfully close within the nominal closure
|
// Zero for an exactly closed ring: a ring may lawfully close within the nominal closure
|
||||||
@@ -208,10 +162,8 @@ internal static class ContourProgramVerifier
|
|||||||
// collinear moves and a cyclic ring's reindexing do not change coverage.
|
// collinear moves and a cyclic ring's reindexing do not change coverage.
|
||||||
// `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon
|
// `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon
|
||||||
// unless the nominal ring closes only within the nominal closure tolerance).
|
// unless the nominal ring closes only within the nominal closure tolerance).
|
||||||
// `partial` accepts an actual stream that ends before the expected one does.
|
|
||||||
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
|
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
|
||||||
int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget,
|
int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget)
|
||||||
bool partial = false)
|
|
||||||
{
|
{
|
||||||
foreach (var curve in actual)
|
foreach (var curve in actual)
|
||||||
{
|
{
|
||||||
@@ -248,7 +200,7 @@ internal static class ContourProgramVerifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cyclic || partial || index == expected.Count || index == expected.Count - 1
|
return cyclic || index == expected.Count || index == expected.Count - 1
|
||||||
&& expected[index].Length - offset <= tolerance;
|
&& expected[index].Length - offset <= tolerance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest.CNC.CuttingStrategy;
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
using OpenNest.CutOffs;
|
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingPlanning;
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
@@ -23,39 +22,20 @@ internal sealed class PlannedPartProgram
|
|||||||
public CuttingParameters Parameters { get; }
|
public CuttingParameters Parameters { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// An owned cutoff segment program cut the other way at the far sheet edge
|
|
||||||
/// (<see cref="CutOff.ReverseAtFarEdge"/>); Apply installs it and records the direction on the
|
|
||||||
/// segment's cutoff definition, so regeneration keeps it.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class PlannedCutOffProgram
|
|
||||||
{
|
|
||||||
public PlannedCutOffProgram(Part segment, Program program)
|
|
||||||
{
|
|
||||||
Segment = segment;
|
|
||||||
Program = program;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Part Segment { get; }
|
|
||||||
public Program Program { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
|
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
|
||||||
internal sealed class PlateCuttingPlan
|
internal sealed class PlateCuttingPlan
|
||||||
{
|
{
|
||||||
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
|
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
|
||||||
IEnumerable<PlannedPartProgram> programs = null, IEnumerable<PlannedCutOffProgram> cutOffs = null)
|
IEnumerable<PlannedPartProgram> programs = null)
|
||||||
{
|
{
|
||||||
Expected = expected;
|
Expected = expected;
|
||||||
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
|
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
|
||||||
Programs = Array.AsReadOnly((programs ?? []).ToArray());
|
Programs = Array.AsReadOnly((programs ?? []).ToArray());
|
||||||
CutOffs = Array.AsReadOnly((cutOffs ?? []).ToArray());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlateCuttingState Expected { get; }
|
public PlateCuttingState Expected { get; }
|
||||||
public IReadOnlyList<Part> Order { get; }
|
public IReadOnlyList<Part> Order { get; }
|
||||||
public IReadOnlyList<PlannedPartProgram> Programs { get; }
|
public IReadOnlyList<PlannedPartProgram> Programs { get; }
|
||||||
public IReadOnlyList<PlannedCutOffProgram> CutOffs { get; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CuttingCommitStatus
|
public enum CuttingCommitStatus
|
||||||
@@ -63,7 +43,7 @@ public enum CuttingCommitStatus
|
|||||||
Applied,
|
Applied,
|
||||||
Stale,
|
Stale,
|
||||||
InvalidInput,
|
InvalidInput,
|
||||||
Canceled,
|
Cancelled,
|
||||||
Failed
|
Failed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +88,7 @@ internal static class CuttingPlanCommit
|
|||||||
Action<Plate, Part> beforeInstall)
|
Action<Plate, Part> beforeInstall)
|
||||||
{
|
{
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
|
return new(CuttingCommitStatus.Cancelled, "Cancelled 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.");
|
||||||
@@ -127,7 +107,7 @@ internal static class CuttingPlanCommit
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
|
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
||||||
}
|
}
|
||||||
if (difference != null)
|
if (difference != null)
|
||||||
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
|
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
|
||||||
@@ -181,49 +161,27 @@ internal static class CuttingPlanCommit
|
|||||||
}
|
}
|
||||||
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
|
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
|
||||||
}
|
}
|
||||||
var cutOffs = new List<(Part, Program, CutOff)>();
|
|
||||||
foreach (var planned in plan.CutOffs)
|
|
||||||
{
|
|
||||||
if (planned?.Segment == null || !onPlate.Contains(planned.Segment) || !targets.Add(planned.Segment))
|
|
||||||
return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
|
|
||||||
var definition = plate.CutOffs.FirstOrDefault(c => ReferenceEquals(c?.Drawing, planned.Segment.BaseDrawing));
|
|
||||||
// Only the placed far-edge segment, cut exactly the other way, may be installed.
|
|
||||||
if (definition == null || planned.Program == null || live.Contains(planned.Program)
|
|
||||||
|| !installed.Add(planned.Program)
|
|
||||||
|| CutOff.ReverseAtFarEdge(planned.Segment.Program) is not { } expected
|
|
||||||
|| !ProgramContent.Equal(expected, planned.Program, token))
|
|
||||||
return Invalid(plate, "A cutoff segment can only be installed cut the other way at the far edge.");
|
|
||||||
cutOffs.Add((planned.Segment, planned.Program, definition));
|
|
||||||
}
|
|
||||||
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
|
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
|
||||||
staged.Add(new(plate, order, programs, cutOffs, reordered || programs.Count != 0 || cutOffs.Count != 0));
|
staged.Add(new(plate, order, programs, reordered || programs.Count != 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last cancellation point. The boundary below has no await, search or geometry work.
|
// Last cancellation point. The boundary below has no await, search or geometry work.
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
|
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
||||||
|
|
||||||
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States,
|
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States)>();
|
||||||
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var item in staged)
|
foreach (var item in staged)
|
||||||
{
|
{
|
||||||
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
|
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
|
||||||
item.Programs.Select(p => p.Part).Concat(item.CutOffs.Select(c => c.Segment))
|
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray()));
|
||||||
.Select(part => (part, part.CaptureCuttingState())).ToArray(),
|
|
||||||
item.CutOffs.Select(c => (c.CutOff, c.CutOff.FarEdgeFirst, c.CutOff.Drawing.Program)).ToArray()));
|
|
||||||
item.Plate.Parts.SetOrder(item.Order);
|
item.Plate.Parts.SetOrder(item.Order);
|
||||||
foreach (var (part, program, bounds, parameters) in item.Programs)
|
foreach (var (part, program, bounds, parameters) in item.Programs)
|
||||||
{
|
{
|
||||||
beforeInstall?.Invoke(item.Plate, part);
|
beforeInstall?.Invoke(item.Plate, part);
|
||||||
part.InstallPlannedProgram(program, bounds, parameters);
|
part.InstallPlannedProgram(program, bounds, parameters);
|
||||||
}
|
}
|
||||||
foreach (var (segment, program, cutOff) in item.CutOffs)
|
|
||||||
{
|
|
||||||
beforeInstall?.Invoke(item.Plate, segment);
|
|
||||||
cutOff.InstallFarEdgeSegment(segment, program);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -232,11 +190,6 @@ internal static class CuttingPlanCommit
|
|||||||
{
|
{
|
||||||
foreach (var (part, state) in undo[i].States)
|
foreach (var (part, state) in undo[i].States)
|
||||||
part.RestoreCuttingState(state);
|
part.RestoreCuttingState(state);
|
||||||
foreach (var (cutOff, farEdgeFirst, program) in undo[i].CutOffs)
|
|
||||||
{
|
|
||||||
cutOff.FarEdgeFirst = farEdgeFirst;
|
|
||||||
cutOff.Drawing.Program = program;
|
|
||||||
}
|
|
||||||
undo[i].Plate.Parts.SetOrder(undo[i].Order);
|
undo[i].Plate.Parts.SetOrder(undo[i].Order);
|
||||||
}
|
}
|
||||||
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
|
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
|
||||||
@@ -255,6 +208,5 @@ internal static class CuttingPlanCommit
|
|||||||
new(CuttingCommitStatus.InvalidInput, message, plate);
|
new(CuttingCommitStatus.InvalidInput, message, plate);
|
||||||
|
|
||||||
private sealed record Staged(Plate Plate, Part[] Order,
|
private sealed record Staged(Plate Plate, Part[] Order,
|
||||||
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs,
|
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs, bool Changed);
|
||||||
List<(Part Segment, Program Program, CutOff CutOff)> CutOffs, bool Changed);
|
|
||||||
}
|
}
|
||||||
@@ -27,12 +27,10 @@ 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, false, null);
|
Walk(program, origin, previous);
|
||||||
return new OwnedExecution(moves);
|
return new OwnedExecution(moves);
|
||||||
|
|
||||||
// raiseArrival: a marked call raises the head for the rapid that arrives at its first motion.
|
Vector Walk(Program current, Vector frame, Vector? arrival)
|
||||||
// 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);
|
||||||
@@ -43,24 +41,13 @@ public static class ExecutionMotionReader
|
|||||||
var pos = frame;
|
var pos = frame;
|
||||||
var first = true;
|
var first = true;
|
||||||
var countBefore = moves.Count;
|
var countBefore = moves.Count;
|
||||||
var raised = HeadRaise.RaisedMask(current.Codes);
|
foreach (var code in current.Codes)
|
||||||
for (var codeIndex = 0; codeIndex < current.Codes.Count; codeIndex++)
|
|
||||||
{
|
{
|
||||||
var code = current.Codes[codeIndex];
|
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (--budget < 0)
|
if (--budget < 0)
|
||||||
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
||||||
if (code == null)
|
if (code == null)
|
||||||
throw new ArgumentException("Program contains a missing instruction.");
|
throw new ArgumentException("Program contains a missing instruction.");
|
||||||
// A display perimeter's tail is never executed: an applied program ends at its marker.
|
|
||||||
// Only a well-formed tail at the end of the part's own program may end the read.
|
|
||||||
if (code is Comment { Value: DisplayPerimeterTail.Marker })
|
|
||||||
{
|
|
||||||
if (!ReferenceEquals(current, program))
|
|
||||||
throw new ArgumentException("A display perimeter tail can only end the part's own program, not a sub-program.");
|
|
||||||
DisplayPerimeterTail.IndexOf(current);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (requireSupportedTypes)
|
if (requireSupportedTypes)
|
||||||
{
|
{
|
||||||
var type = code.GetType();
|
var type = code.GetType();
|
||||||
@@ -74,8 +61,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -96,7 +82,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, raiseArrival, callCode: callCode));
|
moves.Add(new(arrival, frame, true, LayerType.Display, null));
|
||||||
var start = first && rapid ? arrival : pos;
|
var start = first && rapid ? arrival : pos;
|
||||||
var layer = motion switch
|
var layer = motion switch
|
||||||
{
|
{
|
||||||
@@ -111,8 +97,7 @@ 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, raised[codeIndex] || first && rapid && raiseArrival,
|
moves.Add(new(start, end, rapid, layer, curve));
|
||||||
ReferenceEquals(current, program) ? codeIndex : null, callCode));
|
|
||||||
pos = end;
|
pos = end;
|
||||||
first = false;
|
first = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest.Clamps;
|
|
||||||
using OpenNest.CNC.CuttingStrategy;
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
using OpenNest.Diagnostics;
|
using OpenNest.Diagnostics;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ internal static class IncrementalClosureDrift
|
|||||||
|
|
||||||
// The chains material capture closes: cut, display and tab moves between rapids and other layers.
|
// The chains material capture closes: cut, display and tab moves between rapids and other layers.
|
||||||
private static bool IsContour(Motion motion) => !motion.Suppressed
|
private static bool IsContour(Motion motion) => !motion.Suppressed
|
||||||
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter }
|
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab }
|
||||||
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter };
|
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab };
|
||||||
|
|
||||||
private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
|
private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
|
||||||
{
|
{
|
||||||
@@ -79,7 +79,7 @@ internal static class IncrementalClosureDrift
|
|||||||
var endPoint = last.EndPoint + correction;
|
var endPoint = last.EndPoint + correction;
|
||||||
if (last is ArcMove arc)
|
if (last is ArcMove arc)
|
||||||
{
|
{
|
||||||
// Keep the arc's center (relative to its start) equidistant from its moved end.
|
// Keep the arc's centre (relative to its start) equidistant from its moved end.
|
||||||
var chord = endPoint.DistanceTo(new Vector());
|
var chord = endPoint.DistanceTo(new Vector());
|
||||||
if (chord <= Tolerance.Epsilon)
|
if (chord <= Tolerance.Epsilon)
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ namespace OpenNest.CNC.CuttingPlanning;
|
|||||||
public sealed class LeadMaterialSnapshot
|
public sealed class LeadMaterialSnapshot
|
||||||
{
|
{
|
||||||
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
|
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
|
||||||
string reason, bool displayPerimeter = false)
|
string reason)
|
||||||
{
|
{
|
||||||
HasDisplayPerimeter = displayPerimeter;
|
|
||||||
Rings = rings;
|
Rings = rings;
|
||||||
Tabs = tabs;
|
Tabs = tabs;
|
||||||
Reason = reason;
|
Reason = reason;
|
||||||
@@ -42,21 +41,10 @@ public sealed class LeadMaterialSnapshot
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal IReadOnlyList<bool[]> Tabs { get; }
|
internal IReadOnlyList<bool[]> Tabs { get; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when the perimeter (<see cref="Rings"/>[0]) is a display perimeter: a supplied blank's
|
|
||||||
/// edge that bounds material but is never cut. Every other ring is a hole the program cuts.
|
|
||||||
/// </summary>
|
|
||||||
internal bool HasDisplayPerimeter { get; }
|
|
||||||
|
|
||||||
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
|
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
|
||||||
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
|
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
|
||||||
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
|
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
|
||||||
CancellationToken token = default) => Capture(cleanProgram, location, token, sharedSpans: false);
|
CancellationToken token = default)
|
||||||
|
|
||||||
/// <param name="sharedSpans">A common-line member's clean program (docs/common-line-cutting.md):
|
|
||||||
/// its perimeter may carry Tab spans, the shared spans the member never cuts.</param>
|
|
||||||
internal static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
|
|
||||||
CancellationToken token, bool sharedSpans)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -65,8 +53,6 @@ public sealed class LeadMaterialSnapshot
|
|||||||
var ringTabs = new List<bool[]>();
|
var ringTabs = new List<bool[]>();
|
||||||
var chain = new List<PostVerificationGeometry.Curve>();
|
var chain = new List<PostVerificationGeometry.Curve>();
|
||||||
var chainTabs = new List<bool>();
|
var chainTabs = new List<bool>();
|
||||||
var ringDisplay = new List<bool>();
|
|
||||||
var chainDisplay = new List<bool>();
|
|
||||||
foreach (var motion in execution.Motions)
|
foreach (var motion in execution.Motions)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -77,8 +63,7 @@ public sealed class LeadMaterialSnapshot
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// A tabbed cutout's tabs are never cut but still close its hole.
|
// A tabbed cutout's tabs are never cut but still close its hole.
|
||||||
// A display perimeter is never cut but still bounds the blank's material.
|
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab))
|
||||||
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter))
|
|
||||||
throw new ArgumentException("Material capture contains an unsupported layer.");
|
throw new ArgumentException("Material capture contains an unsupported layer.");
|
||||||
var curve = motion.Curve;
|
var curve = motion.Curve;
|
||||||
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
|
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
|
||||||
@@ -87,7 +72,6 @@ public sealed class LeadMaterialSnapshot
|
|||||||
throw new ArgumentException("Material contour is discontinuous.");
|
throw new ArgumentException("Material contour is discontinuous.");
|
||||||
chain.Add(curve);
|
chain.Add(curve);
|
||||||
chainTabs.Add(motion.Layer == LayerType.Tab);
|
chainTabs.Add(motion.Layer == LayerType.Tab);
|
||||||
chainDisplay.Add(motion.Layer == LayerType.DisplayPerimeter);
|
|
||||||
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||||
Finish();
|
Finish();
|
||||||
}
|
}
|
||||||
@@ -96,46 +80,42 @@ public sealed class LeadMaterialSnapshot
|
|||||||
throw new ArgumentException("Material has no closed contour.");
|
throw new ArgumentException("Material has no closed contour.");
|
||||||
var budget = 1000000;
|
var budget = 1000000;
|
||||||
// Certify simple rings and mutually disjoint boundaries before containment.
|
// Certify simple rings and mutually disjoint boundaries before containment.
|
||||||
foreach (var (r, i, s, j) in ContactCandidates(rings, token))
|
for (var r = 0; r < rings.Count; r++)
|
||||||
{
|
for (var s = r; s < rings.Count; s++)
|
||||||
token.ThrowIfCancellationRequested();
|
for (var i = 0; i < rings[r].Length; i++)
|
||||||
if (--budget < 0)
|
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++)
|
||||||
throw new NotSupportedException("Material validation exceeds the native query limit.");
|
{
|
||||||
var a = rings[r][i];
|
token.ThrowIfCancellationRequested();
|
||||||
var b = rings[s][j];
|
if (--budget < 0)
|
||||||
var contacts = a.Contacts(b, out var overlap);
|
throw new NotSupportedException("Material validation exceeds the native query limit.");
|
||||||
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
var a = rings[r][i];
|
||||||
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
|
var b = rings[s][j];
|
||||||
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|
var contacts = a.Contacts(b, out var overlap);
|
||||||
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
|
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
||||||
// Native arc queries pad angles by 1e-5 radians, admitting
|
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
|
||||||
// intersections on an extension (distance grows with radius).
|
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|
||||||
// At a shared arc joint require both actual finite spans;
|
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
|
||||||
// overlap and genuine second intersections still refuse.
|
// Native arc queries pad angles by 1e-5 radians, admitting
|
||||||
if (arcJoint)
|
// intersections on an extension (distance grows with radius).
|
||||||
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
|
// At a shared arc joint require both actual finite spans;
|
||||||
// Native contacts at rounded arc/line joints can drift from the
|
// overlap and genuine second intersections still refuse.
|
||||||
// authored endpoint. Use the shared geometry tolerance only to
|
if (arcJoint)
|
||||||
// recognize that joint; overlaps and other contacts still refuse.
|
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
|
||||||
if (overlap || contacts.Any(p => !adjacent
|
// Native contacts at rounded arc/line joints can drift from the
|
||||||
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
|
// authored endpoint. Use the shared geometry tolerance only to
|
||||||
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
|
// recognize that joint; overlaps and other contacts still refuse.
|
||||||
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
|
if (overlap || contacts.Any(p => !adjacent
|
||||||
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
|
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
|
||||||
throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s])
|
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
|
||||||
? "A display perimeter must enclose every other contour."
|
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
|
||||||
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
|
||||||
}
|
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
||||||
// A ring encloses a point only inside the union of its curves' extents (each at least
|
}
|
||||||
// the curve's own reach), so a part with thousands of holes asks parity only where it can hold.
|
|
||||||
var ringExtents = rings.Select(ring => ring.Aggregate(PostVerificationGeometry.Extent.None,
|
|
||||||
(extent, curve) => extent.Union(curve.Extent))).ToArray();
|
|
||||||
bool Encloses(int ring, Vector point) => ringExtents[ring].Contains(point) && Inside(point, rings[ring], token);
|
|
||||||
var outer = -1;
|
var outer = -1;
|
||||||
for (var i = 0; i < rings.Count; i++)
|
for (var i = 0; i < rings.Count; i++)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (Enumerable.Range(0, rings.Count).All(j => i == j || Encloses(i, rings[j][0].Start)))
|
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token)))
|
||||||
{
|
{
|
||||||
if (outer >= 0)
|
if (outer >= 0)
|
||||||
throw new ArgumentException("Material perimeter is ambiguous.");
|
throw new ArgumentException("Material perimeter is ambiguous.");
|
||||||
@@ -143,23 +123,16 @@ public sealed class LeadMaterialSnapshot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (outer < 0)
|
if (outer < 0)
|
||||||
throw new ArgumentException(ringDisplay.Contains(true)
|
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
|
||||||
? "A display perimeter must enclose every other contour."
|
if (ringTabs[outer].Any(tab => tab))
|
||||||
: "Material must have exactly one enclosing perimeter.");
|
|
||||||
if (!sharedSpans && ringTabs[outer].Any(tab => tab))
|
|
||||||
throw new ArgumentException("A perimeter cannot have tabs.");
|
throw new ArgumentException("A perimeter cannot have tabs.");
|
||||||
if (ringDisplay.Where((_, i) => i != outer).Any(display => display))
|
var holes = rings.Where((_, i) => i != outer).ToArray();
|
||||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
for (var i = 0; i < holes.Length; i++)
|
||||||
if (ringDisplay[outer] && rings.Count == 1)
|
for (var j = i + 1; j < holes.Length; j++)
|
||||||
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
|
||||||
var holeRings = Enumerable.Range(0, rings.Count).Where(i => i != outer).ToArray();
|
|
||||||
var holes = holeRings.Select(i => rings[i]).ToArray();
|
|
||||||
for (var i = 0; i < holeRings.Length; i++)
|
|
||||||
for (var j = i + 1; j < holeRings.Length; j++)
|
|
||||||
if (Encloses(holeRings[j], holes[i][0].Start) || Encloses(holeRings[i], holes[j][0].Start))
|
|
||||||
throw new ArgumentException("Nested cutouts are not supported material.");
|
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);
|
||||||
|
|
||||||
void Finish()
|
void Finish()
|
||||||
{
|
{
|
||||||
@@ -172,14 +145,10 @@ public sealed class LeadMaterialSnapshot
|
|||||||
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
|
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
|
||||||
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
|
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
|
||||||
}
|
}
|
||||||
if (chainDisplay.Contains(true) && chainDisplay.Contains(false))
|
|
||||||
throw new ArgumentException("A display perimeter must be one whole closed contour.");
|
|
||||||
rings.Add(chain.ToArray());
|
rings.Add(chain.ToArray());
|
||||||
ringTabs.Add(chainTabs.ToArray());
|
ringTabs.Add(chainTabs.ToArray());
|
||||||
ringDisplay.Add(chainDisplay.Count > 0 && chainDisplay[0]);
|
|
||||||
chain.Clear();
|
chain.Clear();
|
||||||
chainTabs.Clear();
|
chainTabs.Clear();
|
||||||
chainDisplay.Clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||||
@@ -188,182 +157,18 @@ public sealed class LeadMaterialSnapshot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token)
|
||||||
/// The boundary pairs certification queries natively, in the order of the full pairwise scan
|
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token));
|
||||||
/// (ring, other ring, curve, other curve). A pair whose extents are clear by the lead checks'
|
|
||||||
/// margin (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>) can report no
|
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token)
|
||||||
/// native contact, so it is left out; a sweep over X finds the rest without visiting every pair.
|
|
||||||
/// Material that is not well conditioned keeps every pair.
|
|
||||||
/// </summary>
|
|
||||||
private static List<(int R, int I, int S, int J)> ContactCandidates(
|
|
||||||
IReadOnlyList<PostVerificationGeometry.Curve[]> rings, CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var curves = new List<(int Ring, int Index, PostVerificationGeometry.Extent Extent)>();
|
var inside = false;
|
||||||
var whole = PostVerificationGeometry.Extent.None;
|
foreach (var curve in ring)
|
||||||
for (var r = 0; r < rings.Count; r++)
|
|
||||||
for (var i = 0; i < rings[r].Length; i++)
|
|
||||||
{
|
|
||||||
curves.Add((r, i, rings[r][i].Extent));
|
|
||||||
whole = whole.Union(rings[r][i].Extent);
|
|
||||||
}
|
|
||||||
var pairs = new List<(int R, int I, int S, int J)>();
|
|
||||||
if (!whole.IsWellConditioned)
|
|
||||||
{
|
{
|
||||||
for (var a = 0; a < curves.Count; a++)
|
token.ThrowIfCancellationRequested();
|
||||||
for (var b = a + 1; b < curves.Count; b++)
|
if (curve.CrossesRay(point))
|
||||||
pairs.Add((curves[a].Ring, curves[a].Index, curves[b].Ring, curves[b].Index));
|
inside = !inside;
|
||||||
}
|
}
|
||||||
else
|
return inside;
|
||||||
{
|
|
||||||
// No pair spans more than the whole material, so this bounds every pair's margin.
|
|
||||||
var reach = PostVerificationGeometry.Extent.ClearMargin + PostVerificationGeometry.Extent.SpanRounding
|
|
||||||
* System.Math.Max(whole.MaxX - whole.MinX, whole.MaxY - whole.MinY);
|
|
||||||
var byMinX = curves.OrderBy(c => c.Extent.MinX).ToArray();
|
|
||||||
for (var a = 0; a < byMinX.Length; a++)
|
|
||||||
{
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
var first = byMinX[a];
|
|
||||||
for (var b = a + 1; b < byMinX.Length && byMinX[b].Extent.MinX <= first.Extent.MaxX + reach; b++)
|
|
||||||
{
|
|
||||||
var second = byMinX[b];
|
|
||||||
if (first.Extent.IsClearOfQueriesFrom(second.Extent))
|
|
||||||
continue;
|
|
||||||
pairs.Add((first.Ring, first.Index).CompareTo((second.Ring, second.Index)) < 0
|
|
||||||
? (first.Ring, first.Index, second.Ring, second.Index)
|
|
||||||
: (second.Ring, second.Index, first.Ring, first.Index));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pairs.Sort((x, y) => (x.R, x.S, x.I, x.J).CompareTo((y.R, y.S, y.I, y.J)));
|
|
||||||
return pairs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Material with fewer boundaries is scanned whole, exactly as before the index.</summary>
|
|
||||||
private const int IndexedBoundaries = 64;
|
|
||||||
|
|
||||||
private BoundaryIndex index;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The boundaries a native query from <paramref name="query"/> can report a contact with, in
|
|
||||||
/// ring order: every boundary but those whose extents are clear of it by the lead checks'
|
|
||||||
/// margin plus <paramref name="reach"/> (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>).
|
|
||||||
/// Small material returns every boundary.
|
|
||||||
/// </summary>
|
|
||||||
internal IEnumerable<PostVerificationGeometry.Curve> BoundariesNear(PostVerificationGeometry.Extent query,
|
|
||||||
double reach = 0) => PositionsNear(query, reach).Select(p => Rings[p.Ring][p.Index]);
|
|
||||||
|
|
||||||
/// <summary>As <see cref="BoundariesNear"/>, each boundary as its ring and its index in that ring.</summary>
|
|
||||||
internal IEnumerable<(int Ring, int Index)> PositionsNear(PostVerificationGeometry.Extent query, double reach = 0)
|
|
||||||
{
|
|
||||||
if (BoundaryCount < IndexedBoundaries)
|
|
||||||
return Rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i)));
|
|
||||||
return LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).Near(query, reach);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal bool ContainsMaterial(Vector point, CancellationToken token)
|
|
||||||
{
|
|
||||||
if (BoundaryCount < IndexedBoundaries)
|
|
||||||
return Inside(point, Rings[0], token) && !Rings.Skip(1).Any(hole => Inside(point, hole, token));
|
|
||||||
var holes = LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).HolesOnRow(point.Y);
|
|
||||||
return Inside(point, Rings[0], token) && !holes.Any(hole => Inside(point, Rings[hole], token));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A uniform grid of boundary extents over the material, for material with many holes. Read
|
|
||||||
/// only once built, so concurrent searches may share it.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class BoundaryIndex
|
|
||||||
{
|
|
||||||
private readonly (int Ring, int Index)[] positions;
|
|
||||||
private readonly PostVerificationGeometry.Extent[] extents;
|
|
||||||
private readonly List<int>[] cells;
|
|
||||||
private readonly List<int>[] bands;
|
|
||||||
private readonly PostVerificationGeometry.Extent[] ringExtents;
|
|
||||||
private readonly int ringCount;
|
|
||||||
private readonly PostVerificationGeometry.Extent whole;
|
|
||||||
private readonly int columns;
|
|
||||||
private readonly int rows;
|
|
||||||
private readonly double width;
|
|
||||||
private readonly double height;
|
|
||||||
|
|
||||||
internal BoundaryIndex(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, PostVerificationGeometry.Extent whole)
|
|
||||||
{
|
|
||||||
positions = rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i))).ToArray();
|
|
||||||
extents = rings.SelectMany(r => r).Select(b => b.Extent).ToArray();
|
|
||||||
ringCount = rings.Count;
|
|
||||||
ringExtents = new PostVerificationGeometry.Extent[rings.Count];
|
|
||||||
this.whole = whole;
|
|
||||||
var side = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Sqrt(positions.Length)));
|
|
||||||
columns = side;
|
|
||||||
rows = side;
|
|
||||||
width = System.Math.Max(whole.MaxX - whole.MinX, double.Epsilon) / columns;
|
|
||||||
height = System.Math.Max(whole.MaxY - whole.MinY, double.Epsilon) / rows;
|
|
||||||
cells = new List<int>[columns * rows];
|
|
||||||
bands = new List<int>[rows];
|
|
||||||
if (!whole.IsWellConditioned)
|
|
||||||
return;
|
|
||||||
for (var ring = 1; ring < rings.Count; ring++)
|
|
||||||
{
|
|
||||||
var extent = rings[ring].Aggregate(PostVerificationGeometry.Extent.None, (e, curve) => e.Union(curve.Extent));
|
|
||||||
ringExtents[ring] = extent;
|
|
||||||
for (var band = Row(extent.MinY - PostVerificationGeometry.Extent.ClearMargin);
|
|
||||||
band <= Row(extent.MaxY + PostVerificationGeometry.Extent.ClearMargin); band++)
|
|
||||||
(bands[band] ??= new List<int>()).Add(ring);
|
|
||||||
}
|
|
||||||
for (var b = 0; b < positions.Length; b++)
|
|
||||||
{
|
|
||||||
var (x0, y0, x1, y1) = Cells(extents[b], 0);
|
|
||||||
for (var y = y0; y <= y1; y++)
|
|
||||||
for (var x = x0; x <= x1; x++)
|
|
||||||
(cells[y * columns + x] ??= new List<int>()).Add(b);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The holes, in ring order, whose curves a ray along row <paramref name="y"/> can cross:
|
|
||||||
/// every hole but those whose extents are clear of the row (see
|
|
||||||
/// <see cref="PostVerificationGeometry.Extent.IsClearOfRow"/>).
|
|
||||||
/// </summary>
|
|
||||||
internal IEnumerable<int> HolesOnRow(double y)
|
|
||||||
{
|
|
||||||
if (!whole.IsWellConditioned || !(System.Math.Abs(y) <= PostVerificationGeometry.Extent.WellConditionedLimit))
|
|
||||||
return Enumerable.Range(1, ringCount - 1);
|
|
||||||
return (bands[Row(y)] ?? []).Where(ring => !ringExtents[ring].IsClearOfRow(y));
|
|
||||||
}
|
|
||||||
|
|
||||||
internal IEnumerable<(int Ring, int Index)> Near(PostVerificationGeometry.Extent query, double reach)
|
|
||||||
{
|
|
||||||
// The margin covers every pair's span, and each skip is then decided for the pair alone.
|
|
||||||
// Material or a query that is not well conditioned (whose cells were never built, for
|
|
||||||
// the material) keeps every boundary.
|
|
||||||
var union = whole.Union(query);
|
|
||||||
var margin = PostVerificationGeometry.Extent.ClearMargin + reach
|
|
||||||
+ PostVerificationGeometry.Extent.SpanRounding * System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY);
|
|
||||||
if (!union.IsWellConditioned || !double.IsFinite(margin))
|
|
||||||
return positions;
|
|
||||||
var (x0, y0, x1, y1) = Cells(query, margin);
|
|
||||||
var found = new List<int>();
|
|
||||||
for (var y = y0; y <= y1; y++)
|
|
||||||
for (var x = x0; x <= x1; x++)
|
|
||||||
if (cells[y * columns + x] is { } cell)
|
|
||||||
found.AddRange(cell);
|
|
||||||
found.Sort();
|
|
||||||
var near = new List<(int Ring, int Index)>();
|
|
||||||
for (var i = 0; i < found.Count; i++)
|
|
||||||
if ((i == 0 || found[i] != found[i - 1]) && !query.IsClearOfQueriesFrom(extents[found[i]], reach))
|
|
||||||
near.Add(positions[found[i]]);
|
|
||||||
return near;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The cells an extent widened by margin covers, clamped to the grid.
|
|
||||||
private (int X0, int Y0, int X1, int Y1) Cells(PostVerificationGeometry.Extent extent, double margin) => (
|
|
||||||
Column(extent.MinX - margin), Row(extent.MinY - margin), Column(extent.MaxX + margin), Row(extent.MaxY + margin));
|
|
||||||
|
|
||||||
private int Column(double x) => (int)System.Math.Clamp(System.Math.Floor((x - whole.MinX) / width), 0, columns - 1);
|
|
||||||
|
|
||||||
private int Row(double y) => (int)System.Math.Clamp(System.Math.Floor((y - whole.MinY) / height), 0, rows - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token) =>
|
|
||||||
PostVerificationGeometry.Inside(point, ring, token);
|
|
||||||
}
|
}
|
||||||
@@ -65,17 +65,6 @@ public static class LeadPathValidator
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var move = execution.Motions[i];
|
var move = execution.Motions[i];
|
||||||
if (!move.Rapid && move.Layer == LayerType.CommonLine)
|
|
||||||
{
|
|
||||||
// A shared cut runs in the kerf between two parts: it may touch no material at all.
|
|
||||||
var (shared, sharedObstacle) = CheckSharedCut(i);
|
|
||||||
if (shared == null)
|
|
||||||
continue;
|
|
||||||
if (isFixed?.Invoke(i) != true || fixedFailures == null)
|
|
||||||
return shared;
|
|
||||||
fixedFailures.Add(new(i, shared, sharedObstacle));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (move.Layer is not (LayerType.Leadin or LayerType.Leadout))
|
if (move.Layer is not (LayerType.Leadin or LayerType.Leadout))
|
||||||
continue;
|
continue;
|
||||||
var (failure, obstacle) = CheckMotion(i);
|
var (failure, obstacle) = CheckMotion(i);
|
||||||
@@ -89,27 +78,6 @@ public static class LeadPathValidator
|
|||||||
// checks rapid travel and contour accounting for the complete program.
|
// checks rapid travel and contour accounting for the complete program.
|
||||||
return new(true, true, null);
|
return new(true, true, null);
|
||||||
|
|
||||||
(LeadPathValidationResult Failure, int? Obstacle) CheckSharedCut(int i)
|
|
||||||
{
|
|
||||||
var move = execution.Motions[i];
|
|
||||||
if (move.Curve == null || !double.IsFinite(move.Length) || move.Length <= PostVerificationGeometry.Epsilon)
|
|
||||||
return (new(false, false, "A common-line shared cut is missing, degenerate or inconsistent."), null);
|
|
||||||
if (MaterialContact(move.Curve, target, null, ref budget, token) is { } own)
|
|
||||||
return (new(true, false, $"Common-line shared cut {i} contacts its own part's material ({own}). "
|
|
||||||
+ "Rejoin the parts one kerf apart, then replan."), null);
|
|
||||||
for (var other = 0; other < otherMaterials.Count; other++)
|
|
||||||
{
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
var material = otherMaterials[other];
|
|
||||||
if (ReferenceEquals(material, target) || move.Curve.Extent.IsClearOfQueriesFrom(material.Extent))
|
|
||||||
continue;
|
|
||||||
if (MaterialContact(move.Curve, material, null, ref budget, token) != null)
|
|
||||||
return (new(true, false, "A common-line shared cut contacts or enters another placed material. "
|
|
||||||
+ "Rejoin the parts one kerf apart at the current kerf, then replan."), other);
|
|
||||||
}
|
|
||||||
return (null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
(LeadPathValidationResult Failure, int? Obstacle) CheckMotion(int i)
|
(LeadPathValidationResult Failure, int? Obstacle) CheckMotion(int i)
|
||||||
{
|
{
|
||||||
var move = execution.Motions[i];
|
var move = execution.Motions[i];
|
||||||
@@ -132,7 +100,6 @@ public static class LeadPathValidator
|
|||||||
var adjacentIndex = groupEdge + step;
|
var adjacentIndex = groupEdge + step;
|
||||||
var genuineJoint = false;
|
var genuineJoint = false;
|
||||||
var breakLead = false;
|
var breakLead = false;
|
||||||
var sharedLead = false;
|
|
||||||
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
|
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
|
||||||
{
|
{
|
||||||
var adjacent = execution.Motions[adjacentIndex];
|
var adjacent = execution.Motions[adjacentIndex];
|
||||||
@@ -152,28 +119,13 @@ public static class LeadPathValidator
|
|||||||
allowed = joint;
|
allowed = joint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A shared cut lies in the kerf between two parts, so its lead joins no boundary
|
|
||||||
// and may touch none: its joint is the cut's own start (or end for a lead-out).
|
|
||||||
else if (!adjacent.Rapid && adjacent.Layer == LayerType.CommonLine
|
|
||||||
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
|
|
||||||
{
|
|
||||||
var edge = execution.Motions[groupEdge];
|
|
||||||
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
|
|
||||||
var cutJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
|
|
||||||
if (joint.DistanceTo(cutJoint) <= PostVerificationGeometry.Epsilon)
|
|
||||||
{
|
|
||||||
genuineJoint = true;
|
|
||||||
sharedLead = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
|
else if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
|
||||||
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
|
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
|
||||||
{
|
{
|
||||||
var edge = execution.Motions[groupEdge];
|
var edge = execution.Motions[groupEdge];
|
||||||
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
|
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
|
||||||
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
|
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
|
||||||
// A boundary clear of the contour's first motion contains none of its points.
|
foreach (var boundary in target.Rings.SelectMany(r => r))
|
||||||
foreach (var boundary in target.BoundariesNear(adjacent.Curve.Extent))
|
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (--budget < 0)
|
if (--budget < 0)
|
||||||
@@ -201,13 +153,11 @@ public static class LeadPathValidator
|
|||||||
// material is expected there, but touching any of its boundaries is not.
|
// material is expected there, but touching any of its boundaries is not.
|
||||||
var failure = breakLead
|
var failure = breakLead
|
||||||
? MaterialContact(move.Curve, target, null, ref budget, token, allowInterior: true)
|
? MaterialContact(move.Curve, target, null, ref budget, token, allowInterior: true)
|
||||||
: CheckMaterial(target, sharedLead ? null : allowed);
|
: CheckMaterial(target, allowed);
|
||||||
if (failure != null)
|
if (failure != null)
|
||||||
return (new(true, false, breakLead
|
return (new(true, false, breakLead
|
||||||
? $"Break cut lead motion {i} contacts its part's boundary ({failure}). Try a shorter lead-in or move the tabs."
|
? $"Break cut lead motion {i} contacts its part's boundary ({failure}). Try a shorter lead-in or move the tabs."
|
||||||
: sharedLead
|
: $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null);
|
||||||
? $"Common-line lead motion {i} contacts its part's material ({failure}). The lead runs along the shared line: shorten the lead-in or lead-out, or cut the common lines twice."
|
|
||||||
: $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null);
|
|
||||||
var reach = move.Curve.Extent;
|
var reach = move.Curve.Extent;
|
||||||
for (var other = 0; other < otherMaterials.Count; other++)
|
for (var other = 0; other < otherMaterials.Count; other++)
|
||||||
{
|
{
|
||||||
@@ -240,7 +190,10 @@ public static class LeadPathValidator
|
|||||||
// Every boundary of material this far away is farther than the zone.
|
// Every boundary of material this far away is farther than the zone.
|
||||||
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
|
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
|
||||||
.IsClearOfQueriesFrom(material.Extent, pierceRadius))
|
.IsClearOfQueriesFrom(material.Extent, pierceRadius))
|
||||||
|
{
|
||||||
|
Charge(material.BoundaryCount);
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
if (Clearance(material) is { } distance)
|
if (Clearance(material) is { } distance)
|
||||||
return (Short(distance, "another placed part"), other);
|
return (Short(distance, "another placed part"), other);
|
||||||
}
|
}
|
||||||
@@ -279,11 +232,11 @@ public static class LeadPathValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Material too far from the lead for any native boundary query to report a contact
|
// Material too far from the lead for any native boundary query to report a contact
|
||||||
// (see IsClearOfQueriesFrom): the result the full scan gives. Only queries that run
|
// (see IsClearOfQueriesFrom): the result the full scan gives, charged as if it ran.
|
||||||
// are charged, so many far parts or holes do not exhaust the budget. Ray parity is
|
// Ray parity is still asked natively unless the lead lies beside the material's rows.
|
||||||
// still asked natively unless the lead lies beside the material's rows.
|
|
||||||
bool FarInside(LeadMaterialSnapshot material)
|
bool FarInside(LeadMaterialSnapshot material)
|
||||||
{
|
{
|
||||||
|
Charge(material.BoundaryCount);
|
||||||
var midpoint = move.Curve.Midpoint;
|
var midpoint = move.Curve.Midpoint;
|
||||||
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
|
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
|
||||||
}
|
}
|
||||||
@@ -336,32 +289,19 @@ public static class LeadPathValidator
|
|||||||
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
|
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
|
||||||
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
|
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
|
||||||
{
|
{
|
||||||
// Boundaries no native query from the curve can reach report no contact; only the others run.
|
foreach (var boundary in material.Rings.SelectMany(r => r))
|
||||||
foreach (var boundary in material.BoundariesNear(curve.Extent))
|
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (--budget < 0)
|
if (--budget < 0)
|
||||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||||
var contacts = curve.Contacts(boundary, out var overlap);
|
var contacts = curve.Contacts(boundary, out var overlap);
|
||||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint || !AtJoint(p, joint, boundary)))
|
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|
||||||
|
|| 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,6 +2,7 @@ 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;
|
||||||
|
|
||||||
@@ -53,6 +54,19 @@ internal static class NestedPartContainment
|
|||||||
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary>
|
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary>
|
||||||
internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
|
internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
|
||||||
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
|
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
|
||||||
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter && m.Curve != null)
|
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && 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] = BoundingBox.Union(part.BoundingBox, cleanBounds[index]);
|
hostBounds[index] = NestedPartContainment.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
|
||||||
|| !BoundingBox.IsWithin(cleanBounds[inner], hostBounds[host]))
|
|| !NestedPartContainment.Within(cleanBounds[inner], hostBounds[host]))
|
||||||
continue;
|
continue;
|
||||||
var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token,
|
var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token,
|
||||||
out var ambiguity);
|
out var ambiguity);
|
||||||
|
|||||||
@@ -33,20 +33,18 @@ internal static class OwnedCuttingParameters
|
|||||||
PierceDiameter = Dimension(source.PierceDiameter),
|
PierceDiameter = Dimension(source.PierceDiameter),
|
||||||
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
|
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
|
||||||
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
|
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
|
||||||
|
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
|
||||||
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
|
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
|
||||||
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
|
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
|
||||||
|
ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
|
||||||
RoundLeadInAngles = source.RoundLeadInAngles,
|
RoundLeadInAngles = source.RoundLeadInAngles,
|
||||||
LeadInAngleIncrement = source.LeadInAngleIncrement,
|
LeadInAngleIncrement = source.LeadInAngleIncrement,
|
||||||
|
AutoTabMinSize = Dimension(source.AutoTabMinSize),
|
||||||
|
AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
|
||||||
TabConfig = source.TabConfig == null
|
TabConfig = source.TabConfig == null
|
||||||
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
|
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
|
||||||
: CopyTab(source.TabConfig),
|
: CopyTab(source.TabConfig),
|
||||||
TabsEnabled = source.TabsEnabled,
|
TabsEnabled = source.TabsEnabled,
|
||||||
SupportTabs = source.SupportTabs,
|
|
||||||
TabBySize = source.TabBySize,
|
|
||||||
TabSizeMinWidth = Dimension(source.TabSizeMinWidth),
|
|
||||||
TabSizeMaxWidth = Dimension(source.TabSizeMaxWidth),
|
|
||||||
TabHeldMaxLength = Dimension(source.TabHeldMaxLength),
|
|
||||||
SliceTippingSlugs = source.SliceTippingSlugs,
|
|
||||||
Sequencing = new SequenceParameters
|
Sequencing = new SequenceParameters
|
||||||
{
|
{
|
||||||
Method = sequence.Method,
|
Method = sequence.Method,
|
||||||
@@ -73,8 +71,7 @@ internal static class OwnedCuttingParameters
|
|||||||
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
|
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
|
||||||
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
|
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
|
||||||
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
|
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
|
||||||
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn
|
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn { Radius = Dimension(n.Radius) },
|
||||||
{ Radius = Dimension(n.Radius), SweepAngle = Sweep(n.SweepAngle) },
|
|
||||||
LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
|
LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
|
||||||
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
|
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
|
||||||
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
|
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
|
||||||
@@ -140,7 +137,4 @@ internal static class OwnedCuttingParameters
|
|||||||
// Approach angles are degrees, not lengths. Signed and periodic angles are valid.
|
// Approach angles are degrees, not lengths. Signed and periodic angles are valid.
|
||||||
private static double Angle(double value) => double.IsFinite(value)
|
private static double Angle(double value) => double.IsFinite(value)
|
||||||
? value : throw new ArgumentException("Angles must be finite.");
|
? value : throw new ArgumentException("Angles must be finite.");
|
||||||
|
|
||||||
private static double Sweep(double value) => double.IsFinite(value) && value > 0 && value <= 180
|
|
||||||
? value : throw new ArgumentException("Arc sweep must be in (0, 180] degrees.");
|
|
||||||
}
|
}
|
||||||
@@ -38,38 +38,20 @@ 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, bool headRaised = false, int? code = null, int? callCode = null)
|
PostVerificationGeometry.Curve curve)
|
||||||
{
|
{
|
||||||
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, HeadRaised, Code, CallCode);
|
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,6 @@ using System.Linq;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest.CNC.CuttingStrategy;
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
using OpenNest.Collections;
|
using OpenNest.Collections;
|
||||||
using OpenNest.CommonLines;
|
|
||||||
using OpenNest.CutOffs;
|
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingPlanning;
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
@@ -29,17 +27,10 @@ public sealed class PlateCuttingState
|
|||||||
private readonly Dictionary<Drawing, DrawingRecord> drawings;
|
private readonly Dictionary<Drawing, DrawingRecord> drawings;
|
||||||
private readonly Dictionary<CuttingParameters, string> settings;
|
private readonly Dictionary<CuttingParameters, string> settings;
|
||||||
private readonly CuttingParameters plateSettings;
|
private readonly CuttingParameters plateSettings;
|
||||||
private readonly ObservableList<CommonLineGroup> commonLineList;
|
|
||||||
private readonly CommonLineRecord[] commonLines;
|
|
||||||
private readonly bool cutCommonLinesTwice;
|
|
||||||
|
|
||||||
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
|
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
|
||||||
Dictionary<Drawing, DrawingRecord> drawings, Dictionary<CuttingParameters, string> settings)
|
Dictionary<Drawing, DrawingRecord> drawings, Dictionary<CuttingParameters, string> settings)
|
||||||
{
|
{
|
||||||
commonLineList = plate.CommonLines;
|
|
||||||
commonLines = plate.CommonLines.Select(group => new CommonLineRecord(group, group.Kerf,
|
|
||||||
group.Members.ToArray())).ToArray();
|
|
||||||
cutCommonLinesTwice = plate.CutCommonLinesTwice;
|
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
plateSettings = plate.CuttingParameters;
|
plateSettings = plate.CuttingParameters;
|
||||||
Plate = plate;
|
Plate = plate;
|
||||||
@@ -91,8 +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, c.Gaps,
|
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit)).ToArray();
|
||||||
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
|
|
||||||
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
||||||
|
|
||||||
void Fingerprint(CuttingParameters parameters)
|
void Fingerprint(CuttingParameters parameters)
|
||||||
@@ -165,24 +155,9 @@ public sealed class PlateCuttingState
|
|||||||
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|
||||||
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|
||||||
|| !Bits(cutOff.Position.Y, record.Position.Y)
|
|| !Bits(cutOff.Position.Y, record.Position.Y)
|
||||||
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)
|
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit))
|
||||||
// Gaps are replaced, never edited in place, so the reference decides.
|
|
||||||
|| !ReferenceEquals(cutOff.Gaps, record.Gaps)
|
|
||||||
|| !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
|
|
||||||
return $"Cutoff {i + 1} definition changed.";
|
return $"Cutoff {i + 1} definition changed.";
|
||||||
}
|
}
|
||||||
// Common lines decide which edges each member cuts, and whether they are cut once.
|
|
||||||
if (!ReferenceEquals(plate.CommonLines, commonLineList) || plate.CutCommonLinesTwice != cutCommonLinesTwice
|
|
||||||
|| plate.CommonLines.Count != commonLines.Length)
|
|
||||||
return "Common lines changed.";
|
|
||||||
for (var i = 0; i < commonLines.Length; i++)
|
|
||||||
{
|
|
||||||
var record = commonLines[i];
|
|
||||||
var group = plate.CommonLines[i];
|
|
||||||
if (!ReferenceEquals(group, record.Group) || !Bits(group.Kerf, record.Kerf)
|
|
||||||
|| !group.Members.SequenceEqual(record.Members, ReferenceEqualityComparer.Instance))
|
|
||||||
return $"Common line {i + 1} changed.";
|
|
||||||
}
|
|
||||||
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
|
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
|
||||||
// all compared exactly above.
|
// all compared exactly above.
|
||||||
return null;
|
return null;
|
||||||
@@ -228,9 +203,6 @@ public sealed class PlateCuttingState
|
|||||||
private sealed record DrawingRecord(Program Program, Program Copy, bool IsCutOff,
|
private sealed record DrawingRecord(Program Program, Program Copy, bool IsCutOff,
|
||||||
List<ContourCutPin> Pins, string PinsFingerprint);
|
List<ContourCutPin> Pins, string PinsFingerprint);
|
||||||
|
|
||||||
private sealed record CommonLineRecord(CommonLineGroup Group, double Kerf, Part[] Members);
|
|
||||||
|
|
||||||
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
|
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
|
||||||
double? StartLimit, double? EndLimit, IReadOnlyList<CutOffGap> Gaps, double HeadLockOvercut,
|
double? StartLimit, double? EndLimit);
|
||||||
bool FarEdgeFirst);
|
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,6 @@ using System.Threading;
|
|||||||
using OpenNest.CNC.CuttingStrategy;
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
using OpenNest.Diagnostics;
|
using OpenNest.Diagnostics;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
using OpenNest.Support;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingPlanning;
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
@@ -16,32 +14,13 @@ public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector
|
|||||||
internal PreparedContours Owner { get; init; }
|
internal PreparedContours Owner { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A pierce the planner placed off the contour: a perimeter's in open scrap for the pierce
|
/// A perimeter pierce the planner placed in open scrap for the pierce zone (see
|
||||||
/// zone (see <see cref="PreparedContours.ScrapPierceEntry"/>), or a cutout's where eroding it
|
/// <see cref="PreparedContours.ScrapPierceEntry"/>): the lead-in runs straight from here to
|
||||||
/// leaves room (see <see cref="PreparedContours.ErodedPierceEntries"/>). The lead-in runs
|
/// <see cref="Point"/> instead of the configured lead-in. Null for every other choice.
|
||||||
/// straight from here to <see cref="Point"/> instead of the configured lead-in; a cutout so
|
|
||||||
/// pierced has no lead-out. Null for every other choice.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Vector? Pierce { get; internal init; }
|
public Vector? Pierce { get; internal init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How a common-line member's outside is cut once (see docs/common-line-cutting.md), with the
|
|
||||||
/// group's kerf. The member cuts its outside compensated, so each shared edge it cuts lands on the
|
|
||||||
/// kerf centerline: that edge is the common line. Spans an earlier member cut carry the Tab layer
|
|
||||||
/// and are never cut again. A member that cuts a common line itself (<see cref="Entry"/> set)
|
|
||||||
/// starts there: at <see cref="Entry"/> (part coordinates), the vertex where its shared edge
|
|
||||||
/// begins, with a straight lead-in from <see cref="Pierce"/> along that edge's axis, and runs on
|
|
||||||
/// round its perimeter with no lead-out, closing at the entry or stopping in an earlier member's
|
|
||||||
/// kerf. Its other stretches, and every stretch of a member without an entry, are remainder pieces
|
|
||||||
/// entered in the kerf before them.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record CommonLinePerimeter(double Kerf, Vector? Entry = null, Vector? Pierce = null)
|
|
||||||
{
|
|
||||||
/// <summary>Whether the member starts its outside on a common line it cuts.</summary>
|
|
||||||
public bool StartsOnCommonLine => Entry != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated
|
/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated
|
||||||
/// program and keep it stable during Capture. This is not a topology or lead-path verdict.
|
/// program and keep it stable during Capture. This is not a topology or lead-path verdict.
|
||||||
@@ -57,44 +36,15 @@ 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;
|
||||||
// Slices of the slugs the support bed cannot hold level, in cutting order: open cuts inside
|
|
||||||
// their cutouts, cut right after the break cuts so every slug is sliced before its cutout.
|
|
||||||
private readonly IReadOnlyList<SlugSlice> slugSlices;
|
|
||||||
// Pierce-zone resolutions by contour, shared with every copy: the contours are the same by ordinal.
|
|
||||||
private readonly PierceZoneMemo pierceZones;
|
|
||||||
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and
|
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and
|
||||||
// its own owned settings (the pin's lead-in, lead-out and tab).
|
// its own owned settings (the pin's lead-in, lead-out and tab).
|
||||||
private readonly ContourChoice[] pinned;
|
private readonly ContourChoice[] pinned;
|
||||||
private readonly CuttingParameters[] contourParameters;
|
private readonly CuttingParameters[] contourParameters;
|
||||||
private readonly int[] pinNumbers;
|
private readonly int[] pinNumbers;
|
||||||
// Set when the support bed cannot hold the part level: the perimeter is tabbed and its
|
|
||||||
// automatic entries keep to points where the tab steadies the part. World = local + origin.
|
|
||||||
private readonly PartSupport support;
|
|
||||||
private readonly Vector supportOrigin;
|
|
||||||
// The outside when it is a display perimeter: shown and material, never cut. It is not one of
|
|
||||||
// the contours, so every contour is a hole; a complete emission only appends it as the
|
|
||||||
// never-executed DisplayPerimeterTail. Null for an ordinary cut perimeter.
|
|
||||||
private readonly Shape displayOutline;
|
|
||||||
private bool hasCutPerimeter => displayOutline == null;
|
|
||||||
// Set for a common-line member, cut from one fixed entry: its whole perimeter from where its
|
|
||||||
// first shared edge begins, or its remainder pieces between the Tab spans earlier members cut.
|
|
||||||
private readonly CommonLinePerimeter commonLine;
|
|
||||||
private readonly ContourChoice commonLineEntry;
|
|
||||||
|
|
||||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
||||||
CuttingParameters parameters)
|
CuttingParameters parameters)
|
||||||
: this(shapes, scribes, breakCuts, tabbedCutouts, parameters, displayOutline: null)
|
|
||||||
{
|
{
|
||||||
}
|
|
||||||
|
|
||||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
|
||||||
CuttingParameters parameters, Shape displayOutline, PierceZoneMemo pierceZones = null,
|
|
||||||
CommonLinePerimeter commonLine = null, IReadOnlyList<SlugSlice> slugSlices = null)
|
|
||||||
{
|
|
||||||
this.slugSlices = slugSlices ?? Array.Empty<SlugSlice>();
|
|
||||||
this.pierceZones = pierceZones ?? new PierceZoneMemo();
|
|
||||||
this.displayOutline = displayOutline;
|
|
||||||
this.commonLine = commonLine;
|
|
||||||
this.shapes = shapes;
|
this.shapes = shapes;
|
||||||
this.scribes = scribes;
|
this.scribes = scribes;
|
||||||
this.breakCuts = breakCuts;
|
this.breakCuts = breakCuts;
|
||||||
@@ -102,31 +52,10 @@ public sealed class PreparedContours
|
|||||||
this.parameters = parameters;
|
this.parameters = parameters;
|
||||||
pinned = new ContourChoice[shapes.Length];
|
pinned = new ContourChoice[shapes.Length];
|
||||||
pinNumbers = new int[shapes.Length];
|
pinNumbers = new int[shapes.Length];
|
||||||
if (commonLine is { Entry: { } entry })
|
|
||||||
{
|
|
||||||
// The outside starts where a shared edge it cuts begins, pierced off it.
|
|
||||||
var perimeter = shapes[^1];
|
|
||||||
var entity = perimeter.Entities.FindIndex(e => e.Layer != SpecialLayers.Tab
|
|
||||||
&& Start(e).DistanceTo(entry) <= PostVerificationGeometry.Epsilon);
|
|
||||||
commonLineEntry = new ContourChoice(shapes.Length - 1, entity, Start(perimeter.Entities[entity]))
|
|
||||||
{
|
|
||||||
Owner = this,
|
|
||||||
Pierce = commonLine.Pierce,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else if (commonLine != null)
|
|
||||||
{
|
|
||||||
// The remainder's first piece starts where a shared span ends: the one fixed entry.
|
|
||||||
var perimeter = shapes[^1];
|
|
||||||
var first = ContourCuttingStrategy.TabbedCutoutPieces(perimeter)[0].Entities[0];
|
|
||||||
var entity = perimeter.Entities.IndexOf(first);
|
|
||||||
commonLineEntry = new ContourChoice(shapes.Length - 1, entity, Start(first)) { Owner = this };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
|
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
|
||||||
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters,
|
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters)
|
||||||
unpinned.displayOutline, unpinned.pierceZones, unpinned.commonLine, unpinned.slugSlices)
|
|
||||||
{
|
{
|
||||||
contourParameters = new CuttingParameters[shapes.Length];
|
contourParameters = new CuttingParameters[shapes.Length];
|
||||||
var rejected = new List<CutPinResolution>();
|
var rejected = new List<CutPinResolution>();
|
||||||
@@ -143,16 +72,13 @@ public sealed class PreparedContours
|
|||||||
throw new ArgumentException("A pin resolution does not belong to these contours.", nameof(pins));
|
throw new ArgumentException("A pin resolution does not belong to these contours.", nameof(pins));
|
||||||
if (pinned[contour] != null)
|
if (pinned[contour] != null)
|
||||||
throw new ArgumentException("Two pins resolve to one contour.", nameof(pins));
|
throw new ArgumentException("Two pins resolve to one contour.", nameof(pins));
|
||||||
// The emitter tabs only the outside, so a tab elsewhere cannot be cut as pinned. A
|
// The emitter tabs only the outside, so a tab elsewhere cannot be cut as pinned.
|
||||||
// common-line member's outside is cut from its shared spans, never from a pin.
|
if (pin.Pin.HasTab && contour != PerimeterOrdinal)
|
||||||
if (pin.Pin.HasTab && contour != PerimeterOrdinal || commonLine != null && contour == PerimeterOrdinal)
|
|
||||||
{
|
{
|
||||||
rejected.Add(pin);
|
rejected.Add(pin);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var choice = new ContourChoice(contour, pin.EntityOrdinal,
|
var choice = new ContourChoice(contour, pin.EntityOrdinal, pin.Point) { Owner = this };
|
||||||
SnapToVertex(shapes[contour], pin.EntityOrdinal, pin.Point))
|
|
||||||
{ Owner = this };
|
|
||||||
ValidateChoice(choice);
|
ValidateChoice(choice);
|
||||||
pinned[contour] = choice;
|
pinned[contour] = choice;
|
||||||
pinNumbers[contour] = pin.PinIndex + 1;
|
pinNumbers[contour] = pin.PinIndex + 1;
|
||||||
@@ -161,170 +87,17 @@ public sealed class PreparedContours
|
|||||||
RejectedPins = rejected.AsReadOnly();
|
RejectedPins = rejected.AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
// A copy that tabs the perimeter for support; every pin keeps its entry, now owned here, and
|
|
||||||
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review.
|
|
||||||
private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
|
|
||||||
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
|
|
||||||
source.displayOutline, source.pierceZones, source.commonLine, source.slugSlices)
|
|
||||||
{
|
|
||||||
for (var contour = 0; contour < Count; contour++)
|
|
||||||
{
|
|
||||||
pinned[contour] = source.pinned[contour] is { } pin ? pin with { Owner = this } : null;
|
|
||||||
pinNumbers[contour] = source.pinNumbers[contour];
|
|
||||||
}
|
|
||||||
contourParameters = source.contourParameters == null
|
|
||||||
? new CuttingParameters[Count]
|
|
||||||
: (CuttingParameters[])source.contourParameters.Clone();
|
|
||||||
if (pinned[PerimeterOrdinal] == null)
|
|
||||||
{
|
|
||||||
var perimeter = OwnedCuttingParameters.Copy(source.ParametersFor(PerimeterOrdinal));
|
|
||||||
perimeter.TabsEnabled = true;
|
|
||||||
perimeter.TabConfig = OwnedCuttingParameters.CopyTab(tab);
|
|
||||||
contourParameters[PerimeterOrdinal] = perimeter;
|
|
||||||
}
|
|
||||||
RejectedPins = source.RejectedPins;
|
|
||||||
this.support = support;
|
|
||||||
supportOrigin = location;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A copy whose perimeter is tabbed with <paramref name="tab"/> where it steadies the part
|
|
||||||
/// on its support bed: <paramref name="support"/> analyzed this part's material placed at
|
|
||||||
/// <paramref name="location"/>, and the perimeter's automatic entries keep to points it
|
|
||||||
/// accepts (<see cref="PartSupport.AcceptsTabAt"/>) plus its best tab point. A pinned
|
|
||||||
/// perimeter keeps its pin's entry, leads and tab (the drawing wins); the copy only keeps
|
|
||||||
/// the analysis, so replay can report a pin that leaves the part unsteady.
|
|
||||||
/// </summary>
|
|
||||||
public PreparedContours WithSupportTab(PartSupport support, Vector location, Tab tab)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(support);
|
|
||||||
ArgumentNullException.ThrowIfNull(tab);
|
|
||||||
PostVerificationGeometry.Validate(location);
|
|
||||||
if (!support.IsComplete || support.BestTabPoint == null)
|
|
||||||
throw new ArgumentException("Support tabs need a complete analysis with a tab point.", nameof(support));
|
|
||||||
if (!hasCutPerimeter)
|
|
||||||
throw new ArgumentException("A display perimeter is never cut, so it cannot hold a support tab.", nameof(support));
|
|
||||||
if (commonLine != null)
|
|
||||||
throw new ArgumentException("A common-line member is never support-tabbed.", nameof(support));
|
|
||||||
return new PreparedContours(this, support, location, tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A copy that slices slugs; everything else, pins and support analysis included, is kept.
|
|
||||||
private PreparedContours(PreparedContours source, IReadOnlyList<SlugSlice> slices)
|
|
||||||
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
|
|
||||||
source.displayOutline, source.pierceZones, source.commonLine, slices)
|
|
||||||
{
|
|
||||||
for (var contour = 0; contour < Count; contour++)
|
|
||||||
{
|
|
||||||
pinned[contour] = source.pinned[contour] is { } pin ? pin with { Owner = this } : null;
|
|
||||||
pinNumbers[contour] = source.pinNumbers[contour];
|
|
||||||
}
|
|
||||||
contourParameters = (CuttingParameters[])source.contourParameters?.Clone();
|
|
||||||
RejectedPins = source.RejectedPins;
|
|
||||||
support = source.support;
|
|
||||||
supportOrigin = source.supportOrigin;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A copy that slices the slug of each cutout <paramref name="bed"/> cannot hold level
|
|
||||||
/// (<see cref="SlugSlicer.Tips"/>), with the part placed at <paramref name="location"/> on a
|
|
||||||
/// sheet in <paramref name="quadrant"/>, along the bed's contact lines, each slice stopping the
|
|
||||||
/// kerf short of its cutout (docs/support-tabs.md#slug-slices). Every full or prefix emission
|
|
||||||
/// cuts the slices right after the break cuts, so each slug is sliced before its cutout.
|
|
||||||
/// A cutout that holds another of this part's cuts, or whose box holds one of
|
|
||||||
/// <paramref name="otherParts"/> (world boxes of the plate's other parts), is not sliced:
|
|
||||||
/// a slice would cut what is nested in it. Without a bed or a kerf, or with nothing to slice,
|
|
||||||
/// this preparation is returned unchanged.
|
|
||||||
/// </summary>
|
|
||||||
internal PreparedContours WithSlugSlices(SupportBed bed, int quadrant, Vector location,
|
|
||||||
IReadOnlyList<Box> otherParts, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(otherParts);
|
|
||||||
PostVerificationGeometry.Validate(location);
|
|
||||||
var standoff = parameters.Kerf;
|
|
||||||
if (bed == null || !(standoff > 0) || !double.IsFinite(standoff))
|
|
||||||
return this;
|
|
||||||
// Pierced on the slice where the cutouts' own lead-in would pierce, and at least as far
|
|
||||||
// from the cutout as any pierce keeps from its contour.
|
|
||||||
var clearance = parameters.GetEffectivePierceClearance();
|
|
||||||
var reach = System.Math.Max(clearance,
|
|
||||||
ContourCuttingStrategy.CommonLineLeadInReach(parameters.InternalLeadIn ?? new NoLeadIn()));
|
|
||||||
var slices = new List<SlugSlice>();
|
|
||||||
for (var ordinal = 0; ordinal < PerimeterOrdinal; ordinal++)
|
|
||||||
{
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
var hole = shapes[ordinal];
|
|
||||||
var box = hole.BoundingBox;
|
|
||||||
var world = new Box(box.Left + location.X, box.Bottom + location.Y, box.Length, box.Width);
|
|
||||||
if (otherParts.Any(other => other != null && world.Contains(other)))
|
|
||||||
continue;
|
|
||||||
var polygon = hole.ToPolygonWithTolerance(SupportMaterial.ChordTolerance);
|
|
||||||
if (EnclosesOtherCuts(ordinal, polygon)
|
|
||||||
|| !SlugSlicer.Tips(bed, quadrant, SupportMaterial.FromPolygons(polygon.Vertices.Select(v => v + location))))
|
|
||||||
continue;
|
|
||||||
slices.AddRange(SlugSlicer.Slices(bed, quadrant, polygon.Vertices, location, standoff, reach, clearance));
|
|
||||||
}
|
|
||||||
return slices.Count == 0 ? this : new PreparedContours(this, slices.AsReadOnly());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whether another contour, break cut or tabbed cutout of this part lies inside the cutout.
|
|
||||||
private bool EnclosesOtherCuts(int ordinal, Polygon cutout)
|
|
||||||
{
|
|
||||||
var others = shapes.Where((_, index) => index != ordinal && index != PerimeterOrdinal)
|
|
||||||
.Concat(breakCuts).Concat(tabbedCutouts);
|
|
||||||
return others.Any(other => other.Entities.Count > 0 && cutout.ContainsPoint(other.Entities[0] switch
|
|
||||||
{
|
|
||||||
Circle circle => circle.Center + new Vector(circle.Radius, 0),
|
|
||||||
var entity => Start(entity),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The slug slices this preparation cuts after its break cuts and before its contours.</summary>
|
|
||||||
public IReadOnlyList<SlugSlice> SlugSlices => slugSlices;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The analysis of a part its support bed cannot hold level (see <see cref="WithSupportTab"/>),
|
|
||||||
/// or null; <see cref="SupportOrigin"/> places its local contours in its world frame.
|
|
||||||
/// </summary>
|
|
||||||
public PartSupport Support => support;
|
|
||||||
|
|
||||||
/// <summary>The part's location: world = local + this, for <see cref="Support"/>.</summary>
|
|
||||||
public Vector SupportOrigin => supportOrigin;
|
|
||||||
|
|
||||||
/// <summary>Whether the emitted perimeter is tabbed: by the plate settings, its pin or support.</summary>
|
|
||||||
internal bool PerimeterTabbed => hasCutPerimeter && commonLine == null && ParametersFor(PerimeterOrdinal).TabsEnabled;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True for a common-line member (see <see cref="CommonLinePerimeter"/>): its perimeter has one
|
|
||||||
/// fixed entry and is emitted whole from its common line, or as its remainder pieces.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsCommonLinePerimeter => commonLine != null;
|
|
||||||
|
|
||||||
/// <summary>How the member's outside is cut once, or null for any other part.</summary>
|
|
||||||
public CommonLinePerimeter CommonLine => commonLine;
|
|
||||||
|
|
||||||
public int Count => shapes.Length;
|
public int Count => shapes.Length;
|
||||||
|
|
||||||
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
|
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
|
||||||
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
|
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
|
||||||
|
public int PerimeterOrdinal => Count - 1;
|
||||||
|
|
||||||
/// <summary>The pierce-zone resolutions these contours' emissions share; for tests.</summary>
|
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
|
||||||
internal PierceZoneMemo PierceZones => pierceZones;
|
|
||||||
/// <summary>
|
|
||||||
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
|
|
||||||
/// is a display perimeter, so every contour below it is a hole.
|
|
||||||
/// </summary>
|
|
||||||
public int PerimeterOrdinal => hasCutPerimeter ? Count - 1 : Count;
|
|
||||||
|
|
||||||
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
|
|
||||||
public bool HasCutPerimeter => hasCutPerimeter;
|
|
||||||
|
|
||||||
/// <summary>Resolved pins this preparation could not honor: a tab on an internal contour.</summary>
|
|
||||||
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
||||||
|
|
||||||
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
||||||
public bool IsPinned(int contourOrdinal) => !(contourOrdinal == PerimeterOrdinal && !hasCutPerimeter)
|
public bool IsPinned(int contourOrdinal) => GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
|
||||||
&& GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
|
|
||||||
|
|
||||||
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
|
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
|
||||||
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
|
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
|
||||||
@@ -342,22 +115,24 @@ public sealed class PreparedContours
|
|||||||
/// contour keeps the automatic selection with <paramref name="parameters"/>.
|
/// contour keeps the automatic selection with <paramref name="parameters"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static PreparedContours Capture(Program program, CuttingParameters parameters,
|
public static PreparedContours Capture(Program program, CuttingParameters parameters,
|
||||||
IReadOnlyList<CutPinResolution> pins, CancellationToken token = default, CommonLinePerimeter commonLine = null)
|
IReadOnlyList<CutPinResolution> pins, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(pins);
|
ArgumentNullException.ThrowIfNull(pins);
|
||||||
var unpinned = Capture(program, parameters, token, commonLine);
|
var unpinned = Capture(program, parameters, token);
|
||||||
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
|
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The pin's leads replace both lead sets, since the type the emitter detects is
|
// The pin's leads replace every contour type's leads, since the type the emitter detects is
|
||||||
// not the caller's concern; angle rounding is off because a pinned entry never moves.
|
// not the caller's concern; angle rounding is off because a pinned entry never moves.
|
||||||
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
|
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
|
||||||
{
|
{
|
||||||
var owned = OwnedCuttingParameters.Copy(shared);
|
var owned = OwnedCuttingParameters.Copy(shared);
|
||||||
owned.ExternalLeadIn = pin.LeadIn;
|
owned.ExternalLeadIn = pin.LeadIn;
|
||||||
owned.InternalLeadIn = pin.LeadIn;
|
owned.InternalLeadIn = pin.LeadIn;
|
||||||
|
owned.ArcCircleLeadIn = pin.LeadIn;
|
||||||
owned.ExternalLeadOut = pin.LeadOut;
|
owned.ExternalLeadOut = pin.LeadOut;
|
||||||
owned.InternalLeadOut = pin.LeadOut;
|
owned.InternalLeadOut = pin.LeadOut;
|
||||||
|
owned.ArcCircleLeadOut = pin.LeadOut;
|
||||||
owned.RoundLeadInAngles = false;
|
owned.RoundLeadInAngles = false;
|
||||||
owned.TabsEnabled = pin.HasTab;
|
owned.TabsEnabled = pin.HasTab;
|
||||||
if (pin.HasTab)
|
if (pin.HasTab)
|
||||||
@@ -367,12 +142,7 @@ public sealed class PreparedContours
|
|||||||
|
|
||||||
private CuttingParameters ParametersFor(int contourOrdinal) => contourParameters?[contourOrdinal] ?? parameters;
|
private CuttingParameters ParametersFor(int contourOrdinal) => contourParameters?[contourOrdinal] ?? parameters;
|
||||||
|
|
||||||
/// <param name="commonLine">For a common-line member: how its outside is cut once. With an
|
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default)
|
||||||
/// entry, the program's perimeter is closed and starts there. Without one, it must carry the
|
|
||||||
/// spans earlier members cut on the Tab layer (CommonLineCutting.MarkSharedSpans); it is then
|
|
||||||
/// the perimeter, cut in pieces between them, rather than a tabbed cutout.</param>
|
|
||||||
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default,
|
|
||||||
CommonLinePerimeter commonLine = null)
|
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var ownedParameters = OwnedCuttingParameters.Copy(parameters);
|
var ownedParameters = OwnedCuttingParameters.Copy(parameters);
|
||||||
@@ -385,7 +155,6 @@ public sealed class PreparedContours
|
|||||||
var scribes = new List<Entity>();
|
var scribes = new List<Entity>();
|
||||||
var breakCuts = new List<Shape>();
|
var breakCuts = new List<Shape>();
|
||||||
var tabbedCutouts = new List<Shape>();
|
var tabbedCutouts = new List<Shape>();
|
||||||
Shape displayPerimeter = null;
|
|
||||||
Shape breakCut = null;
|
Shape breakCut = null;
|
||||||
var current = new Shape();
|
var current = new Shape();
|
||||||
foreach (var entity in geometryProgram.ToGeometry())
|
foreach (var entity in geometryProgram.ToGeometry())
|
||||||
@@ -416,8 +185,7 @@ public sealed class PreparedContours
|
|||||||
scribes.Add(entity);
|
scribes.Add(entity);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab
|
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab)
|
||||||
&& entity.Layer != SpecialLayers.DisplayPerimeter)
|
|
||||||
throw new ArgumentException("Preparation requires clean cut/display contours.");
|
throw new ArgumentException("Preparation requires clean cut/display contours.");
|
||||||
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
||||||
throw new ArgumentException("Degenerate contour entity.");
|
throw new ArgumentException("Degenerate contour entity.");
|
||||||
@@ -435,82 +203,6 @@ public sealed class PreparedContours
|
|||||||
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
|
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
|
||||||
FinishContour();
|
FinishContour();
|
||||||
}
|
}
|
||||||
if (commonLine != null)
|
|
||||||
{
|
|
||||||
if (displayPerimeter != null || current.Entities.Count != 0)
|
|
||||||
throw new ArgumentException("A common-line member needs one closed cut perimeter.");
|
|
||||||
if (!double.IsFinite(commonLine.Kerf) || commonLine.Kerf <= 0
|
|
||||||
|| (commonLine.Entry == null) != (commonLine.Pierce == null))
|
|
||||||
throw new ArgumentException("A common-line member needs a positive kerf, and an entry with its pierce.");
|
|
||||||
var outside = tabbedCutouts.Concat(contours).MaxBy(shape => shape.BoundingBox.Width * shape.BoundingBox.Length);
|
|
||||||
if (commonLine is { Entry: { } entry, Pierce: { } pierce })
|
|
||||||
{
|
|
||||||
// Cut from its entry vertex on a common line; every other contour lies inside it.
|
|
||||||
PostVerificationGeometry.Validate(entry);
|
|
||||||
PostVerificationGeometry.Validate(pierce);
|
|
||||||
if (outside == null
|
|
||||||
|| !outside.Entities.Exists(e => e.Layer != SpecialLayers.Tab
|
|
||||||
&& Start(e).DistanceTo(entry) <= PostVerificationGeometry.Epsilon)
|
|
||||||
|| pierce.DistanceTo(entry) <= PostVerificationGeometry.Epsilon)
|
|
||||||
throw new ArgumentException("A common-line member's perimeter does not start at its common line.");
|
|
||||||
if (contours.Contains(outside))
|
|
||||||
{
|
|
||||||
contours.Remove(outside);
|
|
||||||
contours.Add(outside);
|
|
||||||
foreach (var cut in breakCuts)
|
|
||||||
cut.UpdateBounds();
|
|
||||||
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, null, null,
|
|
||||||
commonLine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The outside carries the shared spans earlier members cut as tabs; every other contour lies inside it.
|
|
||||||
else if (outside == null || !tabbedCutouts.Contains(outside)
|
|
||||||
|| ContourCuttingStrategy.TabbedCutoutPieces(outside).Count == 0)
|
|
||||||
throw new ArgumentException("A common-line member's perimeter does not carry its shared spans.");
|
|
||||||
// Each remainder piece is pierced in the kerf of the span before it, the external
|
|
||||||
// lead-in's reach back along it: the span must be that long. Never shortened.
|
|
||||||
var reach = ContourCuttingStrategy.CommonLineLeadInReach(ownedParameters.ExternalLeadIn ?? new NoLeadIn());
|
|
||||||
if (reach <= OpenNest.Math.Tolerance.Epsilon)
|
|
||||||
throw new NotSupportedException("A common-line member's remainder needs a lead-in with length: "
|
|
||||||
+ "its compensation starts in the shared kerf along the lead-in. Choose an external lead-in "
|
|
||||||
+ "with length, or cut the common lines twice.");
|
|
||||||
foreach (var piece in ContourCuttingStrategy.TabbedCutoutPieces(outside))
|
|
||||||
{
|
|
||||||
var index = outside.Entities.IndexOf(piece.Entities[0]);
|
|
||||||
var span = outside.Entities[(index + outside.Entities.Count - 1) % outside.Entities.Count];
|
|
||||||
if (span is not Line line || line.Length < reach + PostVerificationGeometry.Epsilon)
|
|
||||||
throw new NotSupportedException(FormattableString.Invariant(
|
|
||||||
$"One of its shared spans is {span.Length:0.####} long, shorter than the {reach:0.####} external lead-in its remainder starts along in that span's kerf. Shorten the lead-in, or cut the common lines twice."));
|
|
||||||
}
|
|
||||||
tabbedCutouts.Remove(outside);
|
|
||||||
contours.Add(outside);
|
|
||||||
foreach (var cut in breakCuts)
|
|
||||||
cut.UpdateBounds();
|
|
||||||
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, null, null,
|
|
||||||
commonLine);
|
|
||||||
}
|
|
||||||
if (displayPerimeter != null)
|
|
||||||
{
|
|
||||||
if (current.Entities.Count != 0)
|
|
||||||
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
|
||||||
if (contours.Count == 0 && tabbedCutouts.Count == 0 && breakCuts.Count == 0)
|
|
||||||
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
|
||||||
// Every cut must lie on the blank: a non-rectangular blank's box is not enough.
|
|
||||||
// Each cut is sampled along its arcs and must not cross or touch the outline anywhere.
|
|
||||||
var outline = displayPerimeter.ToPolygon();
|
|
||||||
foreach (var shape in contours.Concat(tabbedCutouts).Concat(breakCuts))
|
|
||||||
if (shape.ToPolygon().Vertices.Any(vertex => !outline.ContainsPoint(vertex))
|
|
||||||
|| displayPerimeter.Intersects(shape))
|
|
||||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
|
||||||
// The search plans a part through its closed contours; tabbed cutouts and break cuts
|
|
||||||
// alone leave it nothing to choose.
|
|
||||||
if (contours.Count == 0)
|
|
||||||
throw new ArgumentException("A display perimeter needs at least one closed cutout without tabs.");
|
|
||||||
foreach (var cut in breakCuts)
|
|
||||||
cut.UpdateBounds();
|
|
||||||
// Never a contour: nothing can choose, emit or lead it. Every contour is a hole.
|
|
||||||
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, displayPerimeter);
|
|
||||||
}
|
|
||||||
if (current.Entities.Count != 0 || contours.Count == 0)
|
if (current.Entities.Count != 0 || contours.Count == 0)
|
||||||
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
||||||
var perimeter = 0;
|
var perimeter = 0;
|
||||||
@@ -531,15 +223,7 @@ public sealed class PreparedContours
|
|||||||
void FinishContour()
|
void FinishContour()
|
||||||
{
|
{
|
||||||
current.UpdateBounds();
|
current.UpdateBounds();
|
||||||
if (current.Entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
if (ContourCuttingStrategy.IsTabbedCutout(current))
|
||||||
{
|
|
||||||
if (!current.Entities.TrueForAll(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
|
||||||
throw new ArgumentException("A display perimeter must be one whole closed contour.");
|
|
||||||
if (displayPerimeter != null)
|
|
||||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
|
||||||
displayPerimeter = current;
|
|
||||||
}
|
|
||||||
else if (ContourCuttingStrategy.IsTabbedCutout(current))
|
|
||||||
{
|
{
|
||||||
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
|
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
|
||||||
throw new ArgumentException("A tabbed cutout has nothing to cut.");
|
throw new ArgumentException("A tabbed cutout has nothing to cut.");
|
||||||
@@ -558,8 +242,6 @@ public sealed class PreparedContours
|
|||||||
var shape = GetShape(contourOrdinal);
|
var shape = GetShape(contourOrdinal);
|
||||||
if (pinned[contourOrdinal] is { } pin)
|
if (pinned[contourOrdinal] is { } pin)
|
||||||
return pin;
|
return pin;
|
||||||
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
|
|
||||||
return fixedEntry;
|
|
||||||
var point = shape.ClosestPointTo(approach, out var entity);
|
var point = shape.ClosestPointTo(approach, out var entity);
|
||||||
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
|
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
|
||||||
}
|
}
|
||||||
@@ -578,8 +260,6 @@ public sealed class PreparedContours
|
|||||||
var shape = GetShape(contourOrdinal);
|
var shape = GetShape(contourOrdinal);
|
||||||
if (pinned[contourOrdinal] is { } pin)
|
if (pinned[contourOrdinal] is { } pin)
|
||||||
return new[] { pin };
|
return new[] { pin };
|
||||||
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
|
|
||||||
return new[] { fixedEntry };
|
|
||||||
var entries = new List<ContourChoice>();
|
var entries = new List<ContourChoice>();
|
||||||
entries.Add(ClosestEntry(contourOrdinal, approach));
|
entries.Add(ClosestEntry(contourOrdinal, approach));
|
||||||
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
|
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
|
||||||
@@ -589,8 +269,8 @@ public sealed class PreparedContours
|
|||||||
if (entity is Circle circle)
|
if (entity is Circle circle)
|
||||||
{
|
{
|
||||||
for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++)
|
for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++)
|
||||||
Add(i, circle.Center + new Vector(PortableMath.Cos(angle * System.Math.PI / 4),
|
Add(i, circle.Center + new Vector(System.Math.Cos(angle * System.Math.PI / 4),
|
||||||
PortableMath.Sin(angle * System.Math.PI / 4)) * circle.Radius);
|
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -605,12 +285,8 @@ public sealed class PreparedContours
|
|||||||
void Add(int entityOrdinal, Vector point)
|
void Add(int entityOrdinal, Vector point)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (entries.Count >= maxEntries)
|
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
|
||||||
return;
|
entries.Add(Entry(contourOrdinal, entityOrdinal, point));
|
||||||
// Compare the entry as emitted: snapping can bring two raw points together.
|
|
||||||
var entry = Entry(contourOrdinal, entityOrdinal, point);
|
|
||||||
if (!entries.Any(e => e.Point.DistanceTo(entry.Point) <= PostVerificationGeometry.Epsilon))
|
|
||||||
entries.Add(entry);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +311,6 @@ public sealed class PreparedContours
|
|||||||
|
|
||||||
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
|
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
|
||||||
{
|
{
|
||||||
point = SnapToVertex(GetShape(contourOrdinal), entityOrdinal, point);
|
|
||||||
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
|
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
|
||||||
ValidateChoice(choice);
|
ValidateChoice(choice);
|
||||||
return choice;
|
return choice;
|
||||||
@@ -648,63 +323,12 @@ public sealed class PreparedContours
|
|||||||
/// emission reads only this choice and the perimeter, and the lead validator checks the
|
/// emission reads only this choice and the perimeter, and the lead validator checks the
|
||||||
/// lead as usual. A pinned perimeter keeps its pin's entry.
|
/// lead as usual. A pinned perimeter keeps its pin's entry.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal ContourChoice ScrapPierceEntry(Vector pierce) => PiercedEntry(PerimeterOrdinal, pierce);
|
internal ContourChoice ScrapPierceEntry(Vector pierce)
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entries of cutout <paramref name="contourOrdinal"/> pierced where eroding it leaves room
|
|
||||||
/// (see <see cref="ErodedPierce"/>): the planner's fallback when no configured lead-in fits the
|
|
||||||
/// cutout. For each of the first <paramref name="max"/> <paramref name="targets"/> (its own
|
|
||||||
/// entries, usually ranked) the pierce is the eroded point nearest it, and the lead-in runs
|
|
||||||
/// straight from there to the target itself, then, as an alternative, to the cutout's native
|
|
||||||
/// point closest to the pierce. Neither has a lead-out. Nothing for the perimeter or a pinned
|
|
||||||
/// cutout; the lead validator still checks each one.
|
|
||||||
/// </summary>
|
|
||||||
internal IReadOnlyList<ContourChoice> ErodedPierceEntries(int contourOrdinal, IReadOnlyList<ContourChoice> targets,
|
|
||||||
int max, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
var shape = GetShape(contourOrdinal);
|
|
||||||
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
|
|
||||||
return Array.Empty<ContourChoice>();
|
|
||||||
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
|
|
||||||
var depth = System.Math.Max(ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
|
|
||||||
ApplicableLeadInLength(contourOrdinal));
|
|
||||||
var found = ErodedPierce.Find(shape, depth,
|
|
||||||
targets.Select(t => t.Point).ToArray(), max);
|
|
||||||
var entries = new List<ContourChoice>();
|
|
||||||
for (var i = 0; i < found.Pierces.Count; i++)
|
|
||||||
{
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
var pierce = found.Pierces[i];
|
|
||||||
Add(() => targets[i] with { Pierce = pierce });
|
|
||||||
Add(() => PiercedEntry(contourOrdinal, pierce));
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
|
|
||||||
void Add(Func<ContourChoice> entry)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var choice = entry();
|
|
||||||
ValidateChoice(choice);
|
|
||||||
if (!entries.Any(e => e.Point == choice.Point && e.Pierce == choice.Pierce))
|
|
||||||
entries.Add(choice);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
// A pierce on the wall itself has no lead; the others still stand.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A contour entry at the closest native point to a pierce off it, along a straight lead-in.
|
|
||||||
private ContourChoice PiercedEntry(int contourOrdinal, Vector pierce)
|
|
||||||
{
|
{
|
||||||
PostVerificationGeometry.Validate(pierce);
|
PostVerificationGeometry.Validate(pierce);
|
||||||
var shape = GetShape(contourOrdinal);
|
var shape = GetShape(PerimeterOrdinal);
|
||||||
var point = shape.ClosestPointTo(pierce, out var entity);
|
var point = shape.ClosestPointTo(pierce, out var entity);
|
||||||
point = SnapToVertex(shape, shape.Entities.IndexOf(entity), point);
|
var choice = new ContourChoice(PerimeterOrdinal, shape.Entities.IndexOf(entity), point)
|
||||||
var choice = new ContourChoice(contourOrdinal, shape.Entities.IndexOf(entity), point)
|
|
||||||
{
|
{
|
||||||
Owner = this,
|
Owner = this,
|
||||||
Pierce = pierce,
|
Pierce = pierce,
|
||||||
@@ -714,7 +338,7 @@ public sealed class PreparedContours
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The uncapped preferred automatic start catalog for one contour, in preference then
|
/// The uncapped preferred automatic start catalogue 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
|
||||||
@@ -729,15 +353,13 @@ public sealed class PreparedContours
|
|||||||
var shape = GetShape(contourOrdinal);
|
var shape = GetShape(contourOrdinal);
|
||||||
if (pinned[contourOrdinal] is { } pin)
|
if (pinned[contourOrdinal] is { } pin)
|
||||||
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
||||||
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
|
|
||||||
return new[] { new ContourEntryCandidate(fixedEntry, AutomaticEntryKind.Pinned) };
|
|
||||||
if (IsSingleCircle(shape))
|
if (IsSingleCircle(shape))
|
||||||
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalog.");
|
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
|
||||||
return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
|
return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The complete uncapped automatic start catalog: the preferred points of
|
/// The complete uncapped automatic start catalogue: 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
|
||||||
@@ -745,63 +367,22 @@ 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 catalog is its pinned entry alone: nothing may relocate it.
|
/// pinned contour's catalogue is its pinned entry alone: nothing may relocate it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
||||||
int contourOrdinal,
|
int contourOrdinal,
|
||||||
Vector? lookAhead = null,
|
Vector? lookAhead = null,
|
||||||
CancellationToken token = default,
|
CancellationToken token = default)
|
||||||
SupportEntries supportEntries = SupportEntries.Steadying)
|
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var shape = GetShape(contourOrdinal);
|
var shape = GetShape(contourOrdinal);
|
||||||
if (pinned[contourOrdinal] is { } pin)
|
if (pinned[contourOrdinal] is { } pin)
|
||||||
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
||||||
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
|
|
||||||
return new[] { new ContourEntryCandidate(fixedEntry, AutomaticEntryKind.Pinned) };
|
|
||||||
var all = IsSingleCircle(shape)
|
var all = IsSingleCircle(shape)
|
||||||
? new List<ContourEntryCandidate>()
|
? new List<ContourEntryCandidate>()
|
||||||
: PreferredCandidates(shape, contourOrdinal, token);
|
: PreferredCandidates(shape, contourOrdinal, token);
|
||||||
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
|
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
|
||||||
return SupportFiltered(contourOrdinal, all, supportEntries);
|
return MergeByGeometry(all);
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
|
|
||||||
/// its automatic catalog then keeps to steadying points unless asked for a fallback.
|
|
||||||
/// </summary>
|
|
||||||
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The support margin with this perimeter's tab at <paramref name="choice"/> (see
|
|
||||||
/// <see cref="PartSupport.MarginWith"/>); negative infinity when it is not tabbed for support.
|
|
||||||
/// </summary>
|
|
||||||
internal double SupportMarginAt(ContourChoice choice) =>
|
|
||||||
support?.MarginWith(choice.Point + supportOrigin) ?? double.NegativeInfinity;
|
|
||||||
|
|
||||||
/// <summary>Whether a support tab at <paramref name="choice"/> holds the part level (see <see cref="PartSupport.HoldsLevelWithTabAt"/>).</summary>
|
|
||||||
internal bool HoldsLevelAt(ContourChoice choice) =>
|
|
||||||
support != null && support.HoldsLevelWithTabAt(choice.Point + supportOrigin);
|
|
||||||
|
|
||||||
/// <summary>Whether a support tab at <paramref name="choice"/> keeps the inside-corner clearance.</summary>
|
|
||||||
internal bool IsClearOfInsideCorners(ContourChoice choice) =>
|
|
||||||
support == null || support.IsClearOfInsideCorners(choice.Point + supportOrigin);
|
|
||||||
|
|
||||||
// A perimeter tabbed for support keeps the candidates whose tab steadies the part, and gains
|
|
||||||
// the outline's best tab point. The search's fallbacks, when none of them has a lead-in that
|
|
||||||
// fits, keep every candidate clear of inside corners and then every candidate, still tabbed.
|
|
||||||
private IReadOnlyList<ContourEntryCandidate> SupportFiltered(int contourOrdinal,
|
|
||||||
List<ContourEntryCandidate> candidates, SupportEntries entries)
|
|
||||||
{
|
|
||||||
if (support == null || contourOrdinal != PerimeterOrdinal || entries == SupportEntries.Any)
|
|
||||||
return MergeByGeometry(candidates);
|
|
||||||
if (entries == SupportEntries.ClearOfInsideCorners)
|
|
||||||
return MergeByGeometry(candidates.Where(c => IsClearOfInsideCorners(c.Choice)).ToList());
|
|
||||||
var kept = candidates.Where(c => support.AcceptsTabAt(c.Choice.Point + supportOrigin)).ToList();
|
|
||||||
var best = ClosestEntry(PerimeterOrdinal, support.BestTabPoint!.Value - supportOrigin);
|
|
||||||
if (!IsForbiddenVertex(GetShape(PerimeterOrdinal), best.Point))
|
|
||||||
kept.Add(new ContourEntryCandidate(best, AutomaticEntryKind.SupportTab));
|
|
||||||
return MergeByGeometry(kept);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsSingleCircle(Shape shape) =>
|
private static bool IsSingleCircle(Shape shape) =>
|
||||||
@@ -809,8 +390,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)
|
||||||
=> CatalogPoints(shape, token)
|
=> CataloguePoints(shape, token)
|
||||||
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind, p.CornerReach))
|
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -842,8 +423,8 @@ public sealed class PreparedContours
|
|||||||
// The legacy eight compass points, same native construction.
|
// The legacy eight compass points, same native construction.
|
||||||
for (var angle = 0; angle < 8; angle++)
|
for (var angle = 0; angle < 8; angle++)
|
||||||
fallbacks.Add(Fallback(i, circle.Center + new Vector(
|
fallbacks.Add(Fallback(i, circle.Center + new Vector(
|
||||||
PortableMath.Cos(angle * System.Math.PI / 4),
|
System.Math.Cos(angle * System.Math.PI / 4),
|
||||||
PortableMath.Sin(angle * System.Math.PI / 4)) * circle.Radius,
|
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius,
|
||||||
AutomaticEntryKind.CircleCompass));
|
AutomaticEntryKind.CircleCompass));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -855,7 +436,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 CatalogPoints(shape, token)
|
foreach (var corner in CataloguePoints(shape, token)
|
||||||
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -894,10 +475,9 @@ public sealed class PreparedContours
|
|||||||
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
|
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
|
||||||
// Strictly inside the edge by projection parameter (distance alone loses the
|
// Strictly inside the edge by projection parameter (distance alone loses the
|
||||||
// sign when a short edge is overshoot): never the corner, never the far
|
// sign when a short edge is overshoot): never the corner, never the far
|
||||||
// endpoint (where a reflex vertex might sit). Short edges omit the point. The
|
// endpoint (where a reflex vertex might sit). Short edges omit the point.
|
||||||
// margin is the vertex snap distance, so Entry cannot snap it onto either end.
|
|
||||||
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
|
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
|
||||||
if (t <= OpenNest.Math.Tolerance.Epsilon || t >= line.Length - OpenNest.Math.Tolerance.Epsilon)
|
if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
|
||||||
return;
|
return;
|
||||||
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
|
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
|
||||||
}
|
}
|
||||||
@@ -969,16 +549,14 @@ public sealed class PreparedContours
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The preferred catalog points for one contour: for each entity, its convex corner
|
/// The preferred catalogue points for one contour: for each entity, its convex corner
|
||||||
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
|
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
|
||||||
/// plus tangent line/arc joints. The joints of a corner fillet (see <see cref="FilletReach"/>)
|
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
|
||||||
/// 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, double CornerReach)> CatalogPoints(
|
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
|
||||||
Shape shape, CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var found = new List<(int, Vector, AutomaticEntryKind, double)>();
|
var found = new List<(int, Vector, AutomaticEntryKind)>();
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -993,7 +571,7 @@ public sealed class PreparedContours
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var entity = shape.Entities[i];
|
var entity = shape.Entities[i];
|
||||||
|
|
||||||
// Vertex reached by traveling along entity i (its end point), reported under
|
// Vertex reached by travelling 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);
|
||||||
@@ -1002,72 +580,36 @@ public sealed class PreparedContours
|
|||||||
switch (corner.Kind)
|
switch (corner.Kind)
|
||||||
{
|
{
|
||||||
case ContourCuttingStrategy.CornerKind.Convex:
|
case ContourCuttingStrategy.CornerKind.Convex:
|
||||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
|
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
|
||||||
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(Joint(i, vertex, (i + 1) % count));
|
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||||
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(Joint(i, vertex, i));
|
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entity is Line line)
|
if (entity is Line line)
|
||||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
|
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
|
|
||||||
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
||||||
|
|
||||||
(int, Vector, AutomaticEntryKind, double) Joint(int entityOrdinal, Vector vertex, int arcOrdinal) =>
|
|
||||||
FilletReach(shape, arcOrdinal) is { } reach
|
|
||||||
? (entityOrdinal, vertex, AutomaticEntryKind.ConvexCorner, reach)
|
|
||||||
: (entityOrdinal, vertex, AutomaticEntryKind.TangentJoint, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How far the joints of the arc at <paramref name="arcOrdinal"/> lie from the sharp corner it
|
|
||||||
/// rounds, when it is a corner fillet: tangent to straight edges at both ends, turning less
|
|
||||||
/// than a half circle the convex way, and reaching back along each edge no further than the
|
|
||||||
/// edge is long. A fillet's joints start the cut at the corner, where the mismatch when the
|
|
||||||
/// cut closes is least seen, instead of on a straight edge. Null for any other arc.
|
|
||||||
/// </summary>
|
|
||||||
private static double? FilletReach(Shape shape, int arcOrdinal)
|
|
||||||
{
|
|
||||||
var count = shape.Entities.Count;
|
|
||||||
if (shape.Entities[arcOrdinal] is not Arc arc
|
|
||||||
|| shape.Entities[(arcOrdinal + count - 1) % count] is not Line before
|
|
||||||
|| shape.Entities[(arcOrdinal + 1) % count] is not Line after)
|
|
||||||
return null;
|
|
||||||
var sweep = arc.SweepAngle();
|
|
||||||
if (sweep >= System.Math.PI - OpenNest.Math.Tolerance.Epsilon)
|
|
||||||
return null;
|
|
||||||
if (!IsSmooth(before.EndPoint, before) || !IsSmooth(arc.EndPoint(), arc))
|
|
||||||
return null;
|
|
||||||
var winding = ContourCuttingStrategy.DetermineWinding(shape);
|
|
||||||
if (ContourCuttingStrategy.ClassifyTurn((before.EndPoint - before.StartPoint).Normalize(),
|
|
||||||
(after.EndPoint - after.StartPoint).Normalize(), winding) != ContourCuttingStrategy.CornerKind.Convex)
|
|
||||||
return null;
|
|
||||||
var reach = arc.Radius * PortableMath.Tan(sweep / 2);
|
|
||||||
return reach <= System.Math.Min(before.Length, after.Length) ? reach : null;
|
|
||||||
|
|
||||||
bool IsSmooth(Vector vertex, Entity entity) =>
|
|
||||||
ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)
|
|
||||||
&& corner.Kind == ContourCuttingStrategy.CornerKind.Smooth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
||||||
public Program Emit(IReadOnlyList<ContourChoice> choices)
|
public Program Emit(IReadOnlyList<ContourChoice> choices)
|
||||||
{
|
{
|
||||||
Validate(choices, true);
|
Validate(choices, true);
|
||||||
return EmitOwned(choices, complete: true);
|
return EmitPrefix(choices);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1084,28 +626,21 @@ public sealed class PreparedContours
|
|||||||
throw new ArgumentException("Foreign contour choice.");
|
throw new ArgumentException("Foreign contour choice.");
|
||||||
ValidateChoice(choice);
|
ValidateChoice(choice);
|
||||||
// Same owned clones as a real emission; the source shapes/settings are never used
|
// Same owned clones as a real emission; the source shapes/settings are never used
|
||||||
// directly, so the probe cannot drift the preparation or mutate it. Isolated emission
|
// directly, so the probe cannot drift the preparation or mutate it.
|
||||||
// reads only the chosen contour and the contour count, so only that contour is cloned.
|
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
|
||||||
var owned = new Shape[shapes.Length];
|
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
|
||||||
owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
|
contourParameters);
|
||||||
return new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitCandidateIsolated(
|
|
||||||
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal, commonLine);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
|
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
|
||||||
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
|
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
|
||||||
{
|
{
|
||||||
Validate(choices, false);
|
Validate(choices, false);
|
||||||
return EmitOwned(choices, complete: false);
|
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
|
||||||
}
|
|
||||||
|
|
||||||
// Only a complete emission carries a display perimeter's tail: prefixes are search probes.
|
|
||||||
private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) =>
|
|
||||||
new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitPrepared(
|
|
||||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
|
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
|
||||||
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
|
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
|
||||||
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal,
|
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList());
|
||||||
complete ? (Shape)displayOutline?.Clone() : null, commonLine, slugSlices);
|
}
|
||||||
|
|
||||||
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
|
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
|
||||||
public int BreakCutCount => breakCuts.Count;
|
public int BreakCutCount => breakCuts.Count;
|
||||||
@@ -1125,10 +660,7 @@ public sealed class PreparedContours
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
|
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
|
||||||
// Only the outside is tabbed, with its own pin's tab when it is pinned. A common-line
|
// Only the outside is tabbed, with its own pin's tab when it is pinned.
|
||||||
// member's shared spans are accounted as the material's tabs, never as a tab gap.
|
|
||||||
if (!hasCutPerimeter || commonLine != null)
|
|
||||||
return new(this, choices, expected, 0);
|
|
||||||
var perimeter = ParametersFor(PerimeterOrdinal);
|
var perimeter = ParametersFor(PerimeterOrdinal);
|
||||||
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
|
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
|
||||||
}
|
}
|
||||||
@@ -1160,45 +692,36 @@ public sealed class PreparedContours
|
|||||||
// A pinned contour is cut exactly as pinned: no other entry may be emitted for it.
|
// A pinned contour is cut exactly as pinned: no other entry may be emitted for it.
|
||||||
if (pinned[choice.ContourOrdinal] is { } pin && pin != choice)
|
if (pinned[choice.ContourOrdinal] is { } pin && pin != choice)
|
||||||
throw new ArgumentException("A pinned contour keeps its drawing pin's entry.");
|
throw new ArgumentException("A pinned contour keeps its drawing pin's entry.");
|
||||||
// A common-line perimeter is cut from its one fixed entry.
|
|
||||||
if (commonLineEntry != null && choice.ContourOrdinal == PerimeterOrdinal
|
|
||||||
&& (choice.EntityOrdinal != commonLineEntry.EntityOrdinal || choice.Point != commonLineEntry.Point
|
|
||||||
|| choice.Pierce != commonLineEntry.Pierce))
|
|
||||||
throw new ArgumentException("A common-line member's perimeter is cut from its fixed entry.");
|
|
||||||
if (choice.Pierce is { } pierce)
|
if (choice.Pierce is { } pierce)
|
||||||
{
|
{
|
||||||
PostVerificationGeometry.Validate(pierce);
|
PostVerificationGeometry.Validate(pierce);
|
||||||
|
if (choice.ContourOrdinal != PerimeterOrdinal)
|
||||||
|
throw new ArgumentException("Only a perimeter entry can be pierced in scrap.");
|
||||||
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
|
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
|
||||||
throw new ArgumentException("A planner-placed pierce must lie off its contour.");
|
throw new ArgumentException("A scrap pierce must lie off its contour.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ContourChoice FixedCommonLineEntry(int contourOrdinal) =>
|
|
||||||
commonLineEntry != null && contourOrdinal == PerimeterOrdinal ? commonLineEntry : null;
|
|
||||||
|
|
||||||
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
|
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
|
||||||
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
|
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Representative POINTS per contour ordinal (bounding-box center) for hole routing
|
/// Representative POINTS per contour ordinal (bounding-box centre) 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?> HoleCenters(CancellationToken token = default)
|
internal IReadOnlyList<Vector?> HoleCentres(CancellationToken token = default)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var centers = new Vector?[Count];
|
var centres = new Vector?[Count];
|
||||||
for (var contour = 0; contour < PerimeterOrdinal; contour++)
|
for (var contour = 0; contour < PerimeterOrdinal; contour++)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
centers[contour] = GetShape(contour).BoundingBox.Center;
|
centres[contour] = GetShape(contour).BoundingBox.Center;
|
||||||
}
|
}
|
||||||
return centers;
|
return centres;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The bounding box of contour <paramref name="contourOrdinal"/>, local. A routing hint only.</summary>
|
|
||||||
internal Box ContourBounds(int contourOrdinal) => GetShape(contourOrdinal).BoundingBox;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
|
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
|
||||||
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
|
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
|
||||||
@@ -1210,33 +733,6 @@ public sealed class PreparedContours
|
|||||||
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
|
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Emission splits a contour at its entry with <see cref="Arc.SplitAt"/>/<see cref="Line.SplitAt"/>,
|
|
||||||
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
|
|
||||||
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
|
|
||||||
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
|
|
||||||
/// stored center sits beside the chord's midpoint) is never cut.
|
|
||||||
/// </summary>
|
|
||||||
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
|
|
||||||
{
|
|
||||||
if (entityOrdinal < 0 || entityOrdinal >= shape.Entities.Count
|
|
||||||
|| shape.Entities[entityOrdinal] is not (Line or Arc))
|
|
||||||
return point;
|
|
||||||
var entity = shape.Entities[entityOrdinal];
|
|
||||||
// Within the verifier's epsilon the entry already cuts the contour whole: leave it, so
|
|
||||||
// programs emitted before this snap (saved pinned entries) re-emit unchanged.
|
|
||||||
bool Snaps(Vector vertex)
|
|
||||||
{
|
|
||||||
var distance = point.DistanceTo(vertex);
|
|
||||||
return distance > PostVerificationGeometry.Epsilon && distance < OpenNest.Math.Tolerance.Epsilon;
|
|
||||||
}
|
|
||||||
var start = Start(entity);
|
|
||||||
if (point.DistanceTo(start) < OpenNest.Math.Tolerance.Epsilon)
|
|
||||||
return Snaps(start) ? start : point;
|
|
||||||
var end = End(entity);
|
|
||||||
return Snaps(end) ? end : point;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
|
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
|
||||||
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
|
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
|
||||||
|
|
||||||
|
|||||||
@@ -12,50 +12,19 @@ public sealed class ReleasedContourState
|
|||||||
{
|
{
|
||||||
private readonly List<Obstacle> obstacles = new();
|
private readonly List<Obstacle> obstacles = new();
|
||||||
private readonly bool reportMissingLeadIns;
|
private readonly bool reportMissingLeadIns;
|
||||||
// Parts (by caller part number) whose single tab may not hold them level: once cut, their
|
|
||||||
// tabbed perimeter is an obstacle as if cut free, since the part may tip into the head's path.
|
|
||||||
private readonly Func<int, bool> tabMayNotHold;
|
|
||||||
// Common-line members (by caller part number): their outside is cut as open remainder pieces
|
|
||||||
// that end in kerf their shared cuts opened, so once the part's program ends it is cut free.
|
|
||||||
private readonly Func<int, bool> commonLineMember;
|
|
||||||
// How the last analysed part ended: the contours it numbered, and whether nothing it left
|
|
||||||
// (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
|
|
||||||
// motions appended to it are judged. See Continue.
|
|
||||||
private int partContours;
|
|
||||||
private bool partClosed;
|
|
||||||
|
|
||||||
public ReleasedContourState() : this(true) { }
|
public ReleasedContourState() : this(true) { }
|
||||||
|
|
||||||
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
|
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
|
||||||
/// may pierce directly on a contour and checks travel without requiring a lead.</param>
|
/// may pierce directly on a contour and checks travel without requiring a lead.</param>
|
||||||
public ReleasedContourState(bool reportMissingLeadIns) : this(reportMissingLeadIns, null) { }
|
public ReleasedContourState(bool reportMissingLeadIns)
|
||||||
|
|
||||||
/// <param name="tabMayNotHold">Part numbers whose support tab may not hold them level (no
|
|
||||||
/// single tab can steady them): the longest open cut a part leaves, its tabbed
|
|
||||||
/// perimeter, becomes an obstacle when the part ends, closed across its tab gap.</param>
|
|
||||||
public ReleasedContourState(bool reportMissingLeadIns, Func<int, bool> tabMayNotHold)
|
|
||||||
: this(reportMissingLeadIns, tabMayNotHold, null)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <param name="commonLineMember">Part numbers of common-line members (docs/common-line-cutting.md):
|
|
||||||
/// a member's open cuts are its remainder pieces, which end in kerf its shared cuts opened. They
|
|
||||||
/// need no retention proof, and when the part ends they become one obstacle, closed across its
|
|
||||||
/// shared spans, since the part is then cut free.</param>
|
|
||||||
public ReleasedContourState(bool reportMissingLeadIns, Func<int, bool> tabMayNotHold, Func<int, bool> commonLineMember)
|
|
||||||
{
|
{
|
||||||
this.reportMissingLeadIns = reportMissingLeadIns;
|
this.reportMissingLeadIns = reportMissingLeadIns;
|
||||||
this.tabMayNotHold = tabMayNotHold;
|
|
||||||
this.commonLineMember = commonLineMember;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReleasedContourState Copy()
|
public ReleasedContourState Copy()
|
||||||
{
|
{
|
||||||
var copy = new ReleasedContourState(reportMissingLeadIns, tabMayNotHold, commonLineMember)
|
var copy = new ReleasedContourState(reportMissingLeadIns);
|
||||||
{
|
|
||||||
partContours = partContours,
|
|
||||||
partClosed = partClosed,
|
|
||||||
};
|
|
||||||
copy.obstacles.AddRange(obstacles);
|
copy.obstacles.AddRange(obstacles);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
@@ -76,11 +45,9 @@ 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. Crossings made by a rapid with the
|
/// state advances exactly as it would without ownership.
|
||||||
/// 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,
|
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check(
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -91,39 +58,14 @@ 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>();
|
||||||
var raisedFindings = new List<PostVerificationFinding>();
|
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
|
||||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings,
|
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
|
||||||
raisedFindings: raisedFindings);
|
|
||||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly(), raisedFindings.AsReadOnly());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks <paramref name="execution"/> as the continuation of the part the last check ended
|
|
||||||
/// in, as if that part's whole execution had been checked with these motions appended: the
|
|
||||||
/// findings are those the appended motions would add, and the state advances the same way.
|
|
||||||
/// It needs that part to have ended with every cut closed and nothing deferred, and the
|
|
||||||
/// continuation to open with a rapid and end the same way; otherwise the result is null and
|
|
||||||
/// this state must be discarded, and the caller checks the whole execution instead. Fixed
|
|
||||||
/// emissions and cutoffs are not continued.
|
|
||||||
/// </summary>
|
|
||||||
internal IReadOnlyList<PostVerificationFinding> Continue(OwnedExecution execution, Vector arrival,
|
|
||||||
int partNumber, CancellationToken token)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(execution);
|
|
||||||
PostVerificationGeometry.Validate(arrival);
|
|
||||||
if (!partClosed || execution.Motions.Count == 0 || !execution.Motions[0].Rapid)
|
|
||||||
return null;
|
|
||||||
var moves = execution.Motions.ToArray();
|
|
||||||
moves[0] = moves[0].WithStart(arrival);
|
|
||||||
var findings = new List<PostVerificationFinding>();
|
|
||||||
AnalyzeMoves(moves, false, findings, 1, partNumber, token, contoursBefore: partContours);
|
|
||||||
return partClosed ? findings.AsReadOnly() : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
||||||
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
|
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
|
||||||
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
|
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
|
||||||
int contoursBefore = 0, List<PostVerificationFinding> raisedFindings = null)
|
bool proveOpenCuts = false)
|
||||||
{
|
{
|
||||||
var current = new CutGroup();
|
var current = new CutGroup();
|
||||||
var unfinished = new List<CutGroup>();
|
var unfinished = new List<CutGroup>();
|
||||||
@@ -136,44 +78,31 @@ public sealed class ReleasedContourState
|
|||||||
CutGroup previous = null;
|
CutGroup previous = null;
|
||||||
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
|
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
|
||||||
var hasLead = false;
|
var hasLead = false;
|
||||||
var contourNumber = contoursBefore;
|
var contourNumber = 0;
|
||||||
var inBreakCut = false;
|
var inBreakCut = false;
|
||||||
var member = commonLineMember?.Invoke(part) == true;
|
|
||||||
var reportedTab = false;
|
var reportedTab = false;
|
||||||
// The contour last closed, and the one a lead-out has just left it into: a cutout's
|
|
||||||
// lead-out ends in its slug, so the rapid after it must leave over that cutout's own cut.
|
|
||||||
CutGroup lastClosed = null;
|
|
||||||
Obstacle lastObstacle = null;
|
|
||||||
Obstacle exited = null;
|
|
||||||
var exitPoint = Vector.Zero;
|
|
||||||
for (var index = 0; index < moves.Count; index++)
|
for (var index = 0; index < moves.Count; index++)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var move = moves[index];
|
var move = moves[index];
|
||||||
var owned = isFixed?.Invoke(index) == true;
|
var owned = isFixed?.Invoke(index) == true;
|
||||||
if (!move.Rapid && move.Layer != LayerType.Leadout)
|
if (!move.Rapid && move.Layer == LayerType.Break)
|
||||||
exited = null;
|
|
||||||
if (!move.Rapid && move.Layer is LayerType.Break or LayerType.CommonLine)
|
|
||||||
{
|
{
|
||||||
// A break cut is intentionally open and retained by its uncut tabs: it is never a
|
// A break cut is intentionally open and retained by its uncut tabs: it is never a
|
||||||
// rapid obstacle and never an unexplained fragment, but it is still pierced. A
|
// rapid obstacle and never an unexplained fragment, but it is still pierced.
|
||||||
// common line's shared cut runs in the kerf between two parts: it bounds and
|
|
||||||
// retains nothing, so it is never an obstacle and takes no part in the open-cut proof.
|
|
||||||
if (!inBreakCut)
|
if (!inBreakCut)
|
||||||
{
|
{
|
||||||
Finish();
|
Finish();
|
||||||
if (reportMissingLeadIns && !cutoff && !hasLead)
|
if (reportMissingLeadIns && !cutoff && !hasLead)
|
||||||
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||||
move.Layer == LayerType.Break ? "A break cut is missing lead-in geometry."
|
"A break cut has no nonzero placed lead-in motion.")
|
||||||
: "A common-line shared cut is missing lead-in geometry.")
|
|
||||||
{ Location = move.Curve.Start }, owned);
|
{ Location = move.Curve.Start }, owned);
|
||||||
inBreakCut = true;
|
inBreakCut = true;
|
||||||
hasLead = false;
|
hasLead = false;
|
||||||
previous = new CutGroup();
|
previous = new CutGroup();
|
||||||
previous.Leads.AddRange(leadIns);
|
previous.Leads.AddRange(leadIns);
|
||||||
leadIns.Clear();
|
leadIns.Clear();
|
||||||
if (move.Layer == LayerType.Break)
|
otherOpen.Add(previous);
|
||||||
otherOpen.Add(previous);
|
|
||||||
}
|
}
|
||||||
if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||||
previous.Curves.Add(move.Curve);
|
previous.Curves.Add(move.Curve);
|
||||||
@@ -187,15 +116,8 @@ 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));
|
||||||
@@ -205,31 +127,16 @@ public sealed class ReleasedContourState
|
|||||||
// A rapid well clear of a contour's extent can neither cross nor touch it.
|
// A rapid well clear of a contour's extent can neither cross nor touch it.
|
||||||
if (reach.IsClearOf(obstacle.Extent))
|
if (reach.IsClearOf(obstacle.Extent))
|
||||||
continue;
|
continue;
|
||||||
// A rapid leaving an overcut departs from its retraced tail, which lies on the first
|
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
|
||||||
// cut only within the seam tolerance its arcs were matched at. Contact within that
|
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||||
// much of the departure is the departure itself.
|
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
||||||
var from = start;
|
$"of part {obstacle.Part}.")
|
||||||
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
|
{
|
||||||
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
|
Location = (start + move.End) * 0.5,
|
||||||
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
|
RapidStart = start,
|
||||||
if (!PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
|
RapidEnd = move.End,
|
||||||
continue;
|
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token)
|
||||||
var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
|
}, owned);
|
||||||
obstacle.Part, (move.HeadRaised ? "Head-raised" : "Direct") + " XY rapid crosses or touches "
|
|
||||||
+ (obstacle.TabMayNotHold
|
|
||||||
? $"the tabbed outside of part {obstacle.Part}, which its tab may not hold level."
|
|
||||||
: $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}."))
|
|
||||||
{
|
|
||||||
Location = (start + move.End) * 0.5,
|
|
||||||
RapidStart = start,
|
|
||||||
RapidEnd = move.End,
|
|
||||||
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
|
||||||
};
|
|
||||||
// The head clears a cut part on a raised rapid; the crossing is the raise's reason.
|
|
||||||
if (move.HeadRaised)
|
|
||||||
raisedFindings?.Add(crossing);
|
|
||||||
else
|
|
||||||
Add(crossing, owned);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (move.Layer == LayerType.Leadin)
|
else if (move.Layer == LayerType.Leadin)
|
||||||
@@ -250,11 +157,6 @@ 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;
|
||||||
@@ -277,7 +179,7 @@ public sealed class ReleasedContourState
|
|||||||
contourNumber++;
|
contourNumber++;
|
||||||
if (reportMissingLeadIns && !cutoff && !hasLead)
|
if (reportMissingLeadIns && !cutoff && !hasLead)
|
||||||
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||||
$"Cutting contour {contourNumber} is missing lead-in geometry.")
|
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
|
||||||
{ Location = move.Curve.Start }, owned);
|
{ Location = move.Curve.Start }, owned);
|
||||||
hasLead = false;
|
hasLead = false;
|
||||||
// A rapid can pause/reposition without leaving any material gap.
|
// A rapid can pause/reposition without leaving any material gap.
|
||||||
@@ -299,31 +201,19 @@ public sealed class ReleasedContourState
|
|||||||
// A completed contour becomes an obstacle immediately, not at part end.
|
// A completed contour becomes an obstacle immediately, not at part end.
|
||||||
if (PostVerificationGeometry.Closed(current.Curves))
|
if (PostVerificationGeometry.Closed(current.Curves))
|
||||||
Finish();
|
Finish();
|
||||||
else if (OvercutClosure(current.Curves) is { } closing)
|
|
||||||
{
|
|
||||||
current.OvercutEnd = current.Curves[^1].End;
|
|
||||||
current.Curves[^1] = closing;
|
|
||||||
Finish();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Finish();
|
Finish();
|
||||||
loose.Leads.AddRange(leadIns);
|
loose.Leads.AddRange(leadIns);
|
||||||
// Open cuts that provably enclose nothing retain all their material, however many
|
// Open cuts that provably enclose nothing retain all their material, however many
|
||||||
// pieces a tabbed cutout was cut in, and so does a tabbed perimeter whose lead-out stays
|
// pieces a tabbed cutout was cut in. Otherwise they need manual review. Planning proves
|
||||||
// out of its tab: planning and pre-post review prove them alike. Otherwise they need
|
// them too (its own tabbed cutouts are cut in pieces without lead-outs), but only
|
||||||
// manual review.
|
// pre-post review lets the proof clear a lead-out: planning keeps refusing to certify
|
||||||
// A common-line member's open cuts are its remainder pieces: they end in shared kerf, not at
|
// its own tabs followed by a lead-out.
|
||||||
// tabs, and the planner's contour accounting proves they cut its outside exactly once.
|
|
||||||
var remainders = member && !cutoff && unfinished.Count > 0;
|
|
||||||
// Slug slices (docs/support-tabs.md#slug-slices) cross one another inside one of the part's
|
|
||||||
// own holes: what they enclose is that hole's slug, scrap, so they take no part in the proof.
|
|
||||||
var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0)
|
var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0)
|
||||||
&& (remainders || unfinished.All(LeadsClearOpenEnds)
|
&& unfinished.All(LeadsClearOpenEnds)
|
||||||
&& OpenCutsEncloseNothing(closed, unfinished
|
&& OpenCutsEncloseNothing(closed, unfinished.Concat(otherOpen).Append(loose).ToList(), token);
|
||||||
.Concat(otherOpen.Where(group => !CutsOnlySlug(group, closed, unfinished, token)))
|
for (var i = deferred.Count - 1; i >= 0 && !(retained && proveOpenCuts); i--)
|
||||||
.Append(loose).ToList(), token));
|
|
||||||
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.
|
||||||
@@ -332,34 +222,6 @@ public sealed class ReleasedContourState
|
|||||||
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
|
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
|
||||||
"they cannot be assumed to be retained by tabs. Review rapid travel manually."),
|
"they cannot be assumed to be retained by tabs. Review rapid travel manually."),
|
||||||
unfinished.Any(chain => chain.Fixed));
|
unfinished.Any(chain => chain.Fixed));
|
||||||
// A part that may tip once cut is avoided like one cut free. The retention proof above is
|
|
||||||
// unchanged: the part is still attached by its tab, just not held level by it.
|
|
||||||
if (remainders)
|
|
||||||
{
|
|
||||||
// Its remainder pieces in cutting order, joined across the shared spans between them:
|
|
||||||
// the member's outside, now cut free.
|
|
||||||
var curves = new List<PostVerificationGeometry.Curve>();
|
|
||||||
foreach (var chain in unfinished)
|
|
||||||
{
|
|
||||||
if (curves.Count > 0 && curves[^1].End.DistanceTo(chain.Curves[0].Start) > PostVerificationGeometry.Epsilon)
|
|
||||||
curves.Add(PostVerificationGeometry.Curve.Create(curves[^1].End, chain.Curves[0].Start, null, false));
|
|
||||||
curves.AddRange(chain.Curves);
|
|
||||||
}
|
|
||||||
if (curves[^1].End.DistanceTo(curves[0].Start) > PostVerificationGeometry.Epsilon)
|
|
||||||
curves.Add(PostVerificationGeometry.Curve.Create(curves[^1].End, curves[0].Start, null, false));
|
|
||||||
obstacles.Add(new(part, contourNumber, curves, null));
|
|
||||||
}
|
|
||||||
else if (!cutoff && unfinished.Count > 0 && tabMayNotHold?.Invoke(part) == true)
|
|
||||||
{
|
|
||||||
var perimeter = unfinished.MaxBy(chain => chain.Curves.Sum(curve => curve.Length));
|
|
||||||
var curves = perimeter.Curves.ToList();
|
|
||||||
if (curves[^1].End.DistanceTo(curves[0].Start) > PostVerificationGeometry.Epsilon)
|
|
||||||
curves.Add(PostVerificationGeometry.Curve.Create(curves[^1].End, curves[0].Start, null, false));
|
|
||||||
obstacles.Add(new(part, contourNumber, curves, null) { TabMayNotHold = true });
|
|
||||||
}
|
|
||||||
partContours = contourNumber;
|
|
||||||
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
|
|
||||||
&& !inBreakCut && loose.Leads.Count == 0;
|
|
||||||
|
|
||||||
void Add(PostVerificationFinding finding, bool fixedEmission)
|
void Add(PostVerificationFinding finding, bool fixedEmission)
|
||||||
{
|
{
|
||||||
@@ -382,12 +244,10 @@ public sealed class ReleasedContourState
|
|||||||
return;
|
return;
|
||||||
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
|
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
|
||||||
// no flag or tab configuration is used as evidence of retention.
|
// no flag or tab configuration is used as evidence of retention.
|
||||||
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
|
if (!cutoff && PostVerificationGeometry.Closed(current.Curves))
|
||||||
{
|
{
|
||||||
lastObstacle = new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd);
|
obstacles.Add(new(part, contourNumber, current.Curves.ToArray()));
|
||||||
obstacles.Add(lastObstacle);
|
|
||||||
closed.Add(current);
|
closed.Add(current);
|
||||||
lastClosed = current;
|
|
||||||
}
|
}
|
||||||
else if (!cutoff)
|
else if (!cutoff)
|
||||||
unfinished.Add(current);
|
unfinished.Add(current);
|
||||||
@@ -395,47 +255,12 @@ 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>
|
|
||||||
/// The closing piece of a contour cut on past its own start along its first cut, as a source
|
|
||||||
/// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
|
|
||||||
/// cut passes back through the start and its tail retraces the first cut. The tail encloses
|
|
||||||
/// nothing, so the contour is closed by the last cut up to its start. Null for any other shape.
|
|
||||||
/// Arcs of one circle may carry centers derived from their own rounded endpoints, so the seam
|
|
||||||
/// is matched at <see cref="PostVerificationGeometry.RaySeamTolerance"/>, the closure seam
|
|
||||||
/// ray parity already admits; the obstacle keeps that seam, never the retraced tail.
|
|
||||||
/// </summary>
|
|
||||||
private static PostVerificationGeometry.Curve OvercutClosure(IReadOnlyList<PostVerificationGeometry.Curve> curves)
|
|
||||||
{
|
|
||||||
if (curves.Count < 2)
|
|
||||||
return null;
|
|
||||||
const double tolerance = PostVerificationGeometry.RaySeamTolerance;
|
|
||||||
var first = curves[0];
|
|
||||||
var last = curves[^1];
|
|
||||||
if (!last.SameDirection(first, tolerance) || !last.Contains(first.Start, tolerance)
|
|
||||||
|| !first.Contains(last.End, tolerance))
|
|
||||||
return null;
|
|
||||||
var seam = last.ClosestPoint(first.Start);
|
|
||||||
var along = last.DistanceAlong(seam);
|
|
||||||
var tail = last.Length - along;
|
|
||||||
return along > tolerance && tail > tolerance && tail <= first.Length + tolerance ? last.Until(seam) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether one part's open cuts, with their leads, can release no material. A closed
|
/// Whether one part's open cuts, with their leads, can release no material. A closed
|
||||||
/// contour with its own leads already encloses whatever it releases and counts as one node.
|
/// contour with its own leads already encloses whatever it releases and counts as one node.
|
||||||
/// Open cuts may touch any cut only at their own ends, and must form no loop through those
|
/// Open cuts may touch any cut only at their own ends, and must form no loop through those
|
||||||
/// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and
|
/// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and
|
||||||
/// tab strength are not modeled, exactly as for a single open contour.
|
/// tab strength are not modelled, exactly as for a single open contour.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const double JointTolerance = 1e-5;
|
private const double JointTolerance = 1e-5;
|
||||||
|
|
||||||
@@ -465,35 +290,6 @@ public sealed class ReleasedContourState
|
|||||||
|| lead.End.DistanceTo(point) <= PostVerificationGeometry.Epsilon;
|
|| lead.End.DistanceTo(point) <= PostVerificationGeometry.Epsilon;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether break cut <paramref name="group"/>, its leads included, lies wholly inside one of
|
|
||||||
/// the part's closed holes and touches none of it: whatever it frees is that hole's slug. Only
|
|
||||||
/// straight cuts qualify. The part's outside is its largest cut, never a hole.
|
|
||||||
/// </summary>
|
|
||||||
private static bool CutsOnlySlug(CutGroup group, IReadOnlyList<CutGroup> closed, IReadOnlyList<CutGroup> unfinished,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
|
||||||
var cuts = group.Curves.Concat(group.Leads).ToList();
|
|
||||||
if (cuts.Count == 0 || !cuts.TrueForAll(cut => cut.IsLine))
|
|
||||||
return false;
|
|
||||||
var outside = closed.Concat(unfinished).Where(cut => cut.Curves.Count > 0).MaxBy(cut =>
|
|
||||||
{
|
|
||||||
var extent = cut.Curves.Aggregate(PostVerificationGeometry.Extent.None, (sum, curve) => sum.Union(curve.Extent));
|
|
||||||
return (extent.MaxX - extent.MinX) * (extent.MaxY - extent.MinY);
|
|
||||||
});
|
|
||||||
foreach (var hole in closed)
|
|
||||||
{
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
if (ReferenceEquals(hole, outside) || !PostVerificationGeometry.Closed(hole.Curves))
|
|
||||||
continue;
|
|
||||||
if (cuts.TrueForAll(cut => PostVerificationGeometry.Inside(cut.Start, hole.Curves, token)
|
|
||||||
&& PostVerificationGeometry.Inside(cut.End, hole.Curves, token)
|
|
||||||
&& PostVerificationGeometry.ContactPoints(cut.Start, cut.End, hole.Curves, token).Count == 0))
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool OpenCutsEncloseNothing(IReadOnlyList<CutGroup> closed, IReadOnlyList<CutGroup> open,
|
private static bool OpenCutsEncloseNothing(IReadOnlyList<CutGroup> closed, IReadOnlyList<CutGroup> open,
|
||||||
CancellationToken token)
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -600,9 +396,6 @@ public sealed class ReleasedContourState
|
|||||||
internal List<PostVerificationGeometry.Curve> Curves { get; } = new();
|
internal List<PostVerificationGeometry.Curve> Curves { get; } = new();
|
||||||
internal List<PostVerificationGeometry.Curve> Leads { get; } = new();
|
internal List<PostVerificationGeometry.Curve> Leads { get; } = new();
|
||||||
internal bool Fixed { get; set; }
|
internal bool Fixed { get; set; }
|
||||||
// Where the cut really ended when OvercutClosure closed it: the retraced tail's end, on the
|
|
||||||
// first cut. Its last curve now ends at the seam instead.
|
|
||||||
internal Vector? OvercutEnd { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class DisjointSet(int count)
|
private sealed class DisjointSet(int count)
|
||||||
@@ -619,13 +412,9 @@ public sealed class ReleasedContourState
|
|||||||
internal void Union(int a, int b) => parents[Find(a)] = Find(b);
|
internal void Union(int a, int b) => parents[Find(a)] = Find(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves,
|
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves)
|
||||||
Vector? OvercutEnd)
|
|
||||||
{
|
{
|
||||||
internal PostVerificationGeometry.Extent Extent { get; } =
|
internal PostVerificationGeometry.Extent Extent { get; } =
|
||||||
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
|
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
|
||||||
|
|
||||||
/// <summary>A tabbed perimeter closed across its tab gap, for a part its tab may not hold level.</summary>
|
|
||||||
internal bool TabMayNotHold { get; init; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,8 +72,8 @@ internal static class SeamOvercut
|
|||||||
// Incremental coordinates only need a consistent origin within one contour.
|
// Incremental coordinates only need a consistent origin within one contour.
|
||||||
var reference = incremental ? position ?? new Vector() : new Vector();
|
var reference = incremental ? position ?? new Vector() : new Vector();
|
||||||
var end = reference + motion.EndPoint;
|
var end = reference + motion.EndPoint;
|
||||||
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter }
|
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display }
|
||||||
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter };
|
or ArcMove { Layer: LayerType.Cut or LayerType.Display };
|
||||||
if (!cut || position == null)
|
if (!cut || position == null)
|
||||||
{
|
{
|
||||||
Finish();
|
Finish();
|
||||||
@@ -160,11 +160,11 @@ internal static class SeamOvercut
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The angle traveled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary>
|
/// <summary>The angle travelled 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 = PortableMath.Atan2(from.Y - center.Y, from.X - center.X);
|
var start = System.Math.Atan2(from.Y - center.Y, from.X - center.X);
|
||||||
var end = PortableMath.Atan2(to.Y - center.Y, to.X - center.X);
|
var end = System.Math.Atan2(to.Y - center.Y, to.X - center.X);
|
||||||
var travel = (clockwise ? start - end : end - start) % (2 * System.Math.PI);
|
var travel = (clockwise ? start - end : end - start) % (2 * System.Math.PI);
|
||||||
return travel < 0 ? travel + 2 * System.Math.PI : travel;
|
return travel < 0 ? travel + 2 * System.Math.PI : travel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,20 +83,16 @@ internal static class StateFingerprint
|
|||||||
Out(p.ExternalLeadOut);
|
Out(p.ExternalLeadOut);
|
||||||
Lead(p.InternalLeadIn);
|
Lead(p.InternalLeadIn);
|
||||||
Out(p.InternalLeadOut);
|
Out(p.InternalLeadOut);
|
||||||
|
Lead(p.ArcCircleLeadIn);
|
||||||
|
Out(p.ArcCircleLeadOut);
|
||||||
Number(p.PierceClearance);
|
Number(p.PierceClearance);
|
||||||
Number(p.PierceDiameter);
|
Number(p.PierceDiameter);
|
||||||
Flag(p.RoundLeadInAngles);
|
Flag(p.RoundLeadInAngles);
|
||||||
Number(p.LeadInAngleIncrement);
|
Number(p.LeadInAngleIncrement);
|
||||||
|
Number(p.AutoTabMinSize);
|
||||||
|
Number(p.AutoTabMaxSize);
|
||||||
TabSettings(p.TabConfig);
|
TabSettings(p.TabConfig);
|
||||||
Flag(p.TabsEnabled);
|
Flag(p.TabsEnabled);
|
||||||
Flag(p.SupportTabs);
|
|
||||||
Flag(p.TabBySize);
|
|
||||||
Number(p.TabSizeMinWidth);
|
|
||||||
Number(p.TabSizeMaxWidth);
|
|
||||||
Number(p.TabHeldMaxLength);
|
|
||||||
// Only when on, so fingerprints taken before the setting existed still match.
|
|
||||||
if (p.SliceTippingSlugs)
|
|
||||||
text.Append("slugSlices;");
|
|
||||||
Sequence(p.Sequencing);
|
Sequence(p.Sequencing);
|
||||||
Assignment(p.Assignment);
|
Assignment(p.Assignment);
|
||||||
text.Append('}');
|
text.Append('}');
|
||||||
@@ -145,7 +141,6 @@ internal static class StateFingerprint
|
|||||||
break;
|
break;
|
||||||
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
|
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
|
||||||
Number(l.Radius);
|
Number(l.Radius);
|
||||||
Number(l.SweepAngle);
|
|
||||||
break;
|
break;
|
||||||
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
|
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
|
||||||
Number(l.LineLength);
|
Number(l.LineLength);
|
||||||
|
|||||||
@@ -10,12 +10,6 @@ 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)
|
||||||
@@ -26,7 +20,6 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
|
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
|
||||||
{
|
{
|
||||||
var entities = partProgram.ToGeometry();
|
var entities = partProgram.ToGeometry();
|
||||||
RefuseDisplayPerimeter(entities);
|
|
||||||
var breakCuts = TakeBreakCuts(entities);
|
var breakCuts = TakeBreakCuts(entities);
|
||||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||||
|
|
||||||
@@ -114,14 +107,6 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new CuttingResult { Program = result, LastCutPoint = perimeterPt };
|
return new CuttingResult { Program = result, LastCutPoint = perimeterPt };
|
||||||
}
|
}
|
||||||
|
|
||||||
// These tools would lead the outline or mistake the largest cutout for it (an External lead
|
|
||||||
// pierced on the blank). Only the cutting planner knows every contour of such a part is a hole.
|
|
||||||
private static void RefuseDisplayPerimeter(List<Entity> entities)
|
|
||||||
{
|
|
||||||
if (entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
|
||||||
throw new InvalidOperationException(DisplayPerimeterTail.LeadInRefusal);
|
|
||||||
}
|
|
||||||
|
|
||||||
public CuttingResult ApplySingle(
|
public CuttingResult ApplySingle(
|
||||||
Program partProgram,
|
Program partProgram,
|
||||||
Vector point,
|
Vector point,
|
||||||
@@ -130,7 +115,6 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var entities = partProgram.ToGeometry();
|
var entities = partProgram.ToGeometry();
|
||||||
RefuseDisplayPerimeter(entities);
|
|
||||||
var breakCuts = TakeBreakCuts(entities);
|
var breakCuts = TakeBreakCuts(entities);
|
||||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||||
|
|
||||||
@@ -267,23 +251,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
|
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
|
||||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
|
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
|
||||||
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
|
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
|
||||||
IReadOnlyList<Shape> tabbedCutouts = null, int? perimeterOrdinal = null, Shape displayTail = null,
|
IReadOnlyList<Shape> tabbedCutouts = null)
|
||||||
CuttingPlanning.CommonLinePerimeter commonLine = null, IReadOnlyList<Support.SlugSlice> slugSlices = null)
|
|
||||||
{
|
{
|
||||||
var result = new Program(Mode.Absolute);
|
var result = new Program(Mode.Absolute);
|
||||||
EmitScribeContours(result, scribes);
|
EmitScribeContours(result, scribes);
|
||||||
EmitBreakCuts(result, breakCuts, shapes);
|
EmitBreakCuts(result, breakCuts, shapes);
|
||||||
EmitSlugSlices(result, slugSlices);
|
|
||||||
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
|
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
|
||||||
EmitTabbedCutout(result, cutout);
|
EmitTabbedCutout(result, cutout);
|
||||||
EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1,
|
EmitChosenContours(result, shapes, choices, contourParameters);
|
||||||
commonLine);
|
|
||||||
if (displayTail != null)
|
|
||||||
{
|
|
||||||
// Display only, after every cut: executed reads and posts stop at its marker.
|
|
||||||
var start = GetShapeStartPoint(displayTail);
|
|
||||||
DisplayPerimeterTail.Append(result, ConvertShapeToMoves(displayTail, start, LayerType.DisplayPerimeter), start);
|
|
||||||
}
|
|
||||||
result.Mode = Mode.Incremental;
|
result.Mode = Mode.Incremental;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -294,78 +269,33 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
// perimeter never degrades to a hole because it was isolated. Output must not be
|
// perimeter never degrades to a hole because it was isolated. Output must not be
|
||||||
// installed on a Part or accepted as a complete plan.
|
// installed on a Part or accepted as a complete plan.
|
||||||
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
|
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
|
||||||
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null,
|
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null)
|
||||||
int? perimeterOrdinal = null, CuttingPlanning.CommonLinePerimeter commonLine = null)
|
|
||||||
{
|
{
|
||||||
var result = new Program(Mode.Absolute);
|
var result = new Program(Mode.Absolute);
|
||||||
EmitScribeContours(result, scribes);
|
EmitScribeContours(result, scribes);
|
||||||
EmitChosenContours(result, shapes, new[] { choice }, contourParameters, perimeterOrdinal ?? shapes.Length - 1,
|
EmitChosenContours(result, shapes, new[] { choice }, contourParameters);
|
||||||
commonLine);
|
|
||||||
result.Mode = Mode.Incremental;
|
result.Mode = Mode.Incremental;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One contour per choice in order; the perimeter is always External. Without a cut perimeter
|
// One contour per choice in order; the perimeter (last shape) is always External.
|
||||||
// (a display perimeter outside) perimeterOrdinal is past the last shape: every contour is a hole.
|
|
||||||
// Contour emission itself reads only the contour's own geometry and settings, never
|
// Contour emission itself reads only the contour's own geometry and settings, never
|
||||||
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
|
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
|
||||||
private void EmitChosenContours(Program result, Shape[] shapes,
|
private void EmitChosenContours(Program result, Shape[] shapes,
|
||||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters,
|
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters)
|
||||||
int perimeterOrdinal, CuttingPlanning.CommonLinePerimeter commonLine = null)
|
|
||||||
{
|
{
|
||||||
var shared = Parameters;
|
var shared = Parameters;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var inSlug = false;
|
|
||||||
foreach (var choice in choices)
|
foreach (var choice in choices)
|
||||||
{
|
{
|
||||||
if (commonLine is { StartsOnCommonLine: true } && choice.ContourOrdinal == perimeterOrdinal
|
|
||||||
&& !IsTabbedCutout(shapes[perimeterOrdinal]))
|
|
||||||
{
|
|
||||||
// Cut whole from its common line: the straight lead-in from the choice's
|
|
||||||
// pierce runs along the shared edge, and the perimeter ends where it began,
|
|
||||||
// beside its partner, so it has no lead-out and no tab.
|
|
||||||
var whole = CuttingPlanning.OwnedCuttingParameters.Copy(shared);
|
|
||||||
whole.ExternalLeadOut = new NoLeadOut();
|
|
||||||
whole.TabsEnabled = false;
|
|
||||||
Parameters = whole;
|
|
||||||
var start = result.Codes.Count;
|
|
||||||
EmitContour(result, shapes[perimeterOrdinal], choice.Point,
|
|
||||||
shapes[perimeterOrdinal].Entities[choice.EntityOrdinal], ContourType.External,
|
|
||||||
exactCirclePrograms: true, scrapPierce: choice.Pierce, stopAtCenterLine: false,
|
|
||||||
memoContour: choice.ContourOrdinal);
|
|
||||||
if (inSlug && start < result.Codes.Count && result.Codes[start] is RapidMove)
|
|
||||||
result.Codes.Insert(start, new Comment(HeadRaise.Marker));
|
|
||||||
inSlug = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (commonLine != null && choice.ContourOrdinal == perimeterOrdinal)
|
|
||||||
{
|
|
||||||
// The rapid out of a cutout's slug before it is still made head up.
|
|
||||||
Parameters = shared;
|
|
||||||
var first = result.Codes.Count;
|
|
||||||
EmitCommonLinePerimeter(result, shapes[perimeterOrdinal], commonLine);
|
|
||||||
if (inSlug && first < result.Codes.Count && result.Codes[first] is RapidMove)
|
|
||||||
result.Codes.Insert(first, new Comment(HeadRaise.Marker));
|
|
||||||
inSlug = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// A contour's own settings apply to its emission alone; scribes and every
|
// A contour's own settings apply to its emission alone; scribes and every
|
||||||
// other contour keep the shared settings.
|
// other contour keep the shared settings.
|
||||||
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
|
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
|
||||||
var shape = shapes[choice.ContourOrdinal];
|
var shape = shapes[choice.ContourOrdinal];
|
||||||
var arrival = result.Codes.Count;
|
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||||
var leftInSlug = EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
|
||||||
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
|
exactCirclePrograms: true, scrapPierce: choice.Pierce);
|
||||||
exactCirclePrograms: true, scrapPierce: choice.Pierce,
|
|
||||||
stopAtCenterLine: contourParameters?[choice.ContourOrdinal] == null,
|
|
||||||
memoContour: choice.ContourOrdinal);
|
|
||||||
// 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
|
||||||
@@ -484,17 +414,14 @@ 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);
|
||||||
|
|
||||||
/// <returns>Whether the contour is a cutout left by a lead-out, which ends in its slug.</returns>
|
private void EmitContour(
|
||||||
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);
|
||||||
@@ -503,9 +430,6 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
var leadIn = SelectLeadIn(contourType);
|
var leadIn = SelectLeadIn(contourType);
|
||||||
var leadOut = SelectLeadOut(contourType);
|
var leadOut = SelectLeadOut(contourType);
|
||||||
// A cutout the planner pierced by erosion is too small for its configured leads.
|
|
||||||
if (scrapPierce != null && contourType != ContourType.External)
|
|
||||||
leadOut = new NoLeadOut();
|
|
||||||
|
|
||||||
if (contourType == ContourType.ArcCircle && entity is Circle circle)
|
if (contourType == ContourType.ArcCircle && entity is Circle circle)
|
||||||
{
|
{
|
||||||
@@ -517,21 +441,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
var outwardAngle = normal - System.Math.PI;
|
var outwardAngle = normal - System.Math.PI;
|
||||||
point = new Vector(
|
point = new Vector(
|
||||||
circle.Center.X + circle.Radius * PortableMath.Cos(outwardAngle),
|
circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle),
|
||||||
circle.Center.Y + circle.Radius * PortableMath.Sin(outwardAngle)
|
circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrapPierce is { } holePierce)
|
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
||||||
leadIn = StraightLeadIn(point, holePierce, out normal);
|
Parameters.GetEffectivePierceClearance());
|
||||||
else
|
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius());
|
||||||
{
|
|
||||||
if (stopAtCenterLine)
|
|
||||||
leadIn = StopAtCenterLine(shape, point, leadIn, normal, winding);
|
|
||||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
|
||||||
Parameters.GetEffectivePierceClearance());
|
|
||||||
leadIn = PierceZone(memoContour, shape, point, leadIn, normal, winding);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build hole sub-program relative to (0,0)
|
// Build hole sub-program relative to (0,0)
|
||||||
var holeCenter = circle.Center;
|
var holeCenter = circle.Center;
|
||||||
@@ -548,8 +465,7 @@ 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));
|
||||||
var holeLeadOut = leadOut.Generate(relativePoint, normal, winding);
|
subPgm.Codes.AddRange(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
|
||||||
@@ -568,12 +484,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return holeLeadOut.Count > 0;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var turn = LeadTurn(contourType, winding);
|
|
||||||
double leadInNormal;
|
double leadInNormal;
|
||||||
var configuredLeadIn = leadIn;
|
|
||||||
// A planner-placed scrap pierce replaces the configured lead-in with a straight one.
|
// A planner-placed scrap pierce replaces the configured lead-in with a straight one.
|
||||||
if (scrapPierce is { } pierce)
|
if (scrapPierce is { } pierce)
|
||||||
leadIn = StraightLeadIn(point, pierce, out leadInNormal);
|
leadIn = StraightLeadIn(point, pierce, out leadInNormal);
|
||||||
@@ -582,12 +496,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
||||||
Parameters.GetEffectivePierceClearance(), out leadInNormal);
|
Parameters.GetEffectivePierceClearance(), out leadInNormal);
|
||||||
if (contourType == ContourType.Internal)
|
if (contourType == ContourType.Internal)
|
||||||
{
|
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius());
|
||||||
if (stopAtCenterLine)
|
|
||||||
leadIn = StopAtCenterLine(shape, point, leadIn, leadInNormal, turn);
|
|
||||||
leadIn = PierceZone(memoContour, shape, point, leadIn, leadInNormal, turn);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
|
||||||
|
|
||||||
var reindexedShape = shape.ReindexAt(point, entity);
|
var reindexedShape = shape.ReindexAt(point, entity);
|
||||||
|
|
||||||
@@ -600,7 +511,6 @@ 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:
|
||||||
@@ -610,18 +520,6 @@ 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
|
||||||
@@ -630,24 +528,10 @@ 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));
|
||||||
var leadOutCodes = leadOut.Generate(leadOutPoint, leadOutNormal, turn);
|
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, winding));
|
||||||
program.Codes.AddRange(leadOutCodes);
|
|
||||||
return contourType != ContourType.External && leadOutCodes.Count > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The direction a lead arc turns to meet a contour of <paramref name="winding"/>
|
|
||||||
/// tangentially, in the cut direction. Normals point away from the material, so the arc
|
|
||||||
/// center lies outside an outside perimeter (turning against its winding) and inside a
|
|
||||||
/// cutout (turning with it).
|
|
||||||
/// </summary>
|
|
||||||
public static RotationType LeadTurn(ContourType contourType, RotationType winding) =>
|
|
||||||
contourType == ContourType.External
|
|
||||||
? winding == RotationType.CW ? RotationType.CCW : RotationType.CW
|
|
||||||
: winding;
|
|
||||||
|
|
||||||
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||||
{
|
{
|
||||||
if (scribeEntities.Count == 0)
|
if (scribeEntities.Count == 0)
|
||||||
@@ -732,10 +616,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
|
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
|
||||||
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
|
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
|
||||||
/// reflex corner it bisects the notch. The result does not depend on which of
|
/// reflex corner it bisects the notch. The result does not depend on which of
|
||||||
/// the two edges meeting at the corner was picked. A curved lead-in at a convex
|
/// the two edges meeting at the corner was picked. Other styles and contour
|
||||||
/// perimeter corner uses the normal of the edge cut first, so its arc meets that
|
/// types keep <see cref="ComputeLeadInNormal"/>.
|
||||||
/// edge tangentially. Other styles and contour types keep
|
|
||||||
/// <see cref="ComputeLeadInNormal"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static LeadIn ResolveLeadIn(
|
public static LeadIn ResolveLeadIn(
|
||||||
Shape shape,
|
Shape shape,
|
||||||
@@ -749,19 +631,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
|
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
|
||||||
if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
|
if (contourType != ContourType.External || leadIn is not LineLeadIn line
|
||||||
return leadIn;
|
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||||
|
|
||||||
if (leadIn is ArcLeadIn or LineArcLeadIn or CleanHoleLeadIn)
|
|
||||||
{
|
|
||||||
// The arc meets the edge cut first tangentially; its center sits off that edge,
|
|
||||||
// which at a convex corner keeps the whole arc clear of the part.
|
|
||||||
if (ClassifyCorner(corner, winding) is CornerKind.Convex or CornerKind.Smooth)
|
|
||||||
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
|
||||||
return leadIn;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leadIn is not LineLeadIn line)
|
|
||||||
return leadIn;
|
return leadIn;
|
||||||
|
|
||||||
switch (ClassifyCorner(corner, winding))
|
switch (ClassifyCorner(corner, winding))
|
||||||
@@ -791,8 +662,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
|
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
|
||||||
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
|
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
|
||||||
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
|
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
|
||||||
/// bisects the notch. An <see cref="ArcLeadOut"/> at a convex corner uses the normal of
|
/// bisects the notch. Other styles and contour types keep the entity normal.
|
||||||
/// the edge cut last. Other styles and contour types keep the entity normal.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static LeadOut ResolveLeadOut(
|
public static LeadOut ResolveLeadOut(
|
||||||
Shape shape,
|
Shape shape,
|
||||||
@@ -806,18 +676,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
normal = ComputeNormal(point, entity, contourType, winding);
|
normal = ComputeNormal(point, entity, contourType, winding);
|
||||||
if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
|
if (contourType != ContourType.External || leadOut is not LineLeadOut line
|
||||||
return leadOut;
|
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||||
|
|
||||||
if (leadOut is ArcLeadOut)
|
|
||||||
{
|
|
||||||
// The arc leaves the edge cut last tangentially.
|
|
||||||
if (ClassifyCorner(corner, winding) is CornerKind.Convex or CornerKind.Smooth)
|
|
||||||
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
|
||||||
return leadOut;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leadOut is not LineLeadOut line)
|
|
||||||
return leadOut;
|
return leadOut;
|
||||||
|
|
||||||
switch (ClassifyCorner(corner, winding))
|
switch (ClassifyCorner(corner, winding))
|
||||||
@@ -944,18 +804,10 @@ 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 = tangentIn.X * tangentOut.Y - tangentIn.Y * tangentOut.X;
|
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
|
||||||
var dot = tangentIn.DotProduct(tangentOut);
|
var dot = corner.TangentIn.DotProduct(corner.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)
|
||||||
@@ -975,53 +827,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||||
// Sum unit normals rather than averaging angles (which fails at 0/2Ï€).
|
// Sum unit normals rather than averaging angles (which fails at 0/2Ï€).
|
||||||
// Winding makes this point into the scrap even at reflex corners.
|
// Winding makes this point into the scrap even at reflex corners.
|
||||||
var x = PortableMath.Cos(normal) + PortableMath.Cos(adjacentNormal);
|
var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal);
|
||||||
var y = PortableMath.Sin(normal) + PortableMath.Sin(adjacentNormal);
|
var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal);
|
||||||
if (!double.IsFinite(x) || !double.IsFinite(y)
|
if (!double.IsFinite(x) || !double.IsFinite(y)
|
||||||
|| x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
|
|| x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
|
||||||
return null; // Opposing normals at a cusp have no unique bisector.
|
return null; // Opposing normals at a cusp have no unique bisector.
|
||||||
|
|
||||||
return Angle.NormalizeRad(PortableMath.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 = PortableMath.Cos(startNormal) + PortableMath.Cos(endNormal);
|
|
||||||
var y = PortableMath.Sin(startNormal) + PortableMath.Sin(endNormal);
|
|
||||||
if (x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var normal = Angle.NormalizeRad(PortableMath.Atan2(y, x));
|
|
||||||
var pierce = leadIn.GetPiercePoint(start, normal, turn);
|
|
||||||
var leadOutEnd = ((LinearMove)leadOut.Generate(end, normal, turn)[0]).EndPoint;
|
|
||||||
return IsClearStraightLead(shape, start, pierce, clearance)
|
|
||||||
&& IsClearStraightLead(shape, end, leadOutEnd, clearance)
|
|
||||||
? normal
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1133,7 +945,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
if (leadIn is NoLeadIn || clearance <= 0)
|
if (leadIn is NoLeadIn || clearance <= 0)
|
||||||
return leadIn;
|
return leadIn;
|
||||||
|
|
||||||
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle, circle.Rotation);
|
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle);
|
||||||
var maxRadius = circle.Radius - clearance;
|
var maxRadius = circle.Radius - clearance;
|
||||||
if (maxRadius <= 0)
|
if (maxRadius <= 0)
|
||||||
return leadIn;
|
return leadIn;
|
||||||
@@ -1175,30 +987,17 @@ 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 straight in from the wall to the hole's
|
/// returned as given. Otherwise the pierce moves onto the line from
|
||||||
/// center line, or the lead's own length in when that comes first (see
|
/// <paramref name="point"/> toward the hole's pole of inaccessibility (the centre of a
|
||||||
/// <see cref="StopAtCenterLine"/>). A lead that cannot keep the zone there moves onto the
|
/// round hole), as close to the contour as the zone allows, and the lead becomes a
|
||||||
/// line from <paramref name="point"/> toward the nearest point that keeps it (see
|
/// straight line from there, generated with <paramref name="normal"/>. A hole too small
|
||||||
/// <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.
|
||||||
/// <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)
|
||||||
{
|
{
|
||||||
if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|
if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|
||||||
|| !hole.IsClosed())
|
|| !hole.IsClosed())
|
||||||
@@ -1208,48 +1007,35 @@ 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;
|
||||||
var configured = leadIn.GetPiercePoint(point, normal, turn);
|
if (Clear(leadIn.GetPiercePoint(point, normal)))
|
||||||
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;
|
||||||
|
|
||||||
// The pole of a letter can be a stroke away: head for the stroke beside the entry, on
|
// Clearance grows monotonically toward the pole of a convex hole, so the bisection
|
||||||
// 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 = toward;
|
var pierce = pole;
|
||||||
if (Clear(toward) && (reach < pierceRadius || toward == pole))
|
if (Clear(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 + (toward - point) * middle))
|
if (Clear(point + (pole - point) * middle))
|
||||||
high = middle;
|
high = middle;
|
||||||
else
|
else
|
||||||
low = middle;
|
low = middle;
|
||||||
}
|
}
|
||||||
pierce = point + (toward - point) * high;
|
pierce = point + (pole - point) * high;
|
||||||
}
|
}
|
||||||
|
|
||||||
var length = pierce.DistanceTo(point);
|
var length = pierce.DistanceTo(point);
|
||||||
if (length <= Math.Tolerance.Epsilon)
|
if (length <= Math.Tolerance.Epsilon)
|
||||||
return leadIn;
|
return leadIn;
|
||||||
var direction = PortableMath.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
var direction = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
||||||
return new LineLeadIn
|
return new LineLeadIn
|
||||||
{
|
{
|
||||||
Length = length,
|
Length = length,
|
||||||
@@ -1264,120 +1050,25 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
&& hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius;
|
&& hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Keeps a cutout's lead-in out of the cutout's far half. A lead longer than the cutout is
|
|
||||||
/// deep at its entry, measured straight in from the wall to the center line where the walls
|
|
||||||
/// meet as the cutout erodes, reaches past the center line or runs along a narrow stroke, as
|
|
||||||
/// does one whose own line gets closer to a wall again. It becomes a straight one, generated
|
|
||||||
/// with <paramref name="normal"/>, from that center line (the center of a round hole) or
|
|
||||||
/// from the lead's own length in when that comes first. Any other lead keeps its shape, and
|
|
||||||
/// one that leaves a cutout other than a round hole is left for the lead check to refuse.
|
|
||||||
/// </summary>
|
|
||||||
public static LeadIn StopAtCenterLine(Shape hole, Vector point, LeadIn leadIn, double normal, RotationType turn)
|
|
||||||
{
|
|
||||||
if (leadIn is NoLeadIn || !hole.IsClosed())
|
|
||||||
return leadIn;
|
|
||||||
var pierce = leadIn.GetPiercePoint(point, normal, turn);
|
|
||||||
var length = pierce.DistanceTo(point);
|
|
||||||
if (!double.IsFinite(length) || length <= Math.Tolerance.Epsilon)
|
|
||||||
return leadIn;
|
|
||||||
|
|
||||||
var circle = hole.Entities.Count == 1 ? hole.Entities[0] as Circle : null;
|
|
||||||
var along = pierce - point;
|
|
||||||
var inward = Inward(normal);
|
|
||||||
// A pierce as far from every wall as it is deep has not come near another wall.
|
|
||||||
var inside = circle == null || pierce.DistanceTo(circle.Center) < circle.Radius;
|
|
||||||
if (inside && Clearance(hole, pierce) >= along.X * inward.X + along.Y * inward.Y - Math.Tolerance.Epsilon)
|
|
||||||
return leadIn;
|
|
||||||
|
|
||||||
var depth = CenterLineDepth(hole, point, normal, length);
|
|
||||||
if (depth <= Math.Tolerance.Epsilon
|
|
||||||
|| inside && depth >= length - Math.Tolerance.Epsilon && !PeaksBeforeItsEnd(hole, point, along))
|
|
||||||
return leadIn;
|
|
||||||
if (circle == null)
|
|
||||||
{
|
|
||||||
var polygon = hole.ToPolygonWithTolerance(Math.Tolerance.ChainTolerance);
|
|
||||||
for (var i = 1; i <= CenterLineSteps; i++)
|
|
||||||
if (!polygon.ContainsPoint(point + along * ((double)i / CenterLineSteps)))
|
|
||||||
return leadIn;
|
|
||||||
}
|
|
||||||
return new LineLeadIn { Length = depth, ApproachAngle = 90 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// How far a straight line in from the wall runs, up to maxDepth, before it reaches the
|
|
||||||
// center line: the distance to the wall grows at a steady rate until a second wall is as
|
|
||||||
// near, then levels off along a stroke or falls.
|
|
||||||
private static double CenterLineDepth(Shape hole, Vector point, double normal, double maxDepth)
|
|
||||||
{
|
|
||||||
if (hole.Entities.Count == 1 && hole.Entities[0] is Circle circle)
|
|
||||||
return System.Math.Min(maxDepth, circle.Radius);
|
|
||||||
|
|
||||||
var inward = Inward(normal);
|
|
||||||
var step = maxDepth / CenterLineSteps;
|
|
||||||
var rate = Clearance(hole, point + inward * step) / step;
|
|
||||||
// A corner's curved lead keeps an edge's normal, which runs along the other edge.
|
|
||||||
if (rate < MinimumCenterLineRate)
|
|
||||||
return maxDepth;
|
|
||||||
bool Rising(double depth) => Clearance(hole, point + inward * (depth + step / CenterLineSteps))
|
|
||||||
- Clearance(hole, point + inward * depth) >= rate / 2 * (step / CenterLineSteps);
|
|
||||||
for (var i = 1; i < CenterLineSteps; i++)
|
|
||||||
{
|
|
||||||
if (Rising(step * i))
|
|
||||||
continue;
|
|
||||||
double low = step * (i - 1), high = step * i;
|
|
||||||
for (var refine = 0; refine < CenterLineRefinements; refine++)
|
|
||||||
{
|
|
||||||
var middle = (low + high) / 2;
|
|
||||||
if (Rising(middle))
|
|
||||||
low = middle;
|
|
||||||
else
|
|
||||||
high = middle;
|
|
||||||
}
|
|
||||||
return low;
|
|
||||||
}
|
|
||||||
return maxDepth;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool PeaksBeforeItsEnd(Shape hole, Vector point, Vector along)
|
|
||||||
{
|
|
||||||
var peak = 0.0;
|
|
||||||
for (var i = 1; i <= CenterLineSteps; i++)
|
|
||||||
{
|
|
||||||
var clearance = Clearance(hole, point + along * ((double)i / CenterLineSteps));
|
|
||||||
if (clearance < peak - Math.Tolerance.Epsilon)
|
|
||||||
return true;
|
|
||||||
peak = System.Math.Max(peak, clearance);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double Clearance(Shape hole, Vector point) => hole.ClosestPointTo(point, out _).DistanceTo(point);
|
|
||||||
|
|
||||||
private static Vector Inward(double normal) => new(PortableMath.Cos(normal), PortableMath.Sin(normal));
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the
|
/// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the
|
||||||
/// normal to generate it with.
|
/// normal to generate it with.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static LeadIn StraightLeadIn(Vector point, Vector pierce, out double normal)
|
private static LeadIn StraightLeadIn(Vector point, Vector pierce, out double normal)
|
||||||
{
|
{
|
||||||
normal = PortableMath.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
normal = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
||||||
return new LineLeadIn { Length = pierce.DistanceTo(point), ApproachAngle = 90 };
|
return new LineLeadIn { Length = pierce.DistanceTo(point), ApproachAngle = 90 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bounds the pole search on long, thin holes; the result is checked before use.
|
// Bounds the pole search on long, thin holes; the result is checked before use.
|
||||||
private const int PoleSearchCells = 4096;
|
private const int PoleSearchCells = 4096;
|
||||||
|
|
||||||
// Samples along a lead when looking for the center line, then the steps narrowing in on it.
|
|
||||||
private const int CenterLineSteps = 64;
|
|
||||||
private const int CenterLineRefinements = 16;
|
|
||||||
private const double MinimumCenterLineRate = 0.01;
|
|
||||||
|
|
||||||
private LeadIn SelectLeadIn(ContourType contourType)
|
private LeadIn SelectLeadIn(ContourType contourType)
|
||||||
{
|
{
|
||||||
return contourType switch
|
return contourType switch
|
||||||
{
|
{
|
||||||
ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadIn,
|
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn,
|
||||||
|
ContourType.Internal => Parameters.InternalLeadIn,
|
||||||
_ => Parameters.ExternalLeadIn,
|
_ => Parameters.ExternalLeadIn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1386,29 +1077,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
return contourType switch
|
return contourType switch
|
||||||
{
|
{
|
||||||
ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadOut,
|
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut,
|
||||||
|
ContourType.Internal => Parameters.InternalLeadOut,
|
||||||
_ => Parameters.ExternalLeadOut,
|
_ => Parameters.ExternalLeadOut,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize)
|
private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize)
|
||||||
{
|
{
|
||||||
// A round perimeter is one circle entered at `center`: cut it as an arc from the entry
|
|
||||||
// that stops the tab's chord short of it, travelling the circle's own way.
|
|
||||||
if (shape.Entities is [Circle round] && round.Radius > tabSize / 2)
|
|
||||||
{
|
|
||||||
var entry = round.Center.AngleTo(center);
|
|
||||||
var gap = 2 * PortableMath.Asin(tabSize / (2 * round.Radius));
|
|
||||||
var reversed = round.Rotation == RotationType.CW;
|
|
||||||
var arc = new Arc(round.Center, round.Radius, entry, reversed ? entry + gap : entry - gap, reversed)
|
|
||||||
{
|
|
||||||
Layer = round.Layer,
|
|
||||||
};
|
|
||||||
var trimmed = new Shape();
|
|
||||||
trimmed.Entities.Add(arc);
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
var tabCircle = new Circle(center, tabSize);
|
var tabCircle = new Circle(center, tabSize);
|
||||||
var entities = new List<Entity>(shape.Entities);
|
var entities = new List<Entity>(shape.Entities);
|
||||||
|
|
||||||
@@ -1578,30 +1254,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
path = (Shape)cut.Clone();
|
path = (Shape)cut.Clone();
|
||||||
path.Reverse();
|
path.Reverse();
|
||||||
}
|
}
|
||||||
var normal = Angle.NormalizeRad(PortableMath.Atan2(scrap.Y, scrap.X));
|
var normal = Angle.NormalizeRad(System.Math.Atan2(scrap.Y, scrap.X));
|
||||||
program.Codes.AddRange(leadIn.Generate(entry, normal, fromEnd ? RotationType.CW : RotationType.CCW));
|
program.Codes.AddRange(leadIn.Generate(entry, normal, fromEnd ? RotationType.CW : RotationType.CCW));
|
||||||
program.Codes.AddRange(ConvertShapeToMoves(path, entry, LayerType.Break));
|
program.Codes.AddRange(ConvertShapeToMoves(path, entry, LayerType.Break));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each slug slice is a break cut inside its cutout's slug: pierced on itself, led straight
|
|
||||||
// back along itself to its start, then cut. It has no lead-out and keeps the kerf from
|
|
||||||
// the cutout, so its slug's pieces stay attached until the cutout is cut.
|
|
||||||
private static void EmitSlugSlices(Program program, IReadOnlyList<Support.SlugSlice> slices)
|
|
||||||
{
|
|
||||||
if (slices == null)
|
|
||||||
return;
|
|
||||||
foreach (var slice in slices)
|
|
||||||
{
|
|
||||||
program.Codes.Add(new RapidMove(slice.Pierce));
|
|
||||||
if (slice.Pierce.DistanceTo(slice.Start) > Tolerance.Epsilon)
|
|
||||||
program.Codes.Add(new LinearMove(slice.Start) { Layer = LayerType.Leadin });
|
|
||||||
program.Codes.Add(new LinearMove(slice.End) { Layer = LayerType.Break });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Where a break cut (traveling with its scrap on the left) is entered: the end with more
|
/// Where a break cut (travelling 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>
|
||||||
@@ -1617,78 +1277,6 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
: (start, startScrap, false);
|
: (start, startScrap, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A common-line member's outside around the shared spans earlier members cut (on the Tab
|
|
||||||
/// layer; docs/common-line-cutting.md), each piece in the perimeter's own direction. With a
|
|
||||||
/// common-line entry, the first piece starts there, pierced in scrap with a straight lead-in
|
|
||||||
/// along the shared edge, and runs to the next span; the stretch before the entry is cut
|
|
||||||
/// last and ends at it. Every other piece starts where a shared span ends. It is pierced in that span's opened
|
|
||||||
/// kerf on the centerline, the external lead-in's reach back along the span, and enters along
|
|
||||||
/// a straight lead-in that ends at the piece's start, so compensation starts in the kerf. It
|
|
||||||
/// has no lead-out: it ends in the kerf at the next shared span. Capture checks that every
|
|
||||||
/// lead fits; emission never shortens one.
|
|
||||||
/// </summary>
|
|
||||||
private void EmitCommonLinePerimeter(Program program, Shape perimeter, CuttingPlanning.CommonLinePerimeter commonLine)
|
|
||||||
{
|
|
||||||
var winding = DetermineWinding(perimeter);
|
|
||||||
var leadIn = Parameters?.ExternalLeadIn ?? new NoLeadIn();
|
|
||||||
var entities = perimeter.Entities;
|
|
||||||
var pieces = TabbedCutoutPieces(perimeter);
|
|
||||||
if (commonLine.Entry is { } start && commonLine.Pierce is { } pierce)
|
|
||||||
{
|
|
||||||
// The piece holding the entry is cut from it to its end first, then the pieces after
|
|
||||||
// it in travel order, and its stretch before the entry last.
|
|
||||||
var at = pieces.FindIndex(p => p.Entities.Exists(e => EntityStartPoint(e).DistanceTo(start) <= Tolerance.Epsilon));
|
|
||||||
var piece = pieces[at];
|
|
||||||
var split = piece.Entities.FindIndex(e => EntityStartPoint(e).DistanceTo(start) <= Tolerance.Epsilon);
|
|
||||||
var head = new Shape();
|
|
||||||
head.Entities.AddRange(piece.Entities.Skip(split));
|
|
||||||
program.Codes.Add(new RapidMove(pierce));
|
|
||||||
program.Codes.Add(new Comment(CommonLineCuts.RemainderMarker(winding)));
|
|
||||||
program.Codes.Add(new LinearMove(start) { Layer = LayerType.Leadin });
|
|
||||||
program.Codes.AddRange(ConvertShapeToMoves(head, start));
|
|
||||||
var rest = pieces.Skip(at + 1).Concat(pieces.Take(at)).ToList();
|
|
||||||
if (split > 0)
|
|
||||||
{
|
|
||||||
var tail = new Shape();
|
|
||||||
tail.Entities.AddRange(piece.Entities.Take(split));
|
|
||||||
rest.Add(tail);
|
|
||||||
}
|
|
||||||
pieces = rest;
|
|
||||||
}
|
|
||||||
foreach (var piece in pieces)
|
|
||||||
{
|
|
||||||
var first = piece.Entities[0];
|
|
||||||
var index = entities.IndexOf(first);
|
|
||||||
var span = (Line)entities[(index + entities.Count - 1) % entities.Count];
|
|
||||||
var entry = EntityStartPoint(first);
|
|
||||||
program.Codes.Add(new RapidMove(CommonLineRemainderPierce(span, winding, commonLine.Kerf,
|
|
||||||
CommonLineLeadInReach(leadIn))));
|
|
||||||
// Posts compensate a remainder on its perimeter's side: it encloses nothing itself.
|
|
||||||
program.Codes.Add(new Comment(CommonLineCuts.RemainderMarker(winding)));
|
|
||||||
program.Codes.Add(new LinearMove(entry) { Layer = LayerType.Leadin });
|
|
||||||
program.Codes.AddRange(ConvertShapeToMoves(piece, entry));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The pierce of a common-line remainder entered at the end of <paramref name="span"/> (a
|
|
||||||
/// straight shared span of a perimeter wound <paramref name="winding"/>): on the shared
|
|
||||||
/// centerline, half the <paramref name="kerf"/> outside the span, <paramref name="reach"/>
|
|
||||||
/// back along it from the piece's start.
|
|
||||||
/// </summary>
|
|
||||||
internal static Vector CommonLineRemainderPierce(Line span, RotationType winding, double kerf, double reach)
|
|
||||||
{
|
|
||||||
var along = (span.EndPoint - span.StartPoint).Normalize();
|
|
||||||
// Material lies left of a counterclockwise perimeter's travel, so outside is right.
|
|
||||||
var outward = winding == RotationType.CCW ? new Vector(along.Y, -along.X) : new Vector(-along.Y, along.X);
|
|
||||||
return span.EndPoint - along * reach + outward * (kerf / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>How far a common-line lead-in reaches: the external lead-in's pierce distance.</summary>
|
|
||||||
internal static double CommonLineLeadInReach(LeadIn leadIn) =>
|
|
||||||
leadIn.GetPiercePoint(Vector.Zero, 0, RotationType.CW).DistanceTo(Vector.Zero);
|
|
||||||
|
|
||||||
/// <summary>Whether <paramref name="shape"/> has <see cref="SpecialLayers.Tab"/> spans: a tabbed cutout.</summary>
|
/// <summary>Whether <paramref name="shape"/> has <see cref="SpecialLayers.Tab"/> spans: a tabbed cutout.</summary>
|
||||||
internal static bool IsTabbedCutout(Shape shape) => shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab);
|
internal static bool IsTabbedCutout(Shape shape) => shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab);
|
||||||
|
|
||||||
@@ -1763,7 +1351,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return mirrored;
|
return mirrored;
|
||||||
}
|
}
|
||||||
// Reaching both ways, or tangent to an arc: pierce straight in from the same distance.
|
// Reaching both ways, or tangent to an arc: pierce straight in from the same distance.
|
||||||
var pierce = leadIn.GetPiercePoint(entry, normal, winding);
|
var pierce = leadIn.GetPiercePoint(entry, normal);
|
||||||
return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
|
return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1799,9 +1387,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
private static bool OnArc(Vector center, Vector start, Vector end, Vector point, RotationType rotation)
|
private static bool OnArc(Vector center, Vector start, Vector end, Vector point, RotationType rotation)
|
||||||
{
|
{
|
||||||
var a0 = PortableMath.Atan2(start.Y - center.Y, start.X - center.X);
|
var a0 = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
|
||||||
var a1 = PortableMath.Atan2(end.Y - center.Y, end.X - center.X);
|
var a1 = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
|
||||||
var ap = PortableMath.Atan2(point.Y - center.Y, point.X - center.X);
|
var ap = System.Math.Atan2(point.Y - center.Y, point.X - center.X);
|
||||||
var sign = rotation == RotationType.CCW ? 1 : -1;
|
var sign = rotation == RotationType.CCW ? 1 : -1;
|
||||||
var sweep = Angle.NormalizeRad(sign * (a1 - a0));
|
var sweep = Angle.NormalizeRad(sign * (a1 - a0));
|
||||||
if (sweep <= Tolerance.Epsilon)
|
if (sweep <= Tolerance.Epsilon)
|
||||||
@@ -1858,8 +1446,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
var angle = atEnd ? arc.EndAngle : arc.StartAngle;
|
var angle = atEnd ? arc.EndAngle : arc.StartAngle;
|
||||||
return arc.IsReversed
|
return arc.IsReversed
|
||||||
? new Vector(PortableMath.Sin(angle), -PortableMath.Cos(angle))
|
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
|
||||||
: new Vector(-PortableMath.Sin(angle), PortableMath.Cos(angle));
|
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
|
||||||
}
|
}
|
||||||
var line = (Line)entity;
|
var line = (Line)entity;
|
||||||
return line.EndPoint - line.StartPoint;
|
return line.EndPoint - line.StartPoint;
|
||||||
|
|||||||
@@ -15,14 +15,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
|
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
|
||||||
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
|
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The lead-in of every cutout, full circles included; circles still get their own
|
|
||||||
/// geometry handling (radius clamp, angle rounding) when the lead is emitted.
|
|
||||||
/// </summary>
|
|
||||||
public LeadIn InternalLeadIn { get; set; } =
|
public LeadIn InternalLeadIn { get; set; } =
|
||||||
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
|
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
|
||||||
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
|
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
|
||||||
|
|
||||||
|
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
|
||||||
|
public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut();
|
||||||
|
|
||||||
public double PierceClearance { get; set; } = 0.0625;
|
public double PierceClearance { get; set; } = 0.0625;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -41,78 +40,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public bool RoundLeadInAngles { get; set; }
|
public bool RoundLeadInAngles { get; set; }
|
||||||
public double LeadInAngleIncrement { get; set; } = 5.0;
|
public double LeadInAngleIncrement { get; set; } = 5.0;
|
||||||
|
|
||||||
|
public double AutoTabMinSize { get; set; }
|
||||||
|
public double AutoTabMaxSize { get; set; }
|
||||||
|
|
||||||
public Tab TabConfig { get; set; }
|
public Tab TabConfig { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether a contour is cut with a tab at its entry. The cutting settings never set it for
|
|
||||||
/// every part: Plan Cutting, Place Lead-in and drawing pins set it on the copies of the
|
|
||||||
/// settings they give the parts they tab. Parameters recorded with it on replay as planned.
|
|
||||||
/// </summary>
|
|
||||||
public bool TabsEnabled { get; set; }
|
public bool TabsEnabled { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tabs the parts that need one (see docs/support-tabs.md): with <see cref="TabBySize"/>
|
|
||||||
/// off, each part its plate's support bed cannot hold level once it is cut free, at the
|
|
||||||
/// perimeter point that best keeps its center of gravity over the bed's contacts, or on a
|
|
||||||
/// plate with no bed each part in the size range; with it on, each part in the size range.
|
|
||||||
/// Needs <see cref="TabConfig"/> for the tab size.
|
|
||||||
/// </summary>
|
|
||||||
public bool SupportTabs { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// With <see cref="SupportTabs"/>, ignore the support bed and tab every part whose width is
|
|
||||||
/// in <see cref="TabSizeMinWidth"/> to <see cref="TabSizeMaxWidth"/>
|
|
||||||
/// (<see cref="TabsBySizeAt"/>).
|
|
||||||
/// </summary>
|
|
||||||
public bool TabBySize { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The narrowest part width that is tabbed by size: with <see cref="TabBySize"/>, or with
|
|
||||||
/// <see cref="SupportTabs"/> on a plate that has no support bed (see <see cref="TabsBySizeAt"/>).
|
|
||||||
/// </summary>
|
|
||||||
public double TabSizeMinWidth { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The widest part width that is tabbed by size; 0 turns the rule off, so nothing is
|
|
||||||
/// tabbed by size.
|
|
||||||
/// </summary>
|
|
||||||
public double TabSizeMaxWidth { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Whether <see cref="TabSizeMinWidth"/> to <see cref="TabSizeMaxWidth"/> is a usable range.</summary>
|
|
||||||
public bool HasTabSizeRange() =>
|
|
||||||
TabSizeMaxWidth > 0 && double.IsFinite(TabSizeMaxWidth)
|
|
||||||
&& TabSizeMinWidth >= 0 && TabSizeMinWidth <= TabSizeMaxWidth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether a part <paramref name="width"/> wide (the short side of its minimum-area
|
|
||||||
/// bounding rectangle) is tabbed by size.
|
|
||||||
/// </summary>
|
|
||||||
public bool TabsBySizeAt(double width) =>
|
|
||||||
HasTabSizeRange()
|
|
||||||
&& width >= TabSizeMinWidth - Math.Tolerance.Epsilon
|
|
||||||
&& width <= TabSizeMaxWidth + Math.Tolerance.Epsilon;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// With <see cref="SupportTabs"/> on a plate with a support bed, the longest part (the
|
|
||||||
/// long side of its outline's minimum-area bounding rectangle) its tab alone holds: such a
|
|
||||||
/// part the bed cannot hold level is tabbed, and any tab clear of inside corners steadies
|
|
||||||
/// it. 0 turns the rule off. New settings start at 6; settings saved without it read as 0.
|
|
||||||
/// </summary>
|
|
||||||
public double TabHeldMaxLength { get; set; } = 6;
|
|
||||||
|
|
||||||
/// <summary>Whether a part <paramref name="length"/> long is held by its tab alone (see <see cref="TabHeldMaxLength"/>).</summary>
|
|
||||||
public bool TabHoldsAt(double length) =>
|
|
||||||
TabHeldMaxLength > 0 && double.IsFinite(TabHeldMaxLength)
|
|
||||||
&& length <= TabHeldMaxLength + Math.Tolerance.Epsilon;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Slices the slug of each cutout its plate's support bed cannot hold level along the
|
|
||||||
/// bed's contact lines before the cutout is cut, so the slug drops as pieces between the
|
|
||||||
/// supports instead of tipping (see docs/support-tabs.md#slug-slices). Each slice stops
|
|
||||||
/// <see cref="Kerf"/> short of the cutout, so nothing is sliced without a kerf.
|
|
||||||
/// </summary>
|
|
||||||
public bool SliceTippingSlugs { get; set; }
|
|
||||||
|
|
||||||
public SequenceParameters Sequencing { get; set; } = new SequenceParameters();
|
public SequenceParameters Sequencing { get; set; } = new SequenceParameters();
|
||||||
public AssignmentParameters Assignment { get; set; } = new AssignmentParameters();
|
public AssignmentParameters Assignment { get; set; } = new AssignmentParameters();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Clipper2Lib;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Pierces found by eroding a cutout: the part of it that keeps a clearance from its wall
|
|
||||||
/// (see <see cref="ErodedPierce.Find"/>).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Pierces">The pierce for each target in turn, up to the maximum asked for.</param>
|
|
||||||
/// <param name="Clearance">The least any pierce keeps from the wall, at the erosion level that found it.</param>
|
|
||||||
public sealed record ErodedPierces(IReadOnlyList<Vector> Pierces, double Clearance)
|
|
||||||
{
|
|
||||||
public static ErodedPierces None { get; } = new(System.Array.Empty<Vector>(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The pierce search for cutouts too small for their configured lead-in (text, slots, small
|
|
||||||
/// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
|
|
||||||
/// The erosion goes as deep as the caller asks when the cutout has room for it, else almost
|
|
||||||
/// to its pole of inaccessibility; a round hole is pierced at its center. Each target, an entry the planner ranked, gets the
|
|
||||||
/// nearest point of the deepest erosion that still reaches the wall beside it, stepping down to
|
|
||||||
/// half that depth: a letter stroke is pierced on its own center line next to the entry, and
|
|
||||||
/// only a target with no stroke beside it falls back to the deepest place. The lead then runs
|
|
||||||
/// straight from the pierce to the wall.
|
|
||||||
/// </summary>
|
|
||||||
public static class ErodedPierce
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
|
|
||||||
/// not fit: deep enough to stay near the center line, with room for every place the
|
|
||||||
/// cutout is about as wide as its widest.
|
|
||||||
/// </summary>
|
|
||||||
public const double DepthFraction = 0.9;
|
|
||||||
|
|
||||||
private const int PoleSearchCells = 4096;
|
|
||||||
|
|
||||||
// Shallower erosion levels a target may fall back to, each a tenth of the deepest less.
|
|
||||||
private const int LevelCount = 6;
|
|
||||||
private const double LevelStep = 0.1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A pierce in the closed cutout <paramref name="hole"/> for each of the first
|
|
||||||
/// <paramref name="max"/> <paramref name="targets"/>: the eroded point nearest it. A cutout
|
|
||||||
/// too small to erode is pierced at its pole for every target. Nothing for an open or
|
|
||||||
/// degenerate contour.
|
|
||||||
/// </summary>
|
|
||||||
public static ErodedPierces Find(Shape hole, double depth, IReadOnlyList<Vector> targets, int max)
|
|
||||||
{
|
|
||||||
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|
|
||||||
|| !double.IsFinite(depth))
|
|
||||||
return ErodedPierces.None;
|
|
||||||
|
|
||||||
// The inscribed radius, from the true wall: the deepest any erosion can go.
|
|
||||||
var coarse = ClipperBridge.Flatten(hole, Tolerance(hole.BoundingBox.Width, hole.BoundingBox.Length), false);
|
|
||||||
if (coarse.Vertices.Count < 3)
|
|
||||||
return ErodedPierces.None;
|
|
||||||
var pole = PolyLabel.Find(coarse, null, Tolerance(coarse.BoundingBox.Width, coarse.BoundingBox.Length) / 10,
|
|
||||||
PoleSearchCells);
|
|
||||||
if (!double.IsFinite(pole.X) || !double.IsFinite(pole.Y) || !coarse.ContainsPoint(pole))
|
|
||||||
return ErodedPierces.None;
|
|
||||||
var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
|
|
||||||
if (!(inscribed > 0))
|
|
||||||
return ErodedPierces.None;
|
|
||||||
if (hole.Entities.Count == 1 && hole.Entities[0] is Circle circle)
|
|
||||||
return new(targets.Take(max).Select(_ => circle.Center).ToArray(), circle.Radius);
|
|
||||||
|
|
||||||
// Inscribed flattening can stand off a concave arc, and round joins cut their arcs,
|
|
||||||
// each by the tolerance; Clipper rounds to its precision. Erode past both.
|
|
||||||
depth = System.Math.Min(System.Math.Max(depth, 0), inscribed * DepthFraction);
|
|
||||||
var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision));
|
|
||||||
var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
|
|
||||||
if (depth + margin >= inscribed - tolerance)
|
|
||||||
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
|
|
||||||
|
|
||||||
var polygon = ClipperBridge.Flatten(hole, tolerance, circumscribe: false);
|
|
||||||
var path = new PathsD { ClipperBridge.ToPath(polygon, positive: true) };
|
|
||||||
var levels = new List<PathD>[LevelCount];
|
|
||||||
List<PathD> Level(int level) => levels[level] ??= Clipper.InflatePaths(path,
|
|
||||||
-(Depth(level) + margin), JoinType.Round, EndType.Polygon, 2.0, ClipperBridge.Precision, tolerance)
|
|
||||||
.Where(p => p.Count >= 3).ToList();
|
|
||||||
double Depth(int level) => depth * (1 - level * LevelStep);
|
|
||||||
if (Level(0).Count == 0)
|
|
||||||
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
|
|
||||||
|
|
||||||
// Each target gets the deepest level that reaches the wall beside it: a stroke
|
|
||||||
// narrower than the cutout's widest place is still pierced on its own center line, near
|
|
||||||
// the entry, rather than only at the widest place.
|
|
||||||
var pierces = new List<Vector>();
|
|
||||||
var shallowest = depth;
|
|
||||||
foreach (var target in targets)
|
|
||||||
{
|
|
||||||
var pierce = Nearest(Level(0), target);
|
|
||||||
var reached = 0;
|
|
||||||
for (var level = 0; level < LevelCount; level++)
|
|
||||||
{
|
|
||||||
var region = Level(level);
|
|
||||||
if (region.Count == 0)
|
|
||||||
continue;
|
|
||||||
var nearest = Nearest(region, target);
|
|
||||||
if (nearest.DistanceTo(target) <= 2 * (Depth(level) + margin))
|
|
||||||
{
|
|
||||||
pierce = nearest;
|
|
||||||
reached = level;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pierces.Add(pierce);
|
|
||||||
shallowest = System.Math.Min(shallowest, Depth(reached));
|
|
||||||
if (pierces.Count == max)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return new(pierces, shallowest);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A hundredth of the smaller side: fine enough to find a thin cutout's pole.
|
|
||||||
private static double Tolerance(double width, double length) =>
|
|
||||||
System.Math.Max(System.Math.Min(width, length) / 100, System.Math.Pow(10, -ClipperBridge.Precision));
|
|
||||||
|
|
||||||
// The closest point of the region's boundary; targets lie on the wall, outside it.
|
|
||||||
private static Vector Nearest(List<PathD> paths, Vector target)
|
|
||||||
{
|
|
||||||
var best = default(Vector);
|
|
||||||
var bestDistance = double.PositiveInfinity;
|
|
||||||
foreach (var path in paths)
|
|
||||||
for (var i = 0; i < path.Count; i++)
|
|
||||||
{
|
|
||||||
var a = new Vector(path[i].x, path[i].y);
|
|
||||||
var b = new Vector(path[(i + 1) % path.Count].x, path[(i + 1) % path.Count].y);
|
|
||||||
var point = new Line(a, b).ClosestPointTo(target);
|
|
||||||
var distance = point.DistanceTo(target);
|
|
||||||
if (distance < bestDistance)
|
|
||||||
{
|
|
||||||
bestDistance = distance;
|
|
||||||
best = point;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +1,43 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// A single arc that ends tangent to the cut. Its center lies <see cref="Radius"/> along the
|
|
||||||
/// contour normal and it turns <see cref="SweepAngle"/> degrees in the lead's turn direction:
|
|
||||||
/// 90 is a quarter circle, 180 (the default) a half circle starting two radii out.
|
|
||||||
/// </summary>
|
|
||||||
public class ArcLeadIn : LeadIn
|
public class ArcLeadIn : LeadIn
|
||||||
{
|
{
|
||||||
public const double DefaultSweepAngle = 180.0;
|
|
||||||
|
|
||||||
public double Radius { get; set; }
|
public double Radius { get; set; }
|
||||||
|
|
||||||
/// <summary>Degrees of arc, in (0, 180].</summary>
|
|
||||||
public double SweepAngle { get; set; } = DefaultSweepAngle;
|
|
||||||
|
|
||||||
public override List<ICode> Generate(
|
public override List<ICode> Generate(
|
||||||
Vector contourStartPoint,
|
Vector contourStartPoint,
|
||||||
double contourNormalAngle,
|
double contourNormalAngle,
|
||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||||
|
|
||||||
|
var arcCenter = new Vector(
|
||||||
|
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
|
||||||
|
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
|
||||||
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(GetPiercePoint(contourStartPoint, contourNormalAngle, winding)),
|
new RapidMove(piercePoint),
|
||||||
new ArcMove(contourStartPoint, ArcCenter(contourStartPoint, contourNormalAngle), winding)
|
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||||
{
|
|
||||||
Layer = LayerType.Leadin,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Vector GetPiercePoint(
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||||
Vector contourStartPoint,
|
|
||||||
double contourNormalAngle,
|
|
||||||
RotationType winding = RotationType.CW
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
// The contour point sits opposite the normal from the center; the pierce is the
|
var arcCenterX = contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle);
|
||||||
// sweep back from there, against the turn. A half circle pierces on the normal.
|
var arcCenterY = contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
||||||
var shortfall = Angle.ToRadians(DefaultSweepAngle - SweepAngle);
|
|
||||||
var angle = contourNormalAngle + (winding == RotationType.CCW ? shortfall : -shortfall);
|
|
||||||
var center = ArcCenter(contourStartPoint, contourNormalAngle);
|
|
||||||
return new Vector(
|
return new Vector(
|
||||||
center.X + Radius * PortableMath.Cos(angle),
|
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
|
||||||
center.Y + Radius * PortableMath.Sin(angle)
|
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override LeadIn Scale(double factor) =>
|
public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor };
|
||||||
new ArcLeadIn { Radius = Radius * factor, SweepAngle = SweepAngle };
|
|
||||||
|
|
||||||
private Vector ArcCenter(Vector contourStartPoint, double contourNormalAngle) => new Vector(
|
|
||||||
contourStartPoint.X + Radius * PortableMath.Cos(contourNormalAngle),
|
|
||||||
contourStartPoint.Y + Radius * PortableMath.Sin(contourNormalAngle)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
public class CleanHoleLeadIn : LeadIn
|
public class CleanHoleLeadIn : LeadIn
|
||||||
{
|
{
|
||||||
private const double ApproachAngle = 135.0;
|
|
||||||
|
|
||||||
public double LineLength { get; set; }
|
public double LineLength { get; set; }
|
||||||
public double ArcRadius { get; set; }
|
public double ArcRadius { get; set; }
|
||||||
public double Kerf { get; set; }
|
public double Kerf { get; set; }
|
||||||
@@ -15,11 +14,42 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
Vector contourStartPoint,
|
Vector contourStartPoint,
|
||||||
double contourNormalAngle,
|
double contourNormalAngle,
|
||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
|
)
|
||||||
|
{
|
||||||
|
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
||||||
RotationType winding = RotationType.CW) =>
|
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
||||||
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
|
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
||||||
|
|
||||||
|
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
|
||||||
|
var arcStart = new Vector(
|
||||||
|
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
||||||
|
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new List<ICode>
|
||||||
|
{
|
||||||
|
new RapidMove(piercePoint),
|
||||||
|
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||||
|
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||||
|
{
|
||||||
|
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
||||||
|
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
||||||
|
|
||||||
|
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
|
||||||
|
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
|
||||||
|
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
|
||||||
|
|
||||||
|
return new Vector(
|
||||||
|
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||||
|
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public override LeadIn Scale(double factor) =>
|
public override LeadIn Scale(double factor) =>
|
||||||
new CleanHoleLeadIn
|
new CleanHoleLeadIn
|
||||||
@@ -28,8 +58,5 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
ArcRadius = ArcRadius * factor,
|
ArcRadius = ArcRadius * factor,
|
||||||
Kerf = Kerf,
|
Kerf = Kerf,
|
||||||
};
|
};
|
||||||
|
|
||||||
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
|
|
||||||
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,6 @@ using OpenNest.Geometry;
|
|||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// A lead into a contour point. <c>winding</c> is the direction a lead arc turns to meet the
|
|
||||||
/// cut tangentially: the cut's own direction around the arc center, which lies along the
|
|
||||||
/// normal (see <see cref="ContourCuttingStrategy.LeadTurn"/>). Straight styles ignore it.
|
|
||||||
/// </summary>
|
|
||||||
public abstract class LeadIn
|
public abstract class LeadIn
|
||||||
{
|
{
|
||||||
public abstract List<ICode> Generate(
|
public abstract List<ICode> Generate(
|
||||||
@@ -16,8 +11,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
);
|
);
|
||||||
|
|
||||||
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
|
||||||
RotationType winding = RotationType.CW);
|
|
||||||
|
|
||||||
public virtual LeadIn Scale(double factor) => this;
|
public virtual LeadIn Scale(double factor) => this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A straight line into an arc that ends on the contour start, shared by
|
|
||||||
/// <see cref="LineArcLeadIn"/> and <see cref="CleanHoleLeadIn"/>. The arc's center lies
|
|
||||||
/// the arc radius along the contour normal; the line meets the arc at the approach angle
|
|
||||||
/// (degrees from the normal, on the side the arc turns from) and starts at the pierce point.
|
|
||||||
/// </summary>
|
|
||||||
internal readonly struct LineArcApproach
|
|
||||||
{
|
|
||||||
private LineArcApproach(Vector arcCenter, Vector arcStart, Vector piercePoint)
|
|
||||||
{
|
|
||||||
ArcCenter = arcCenter;
|
|
||||||
ArcStart = arcStart;
|
|
||||||
PiercePoint = piercePoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Vector ArcCenter { get; }
|
|
||||||
|
|
||||||
public Vector ArcStart { get; }
|
|
||||||
|
|
||||||
public Vector PiercePoint { get; }
|
|
||||||
|
|
||||||
public static LineArcApproach Create(Vector contourStartPoint, double contourNormalAngle,
|
|
||||||
double arcRadius, double approachAngle, double lineLength, RotationType winding)
|
|
||||||
{
|
|
||||||
var arcCenterX = contourStartPoint.X + arcRadius * PortableMath.Cos(contourNormalAngle);
|
|
||||||
var arcCenterY = contourStartPoint.Y + arcRadius * PortableMath.Sin(contourNormalAngle);
|
|
||||||
|
|
||||||
// The approach angle is measured back from the normal against the turn, so the arc
|
|
||||||
// runs the short way round to the contour point whichever way it turns.
|
|
||||||
var approach = Angle.ToRadians(approachAngle);
|
|
||||||
var lineAngle = contourNormalAngle + (winding == RotationType.CCW ? approach : -approach);
|
|
||||||
var arcStartX = arcCenterX + arcRadius * PortableMath.Cos(lineAngle);
|
|
||||||
var arcStartY = arcCenterY + arcRadius * PortableMath.Sin(lineAngle);
|
|
||||||
|
|
||||||
return new LineArcApproach(
|
|
||||||
new Vector(arcCenterX, arcCenterY),
|
|
||||||
new Vector(arcStartX, arcStartY),
|
|
||||||
new Vector(
|
|
||||||
arcStartX + lineLength * PortableMath.Cos(lineAngle),
|
|
||||||
arcStartY + lineLength * PortableMath.Sin(lineAngle)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Rapid to the pierce point, then the line and arc on the lead-in layer.</summary>
|
|
||||||
public List<ICode> ToCodes(Vector contourStartPoint, RotationType winding) =>
|
|
||||||
new List<ICode>
|
|
||||||
{
|
|
||||||
new RapidMove(PiercePoint),
|
|
||||||
new LinearMove(ArcStart) { Layer = LayerType.Leadin },
|
|
||||||
new ArcMove(contourStartPoint, ArcCenter, winding) { Layer = LayerType.Leadin },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -13,11 +14,42 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
Vector contourStartPoint,
|
Vector contourStartPoint,
|
||||||
double contourNormalAngle,
|
double contourNormalAngle,
|
||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
|
)
|
||||||
|
{
|
||||||
|
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
||||||
RotationType winding = RotationType.CW) =>
|
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
||||||
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
|
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
||||||
|
|
||||||
|
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
||||||
|
var arcStart = new Vector(
|
||||||
|
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
||||||
|
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new List<ICode>
|
||||||
|
{
|
||||||
|
new RapidMove(piercePoint),
|
||||||
|
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||||
|
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||||
|
{
|
||||||
|
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
||||||
|
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
||||||
|
|
||||||
|
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
||||||
|
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
|
||||||
|
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
|
||||||
|
|
||||||
|
return new Vector(
|
||||||
|
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||||
|
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public override LeadIn Scale(double factor) =>
|
public override LeadIn Scale(double factor) =>
|
||||||
new LineArcLeadIn
|
new LineArcLeadIn
|
||||||
@@ -26,8 +58,5 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
ArcRadius = ArcRadius * factor,
|
ArcRadius = ArcRadius * factor,
|
||||||
ApproachAngle = ApproachAngle,
|
ApproachAngle = ApproachAngle,
|
||||||
};
|
};
|
||||||
|
|
||||||
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
|
|
||||||
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,13 +24,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||||
RotationType winding = RotationType.CW)
|
|
||||||
{
|
{
|
||||||
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
||||||
return new Vector(
|
return new Vector(
|
||||||
contourStartPoint.X + Length * PortableMath.Cos(approachAngle),
|
contourStartPoint.X + Length * System.Math.Cos(approachAngle),
|
||||||
contourStartPoint.Y + Length * PortableMath.Sin(approachAngle)
|
contourStartPoint.Y + Length * System.Math.Sin(approachAngle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||||
var midPoint = new Vector(
|
var midPoint = new Vector(
|
||||||
contourStartPoint.X + Length2 * PortableMath.Cos(secondAngle),
|
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
|
||||||
contourStartPoint.Y + Length2 * PortableMath.Sin(secondAngle)
|
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle)
|
||||||
);
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
@@ -33,17 +33,16 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||||
RotationType winding = RotationType.CW)
|
|
||||||
{
|
{
|
||||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||||
var midX = contourStartPoint.X + Length2 * PortableMath.Cos(secondAngle);
|
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
|
||||||
var midY = contourStartPoint.Y + Length2 * PortableMath.Sin(secondAngle);
|
var midY = contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle);
|
||||||
|
|
||||||
var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2);
|
var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2);
|
||||||
return new Vector(
|
return new Vector(
|
||||||
midX + Length1 * PortableMath.Cos(firstAngle),
|
midX + Length1 * System.Math.Cos(firstAngle),
|
||||||
midY + Length1 * PortableMath.Sin(firstAngle)
|
midY + Length1 * System.Math.Sin(firstAngle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new List<ICode> { new RapidMove(contourStartPoint) };
|
return new List<ICode> { new RapidMove(contourStartPoint) };
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||||
RotationType winding = RotationType.CW)
|
|
||||||
{
|
{
|
||||||
return contourStartPoint;
|
return contourStartPoint;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// A quarter circle leaving the cut tangentially, turning <c>winding</c> about a center
|
|
||||||
/// <see cref="Radius"/> along the contour normal.
|
|
||||||
/// </summary>
|
|
||||||
public class ArcLeadOut : LeadOut
|
public class ArcLeadOut : LeadOut
|
||||||
{
|
{
|
||||||
public double Radius { get; set; }
|
public double Radius { get; set; }
|
||||||
@@ -18,16 +13,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var arcCenterX = contourEndPoint.X + Radius * PortableMath.Cos(contourNormalAngle);
|
var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle);
|
||||||
var arcCenterY = contourEndPoint.Y + Radius * PortableMath.Sin(contourNormalAngle);
|
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
||||||
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
||||||
|
|
||||||
// The contour point sits opposite the normal from the center; turn a quarter on.
|
|
||||||
var endAngle = contourNormalAngle
|
|
||||||
+ (winding == RotationType.CCW ? -System.Math.PI / 2 : System.Math.PI / 2);
|
|
||||||
var endPoint = new Vector(
|
var endPoint = new Vector(
|
||||||
arcCenterX + Radius * PortableMath.Cos(endAngle),
|
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2),
|
||||||
arcCenterY + Radius * PortableMath.Sin(endAngle)
|
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)
|
||||||
);
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
|
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
|
||||||
var endPoint = new Vector(
|
var endPoint = new Vector(
|
||||||
contourEndPoint.X + Length * PortableMath.Cos(overcutAngle),
|
contourEndPoint.X + Length * System.Math.Cos(overcutAngle),
|
||||||
contourEndPoint.Y + Length * PortableMath.Sin(overcutAngle)
|
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle)
|
||||||
);
|
);
|
||||||
|
|
||||||
return new List<ICode> { new LinearMove(endPoint) { Layer = LayerType.Leadout } };
|
return new List<ICode> { new LinearMove(endPoint) { Layer = LayerType.Leadout } };
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Remembers <see cref="ContourCuttingStrategy.ResolvePierceZone"/> for one preparation's
|
|
||||||
/// contours. The resolution reads only the cutout's own geometry, the entry, the configured
|
|
||||||
/// lead-in and the zone, so for a given contour it is a pure function of those, yet the
|
|
||||||
/// search emits every contour of a part's chain again on every expansion, and on a cutout
|
|
||||||
/// whose configured pierce is inside the zone each resolution flattens the cutout, finds its
|
|
||||||
/// pole and erodes it. One memo serves every emission of one <c>PreparedContours</c> and
|
|
||||||
/// its copies, which share the contours by ordinal. A lead-in a memo returns is shared and
|
|
||||||
/// must not be mutated.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class PierceZoneMemo
|
|
||||||
{
|
|
||||||
private readonly Dictionary<Key, LeadIn> resolved = new();
|
|
||||||
private readonly object gate = new();
|
|
||||||
|
|
||||||
/// <summary>How many resolutions the memo answered without computing; for tests.</summary>
|
|
||||||
internal int Hits { get; private set; }
|
|
||||||
|
|
||||||
internal LeadIn Resolve(int contour, Shape hole, Vector point, LeadIn leadIn, double normal,
|
|
||||||
double pierceRadius, RotationType turn)
|
|
||||||
{
|
|
||||||
if (KeyOf(leadIn) is not { } lead)
|
|
||||||
return ContourCuttingStrategy.ResolvePierceZone(hole, point, leadIn, normal, pierceRadius, turn);
|
|
||||||
var key = new Key(contour, point.X, point.Y, normal, pierceRadius, turn, lead);
|
|
||||||
lock (gate)
|
|
||||||
{
|
|
||||||
if (resolved.TryGetValue(key, out var known))
|
|
||||||
{
|
|
||||||
Hits++;
|
|
||||||
return known;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var result = ContourCuttingStrategy.ResolvePierceZone(hole, point, leadIn, normal, pierceRadius, turn);
|
|
||||||
lock (gate)
|
|
||||||
{
|
|
||||||
resolved[key] = result;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The configured lead-in by value: an equal lead-in resolves the same way whichever
|
|
||||||
// instance carries it. A lead-in type this does not know is resolved every time.
|
|
||||||
private static LeadKey? KeyOf(LeadIn leadIn) => leadIn switch
|
|
||||||
{
|
|
||||||
NoLeadIn => new(0, 0, 0, 0, 0),
|
|
||||||
LineLeadIn line => new(1, line.Length, line.ApproachAngle, 0, 0),
|
|
||||||
ArcLeadIn arc => new(2, arc.Radius, arc.SweepAngle, 0, 0),
|
|
||||||
LineArcLeadIn lineArc => new(3, lineArc.LineLength, lineArc.ApproachAngle, lineArc.ArcRadius, 0),
|
|
||||||
CleanHoleLeadIn cleanHole => new(4, cleanHole.LineLength, cleanHole.ArcRadius, cleanHole.Kerf, 0),
|
|
||||||
LineLineLeadIn lineLine => new(5, lineLine.Length1, lineLine.ApproachAngle1, lineLine.Length2, lineLine.ApproachAngle2),
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly record struct LeadKey(int Kind, double A, double B, double C, double D);
|
|
||||||
|
|
||||||
private readonly record struct Key(int Contour, double X, double Y, double Normal, double PierceRadius,
|
|
||||||
RotationType Turn, LeadKey Lead);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -24,8 +23,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
var scoreAngle = contourNormalAngle + System.Math.PI;
|
var scoreAngle = contourNormalAngle + System.Math.PI;
|
||||||
var scoreEnd = new Vector(
|
var scoreEnd = new Vector(
|
||||||
tabStartPoint.X + BreakerDepth * PortableMath.Cos(scoreAngle),
|
tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle),
|
||||||
tabStartPoint.Y + BreakerDepth * PortableMath.Sin(scoreAngle)
|
tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle)
|
||||||
);
|
);
|
||||||
codes.Add(new LinearMove(scoreEnd));
|
codes.Add(new LinearMove(scoreEnd));
|
||||||
codes.Add(new RapidMove(tabEndPoint));
|
codes.Add(new RapidMove(tabEndPoint));
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The never-cut outline of a supplied blank, kept at the end of an applied cutting program.
|
|
||||||
/// An applied program for a blank with a <see cref="LayerType.DisplayPerimeter"/> outline cuts
|
|
||||||
/// only its cutouts; the outline follows them after <see cref="Marker"/> so the part keeps the
|
|
||||||
/// blank's extent for bounds, overlap and cut-offs. Nothing that executes or posts a program may
|
|
||||||
/// run the tail: executed reads stop at the marker and posts take <see cref="ForPosting"/>.
|
|
||||||
/// </summary>
|
|
||||||
public static class DisplayPerimeterTail
|
|
||||||
{
|
|
||||||
/// <summary>The comment that starts the tail; every code after it is display only.</summary>
|
|
||||||
public const string Marker = "DISPLAY PERIMETER NEVER CUT";
|
|
||||||
|
|
||||||
/// <summary>Why the manual and legacy lead-in tools leave a display-perimeter part alone.</summary>
|
|
||||||
public const string LeadInRefusal =
|
|
||||||
"This part's outline is a display perimeter: a supplied blank that is never cut. "
|
|
||||||
+ "Lead-ins for its cutouts come from Plan Cutting, which keeps every pierce inside a cutout.";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The index of the tail's marker in <paramref name="program"/>'s codes, or -1. A marker that is
|
|
||||||
/// repeated, sits in a sub-program, or is followed by anything but one rapid and moves on the
|
|
||||||
/// display perimeter layer is a malformed program (<see cref="ArgumentException"/>): cuts after
|
|
||||||
/// it must never be dropped silently.
|
|
||||||
/// </summary>
|
|
||||||
public static int IndexOf(Program program)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(program);
|
|
||||||
var marker = -1;
|
|
||||||
for (var i = 0; i < program.Codes.Count; i++)
|
|
||||||
{
|
|
||||||
if (program.Codes[i] is Comment { Value: Marker })
|
|
||||||
{
|
|
||||||
if (marker >= 0)
|
|
||||||
throw new ArgumentException("A program has more than one display perimeter tail.");
|
|
||||||
marker = i;
|
|
||||||
}
|
|
||||||
else if (program.Codes[i] is SubProgramCall call
|
|
||||||
&& HasMarker(call.Program, new HashSet<Program>(ReferenceEqualityComparer.Instance)))
|
|
||||||
throw new ArgumentException("A display perimeter tail can only end the part's own program, not a sub-program.");
|
|
||||||
}
|
|
||||||
if (marker < 0)
|
|
||||||
return -1;
|
|
||||||
var tail = program.Codes.Count - marker - 1;
|
|
||||||
if (tail < 2 || program.Codes[marker + 1] is not RapidMove
|
|
||||||
|| !program.Codes.Skip(marker + 2).All(code =>
|
|
||||||
code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter }))
|
|
||||||
throw new ArgumentException(
|
|
||||||
"A display perimeter tail must be one rapid followed only by the outline on the display perimeter layer.");
|
|
||||||
return marker;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool HasMarker(Program program, HashSet<Program> visited)
|
|
||||||
{
|
|
||||||
if (program?.Codes == null || !visited.Add(program))
|
|
||||||
return false;
|
|
||||||
foreach (var code in program.Codes)
|
|
||||||
if (code is Comment { Value: Marker } || code is SubProgramCall call && HasMarker(call.Program, visited))
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool HasTail(Program program) => IndexOf(program) >= 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Appends the outline after the marker: a rapid to its start, then its entities on the
|
|
||||||
/// display perimeter layer. <paramref name="program"/> must still be in absolute mode.
|
|
||||||
/// </summary>
|
|
||||||
internal static void Append(Program program, IReadOnlyList<ICode> outline, Vector start)
|
|
||||||
{
|
|
||||||
if (program.Mode != Mode.Absolute)
|
|
||||||
throw new InvalidOperationException("Append the display tail before switching to incremental mode.");
|
|
||||||
program.Codes.Add(new Comment(Marker));
|
|
||||||
program.Codes.Add(new RapidMove(start));
|
|
||||||
program.Codes.AddRange(outline);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The executed program for estimates and drawing: <paramref name="program"/> itself when it
|
|
||||||
/// has no well-formed tail, otherwise a shallow copy of the codes before the marker. Posts
|
|
||||||
/// and verification use the strict <see cref="ForPosting"/> and executed reads instead.
|
|
||||||
/// </summary>
|
|
||||||
public static Program WithoutTail(Program program)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var marker = IndexOf(program);
|
|
||||||
return marker < 0 ? program : program.HeadCopy(marker);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Whether <paramref name="program"/> or any sub-program it calls moves on the display perimeter layer.</summary>
|
|
||||||
public static bool HasDisplayPerimeterMotion(Program program) =>
|
|
||||||
HasDisplayPerimeterMotion(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
|
|
||||||
|
|
||||||
private static bool HasDisplayPerimeterMotion(Program program, HashSet<Program> visited)
|
|
||||||
{
|
|
||||||
if (program?.Codes == null || !visited.Add(program))
|
|
||||||
return false;
|
|
||||||
foreach (var code in program.Codes)
|
|
||||||
{
|
|
||||||
if (code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter })
|
|
||||||
return true;
|
|
||||||
if (code is SubProgramCall call && HasDisplayPerimeterMotion(call.Program, visited))
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The program a post may write for a part: its tail removed. A display perimeter anywhere
|
|
||||||
/// else means the part was never planned, and posting it would cut the blank's outline.
|
|
||||||
/// </summary>
|
|
||||||
public static Program ForPosting(Program program, string partName)
|
|
||||||
{
|
|
||||||
int marker;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
marker = IndexOf(program);
|
|
||||||
}
|
|
||||||
catch (ArgumentException ex)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"{partName} cannot be posted: {ex.Message}", ex);
|
|
||||||
}
|
|
||||||
var executed = marker < 0 ? program : program.HeadCopy(marker);
|
|
||||||
if (HasDisplayPerimeterMotion(executed))
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"{partName} has a display perimeter (a supplied blank's outline that is never cut) but no "
|
|
||||||
+ "cutting plan. Plan cutting first (Plate > Plan Cutting..., or --plan-cutting) so only its cutouts are cut.");
|
|
||||||
return executed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The name posts give a part in a refusal.</summary>
|
|
||||||
public static string PartName(Part part) => $"Part '{part.BaseDrawing?.Name}'";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Refuses a nest a post cannot write, before any output is created: every part's program
|
|
||||||
/// must pass <see cref="ForPosting"/>, so a refusal never leaves an empty or partial file.
|
|
||||||
/// </summary>
|
|
||||||
public static void EnsurePostable(Nest nest)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(nest);
|
|
||||||
foreach (var plate in nest.Plates)
|
|
||||||
foreach (var part in plate.Parts)
|
|
||||||
if (part?.Program != null && part.BaseDrawing?.IsCutOff != true)
|
|
||||||
ForPosting(part.Program, PartName(part));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The head-height lock around a cut-off's overcut past the nominal far sheet edge. Stock can
|
|
||||||
/// arrive wider than nominal, so a cut-off overcuts past the far edge to cut the oversize off;
|
|
||||||
/// past the real edge there is no material to sense, so the head height is locked first. A
|
|
||||||
/// cut-off program marks the locked stretch with a <see cref="Lock"/> comment and ends it with an
|
|
||||||
/// <see cref="Unlock"/> comment; a rapid between them keeps the head locked and is not a head
|
|
||||||
/// raise. Posts write each marker as their machine's lock or unlock code and refuse to post a
|
|
||||||
/// marked program when no code is configured, because the overcut is unsafe without the lock.
|
|
||||||
/// See docs/automatic-scrap-cutoffs.md.
|
|
||||||
/// </summary>
|
|
||||||
public static class HeadHeightLock
|
|
||||||
{
|
|
||||||
/// <summary>The comment before the first motion made with the head height locked.</summary>
|
|
||||||
public const string Lock = "HEAD HEIGHT LOCK";
|
|
||||||
|
|
||||||
/// <summary>The comment after the last motion made with the head height locked.</summary>
|
|
||||||
public const string Unlock = "HEAD HEIGHT UNLOCK";
|
|
||||||
|
|
||||||
public static bool IsLock(ICode code) => code is Comment { Value: Lock };
|
|
||||||
|
|
||||||
public static bool IsUnlock(ICode code) => code is Comment { Value: Unlock };
|
|
||||||
|
|
||||||
/// <summary>Whether <paramref name="program"/>'s own codes hold a lock marker.</summary>
|
|
||||||
public static bool IsMarked(Program program)
|
|
||||||
{
|
|
||||||
if (program?.Codes == null)
|
|
||||||
return false;
|
|
||||||
foreach (var code in program.Codes)
|
|
||||||
if (IsLock(code))
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For each of <paramref name="codes"/>, whether the head height is locked while it runs: the
|
|
||||||
/// codes after a lock marker up to its unlock marker. Markers must alternate, starting with a
|
|
||||||
/// lock and ending unlocked; anything else is a malformed program (<see cref="ArgumentException"/>),
|
|
||||||
/// since a post that lost track of the lock could cut off the sheet unlocked or never unlock.
|
|
||||||
/// </summary>
|
|
||||||
public static bool[] LockedMask(IReadOnlyList<ICode> codes)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(codes);
|
|
||||||
var mask = new bool[codes.Count];
|
|
||||||
var locked = false;
|
|
||||||
for (var i = 0; i < codes.Count; i++)
|
|
||||||
{
|
|
||||||
if (IsLock(codes[i]))
|
|
||||||
{
|
|
||||||
if (locked)
|
|
||||||
throw new ArgumentException("A head-height lock is set twice without an unlock.");
|
|
||||||
locked = true;
|
|
||||||
}
|
|
||||||
else if (IsUnlock(codes[i]))
|
|
||||||
{
|
|
||||||
if (!locked)
|
|
||||||
throw new ArgumentException("A head-height unlock has no lock before it.");
|
|
||||||
locked = false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
mask[i] = locked;
|
|
||||||
}
|
|
||||||
if (locked)
|
|
||||||
throw new ArgumentException("A head-height lock is never released.");
|
|
||||||
return mask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked
|
|
||||||
/// overcut needs the post's lock and unlock codes, and its markers must be well formed.
|
|
||||||
/// </summary>
|
|
||||||
public static void EnsurePostable(Nest nest, bool hasCodes, string postName) =>
|
|
||||||
PostMarkers.EnsurePostable(nest, hasCodes, IsMarked, program => LockedMask(program.Codes),
|
|
||||||
name => $"{name} overcuts past the sheet edge with the head height locked, but the "
|
|
||||||
+ $"{postName} post has no head-height lock and unlock codes. Enter them in "
|
|
||||||
+ $"\"Head height lock\" and \"Head height unlock\" in the {postName} Settings "
|
|
||||||
+ "dialog, which opens when you post, or delete the cut-off and add it again "
|
|
||||||
+ "with \"Lock head height and overcut the far edge\" unchecked.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A rapid the head must make raised because it passes over parts already cut, which may have
|
|
||||||
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing, and always
|
|
||||||
/// marks the rapid out of a cutout's slug after its lead-out (see docs/cutting-planner.md): a
|
|
||||||
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>, or before the
|
|
||||||
/// <see cref="SubProgramCall"/> whose arrival rapid it is, since a sub-program may be shared and
|
|
||||||
/// is never marked itself. Rapid checks accept a crossing on a marked rapid. Posts write their machine's head raise before every marked rapid,
|
|
||||||
/// and refuse to post a marked program when they have none, since the rapid is unsafe head down.
|
|
||||||
/// See docs/cutting-planner.md.
|
|
||||||
/// </summary>
|
|
||||||
public static class HeadRaise
|
|
||||||
{
|
|
||||||
/// <summary>The comment immediately before a rapid made with the head raised.</summary>
|
|
||||||
public const string Marker = "HEAD RAISE";
|
|
||||||
|
|
||||||
public static bool IsMarker(ICode code) => code is Comment { Value: Marker };
|
|
||||||
|
|
||||||
/// <summary>Whether <paramref name="program"/>'s own codes hold a marker.</summary>
|
|
||||||
public static bool IsMarked(Program program)
|
|
||||||
{
|
|
||||||
if (program?.Codes == null)
|
|
||||||
return false;
|
|
||||||
foreach (var code in program.Codes)
|
|
||||||
if (IsMarker(code))
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For each of <paramref name="codes"/>, whether it is a rapid made with the head raised, or a
|
|
||||||
/// sub-program call whose arrival rapid is. A marker must be followed directly by a rapid or
|
|
||||||
/// a call; anything else is a malformed program (<see cref="ArgumentException"/>), since a
|
|
||||||
/// post could raise before the wrong move.
|
|
||||||
/// </summary>
|
|
||||||
public static bool[] RaisedMask(IReadOnlyList<ICode> codes)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(codes);
|
|
||||||
var mask = new bool[codes.Count];
|
|
||||||
for (var i = 0; i < codes.Count; i++)
|
|
||||||
{
|
|
||||||
if (!IsMarker(codes[i]))
|
|
||||||
continue;
|
|
||||||
if (i + 1 >= codes.Count || codes[i + 1] is not (RapidMove or SubProgramCall))
|
|
||||||
throw new ArgumentException("A head-raise marker is not followed by a rapid.");
|
|
||||||
mask[i + 1] = true;
|
|
||||||
}
|
|
||||||
return mask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A copy of <paramref name="program"/> with a marker before each of its own codes at
|
|
||||||
/// <paramref name="rapids"/>, which must be rapids or sub-program calls not marked already.
|
|
||||||
/// </summary>
|
|
||||||
public static Program Mark(Program program, IReadOnlyCollection<int> rapids)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(program);
|
|
||||||
ArgumentNullException.ThrowIfNull(rapids);
|
|
||||||
var marked = RaisedMask(program.Codes);
|
|
||||||
var copy = (Program)program.Clone();
|
|
||||||
for (var i = copy.Codes.Count - 1; i >= 0; i--)
|
|
||||||
{
|
|
||||||
if (!rapids.Contains(i))
|
|
||||||
continue;
|
|
||||||
if (copy.Codes[i] is not (RapidMove or SubProgramCall) || marked[i])
|
|
||||||
throw new ArgumentException("Only an unmarked rapid or sub-program call can be marked for a head raise.");
|
|
||||||
copy.Codes.Insert(i, new Comment(Marker));
|
|
||||||
}
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Refuses a nest a post cannot write, before any output is created: a marked rapid needs
|
|
||||||
/// the post's head-raise code, and its markers must be well formed.
|
|
||||||
/// </summary>
|
|
||||||
public static void EnsurePostable(Nest nest, bool hasCode, string postName) =>
|
|
||||||
PostMarkers.EnsurePostable(nest, hasCode, IsMarked, program => RaisedMask(program.Codes),
|
|
||||||
name => $"{name} has rapids that must raise the head over parts already cut, but "
|
|
||||||
+ $"the {postName} post has no head-raise code. Enter it in \"Head raise\" in "
|
|
||||||
+ $"the {postName} Settings dialog, which opens when you post.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,22 +21,5 @@ namespace OpenNest.CNC
|
|||||||
/// leaves it out, cutting the rest of the contour as open pieces.
|
/// leaves it out, cutting the rest of the contour as open pieces.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Tab,
|
Tab,
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The outside edge of a supplied blank that is only shown, never cut (for example a
|
|
||||||
/// purchased door whose instrument cutouts are the only cuts). It bounds material like
|
|
||||||
/// <see cref="Cut"/>, so nesting, containment, lead clearance and cut-offs all see the
|
|
||||||
/// blank, but it has no pierce, lead or motion in any emitted program. It must be one whole
|
|
||||||
/// closed contour enclosing every other contour.
|
|
||||||
/// </summary>
|
|
||||||
DisplayPerimeter,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A shared cut of a common-line group (see docs/common-line-cutting.md): an open cut on the
|
|
||||||
/// centerline between two parts placed one kerf apart, which cuts both parts' facing edges
|
|
||||||
/// at once. It is cut without kerf compensation, bounds no material and is never a rapid
|
|
||||||
/// obstacle. Posts that cannot write it refuse the nest.
|
|
||||||
/// </summary>
|
|
||||||
CommonLine,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.Posting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Which posted contour is the perimeter, and which compensation side keeps the kerf in the
|
|
||||||
/// scrap. Imported drawings are wound perimeter CW and holes CCW
|
|
||||||
/// (<see cref="ShapeProfile.NormalizeWinding"/>), which puts the part right of travel and gives
|
|
||||||
/// G41 throughout; a contour wound the other way (built in code or edited by hand) needs G42.
|
|
||||||
/// Paths that enclose no area (open break cuts) get G41, a defensive default.
|
|
||||||
/// </summary>
|
|
||||||
public static class ContourWinding
|
|
||||||
{
|
|
||||||
/// <summary>Signed shoelace area of the cut path; negative when clockwise, 0 when degenerate.</summary>
|
|
||||||
public static double SignedArea(PostContour contour)
|
|
||||||
{
|
|
||||||
var pts = SamplePoints(contour);
|
|
||||||
if (pts.Count < 3)
|
|
||||||
return 0.0;
|
|
||||||
|
|
||||||
var sum = 0.0;
|
|
||||||
for (var i = 0; i < pts.Count; i++)
|
|
||||||
{
|
|
||||||
var a = pts[i];
|
|
||||||
var b = pts[(i + 1) % pts.Count];
|
|
||||||
sum += a.X * b.Y - b.X * a.Y;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sum / 2.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Index of the perimeter: the material contour enclosing the largest bounding box, as
|
|
||||||
/// <see cref="ShapeProfile"/> picks it. -1 when none encloses any area.
|
|
||||||
/// </summary>
|
|
||||||
public static int PerimeterIndex(IReadOnlyList<PostContour> contours)
|
|
||||||
{
|
|
||||||
var best = -1;
|
|
||||||
var bestArea = 0.0;
|
|
||||||
for (var i = 0; i < contours.Count; i++)
|
|
||||||
{
|
|
||||||
if (contours[i].IsMark || !EnclosesArea(contours[i]))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var area = BoundingBoxArea(SamplePoints(contours[i]));
|
|
||||||
if (best < 0 || area > bestArea)
|
|
||||||
{
|
|
||||||
best = i;
|
|
||||||
bestArea = area;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <see cref="KerfType.Left"/> (G41, part right of travel) for a CW perimeter, a CCW hole or
|
|
||||||
/// a path enclosing no area; otherwise <see cref="KerfType.Right"/> (G42).
|
|
||||||
/// </summary>
|
|
||||||
public static KerfType CompensationSide(PostContour contour, bool isPerimeter)
|
|
||||||
{
|
|
||||||
if (!EnclosesArea(contour))
|
|
||||||
return KerfType.Left;
|
|
||||||
|
|
||||||
var clockwise = SignedArea(contour) < 0.0;
|
|
||||||
return clockwise == isPerimeter ? KerfType.Left : KerfType.Right;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool EnclosesArea(PostContour contour) =>
|
|
||||||
System.Math.Abs(SignedArea(contour)) > 1e-9;
|
|
||||||
|
|
||||||
private static double BoundingBoxArea(List<Vector> pts)
|
|
||||||
{
|
|
||||||
double minX = double.MaxValue, minY = double.MaxValue;
|
|
||||||
double maxX = double.MinValue, maxY = double.MinValue;
|
|
||||||
foreach (var p in pts)
|
|
||||||
{
|
|
||||||
minX = System.Math.Min(minX, p.X);
|
|
||||||
minY = System.Math.Min(minY, p.Y);
|
|
||||||
maxX = System.Math.Max(maxX, p.X);
|
|
||||||
maxY = System.Math.Max(maxY, p.Y);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (maxX - minX) * (maxY - minY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ordered points along the path: the contour start, then each cut endpoint, with arc
|
|
||||||
/// interior samples at no more than 90-degree steps so full circles give a well-formed
|
|
||||||
/// polygon. Consecutive duplicates are dropped so zero-length lead-ins do not skew it.
|
|
||||||
/// </summary>
|
|
||||||
private static List<Vector> SamplePoints(PostContour contour)
|
|
||||||
{
|
|
||||||
var pts = new List<Vector>();
|
|
||||||
|
|
||||||
void Add(Vector v)
|
|
||||||
{
|
|
||||||
if (pts.Count == 0 || pts[^1].DistanceTo(v) > 1e-9)
|
|
||||||
pts.Add(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
var prev = contour.LeadIn != null ? contour.LeadIn.EndPoint : contour.Pierce;
|
|
||||||
Add(prev);
|
|
||||||
|
|
||||||
foreach (var cut in contour.Cuts)
|
|
||||||
{
|
|
||||||
if (cut is ArcMove arc)
|
|
||||||
{
|
|
||||||
foreach (var mid in ArcSamplePoints(prev, arc))
|
|
||||||
Add(mid);
|
|
||||||
}
|
|
||||||
Add(cut.EndPoint);
|
|
||||||
prev = cut.EndPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Interior points on the arc from <paramref name="prev"/> to its endpoint at no more than
|
|
||||||
/// ~90-degree steps. A full circle yields four, so its shoelace sign is its true winding.
|
|
||||||
/// </summary>
|
|
||||||
private static List<Vector> ArcSamplePoints(Vector prev, ArcMove arc)
|
|
||||||
{
|
|
||||||
var points = new List<Vector>();
|
|
||||||
var center = arc.CenterPoint;
|
|
||||||
var radius = center.DistanceTo(arc.EndPoint);
|
|
||||||
if (radius < 1e-12)
|
|
||||||
return points;
|
|
||||||
|
|
||||||
var a0 = PortableMath.Atan2(prev.Y - center.Y, prev.X - center.X);
|
|
||||||
var a1 = PortableMath.Atan2(arc.EndPoint.Y - center.Y, arc.EndPoint.X - center.X);
|
|
||||||
|
|
||||||
var sweep = arc.Rotation == RotationType.CW ? a0 - a1 : a1 - a0;
|
|
||||||
if (sweep <= 0)
|
|
||||||
sweep += 2.0 * System.Math.PI;
|
|
||||||
|
|
||||||
// Full circle (start == end) reads as zero sweep above.
|
|
||||||
if (
|
|
||||||
sweep < 1e-9
|
|
||||||
&& System.Math.Abs(prev.X - arc.EndPoint.X) < 1e-9
|
|
||||||
&& System.Math.Abs(prev.Y - arc.EndPoint.Y) < 1e-9
|
|
||||||
)
|
|
||||||
sweep = 2.0 * System.Math.PI;
|
|
||||||
|
|
||||||
var direction = arc.Rotation == RotationType.CW ? -1.0 : 1.0;
|
|
||||||
var steps = System.Math.Max(1, (int)System.Math.Ceiling(sweep / (System.Math.PI / 2.0)));
|
|
||||||
|
|
||||||
for (var k = 1; k <= steps; k++)
|
|
||||||
{
|
|
||||||
var angle = a0 + direction * sweep * k / (steps + 1);
|
|
||||||
points.Add(
|
|
||||||
new Vector(
|
|
||||||
center.X + radius * PortableMath.Cos(angle),
|
|
||||||
center.Y + radius * PortableMath.Sin(angle)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return points;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.Posting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One contour of a posted part: pierce, lead-in and cut path in a single absolute frame
|
|
||||||
/// (sheet coordinates from <see cref="PostContours.Extract(Part)"/>). Arc moves keep their
|
|
||||||
/// absolute centres; each post formats I/J its own way.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class PostContour
|
|
||||||
{
|
|
||||||
/// <summary>Pierce point: the rapid target.</summary>
|
|
||||||
public Vector Pierce { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// First lead-in move, ending on the contour, or null when the contour starts cutting at
|
|
||||||
/// the pierce.
|
|
||||||
/// </summary>
|
|
||||||
public Motion LeadIn { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Lead-in moves after the first (e.g. the arc after a clean-hole line).</summary>
|
|
||||||
public List<Motion> LeadInExtra { get; } = new();
|
|
||||||
|
|
||||||
/// <summary>Moves made with the cut layer on: cut, lead-out, display and scribe.</summary>
|
|
||||||
public List<Motion> Cuts { get; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Head-height lock changes in a cut-off's overcut: each comes before the cut at
|
|
||||||
/// <c>Before</c> (<see cref="Cuts"/>.Count: after the last cut), locking when <c>Lock</c>.
|
|
||||||
/// </summary>
|
|
||||||
public List<(int Before, bool Lock)> HeadLocks { get; } = new();
|
|
||||||
|
|
||||||
/// <summary>Whether the rapid to the pierce raises the head over parts already cut (<see cref="HeadRaise"/>).</summary>
|
|
||||||
public bool Raised { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True for a common line's shared cut (<see cref="LayerType.CommonLine"/>): an open
|
|
||||||
/// centerline cut made without kerf compensation.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsCommonLine { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For a common-line remainder, the winding of the perimeter it belongs to
|
|
||||||
/// (<see cref="CommonLineCuts"/>): it is cut as the perimeter, compensated on that side.
|
|
||||||
/// Null for every other contour.
|
|
||||||
/// </summary>
|
|
||||||
public RotationType? RemainderWinding { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True for the part's outside edge, or a common-line remainder of it; false for holes,
|
|
||||||
/// marks, shared cuts and cut-offs.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsPerimeter { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The compensation that keeps the kerf in the scrap: <see cref="KerfType.Left"/> (G41)
|
|
||||||
/// leaves the part right of travel, <see cref="KerfType.Right"/> (G42) left of it.
|
|
||||||
/// <see cref="KerfType.None"/> for marks, common-line shared cuts and cut-offs, which have
|
|
||||||
/// no inside.
|
|
||||||
/// </summary>
|
|
||||||
public KerfType Kerf { get; set; }
|
|
||||||
|
|
||||||
/// <summary>True when every cut is on the scribe layer: a mark, not material.</summary>
|
|
||||||
public bool IsMark
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (Cuts.Count == 0)
|
|
||||||
return false;
|
|
||||||
foreach (var cut in Cuts)
|
|
||||||
{
|
|
||||||
if (PostContours.LayerOf(cut) != LayerType.Scribe)
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.Posting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reduces a part program to the contours a post writes: hole calls and incremental programs
|
|
||||||
/// flattened to one absolute frame, each contour split into pierce, lead-in and cuts, the
|
|
||||||
/// perimeter picked out and the kerf side chosen. Posts format these; they do not redo the
|
|
||||||
/// geometry. Caller programs are never modified.
|
|
||||||
/// </summary>
|
|
||||||
public static class PostContours
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The part's contours in sheet coordinates. A display perimeter's tail is never cut and an
|
|
||||||
/// unplanned one is refused (<see cref="DisplayPerimeterTail.ForPosting"/>). Cut-off contours
|
|
||||||
/// are open lines on the beam centreline: no perimeter and no kerf.
|
|
||||||
/// </summary>
|
|
||||||
public static List<PostContour> Extract(Part part)
|
|
||||||
{
|
|
||||||
if (part == null)
|
|
||||||
throw new ArgumentNullException(nameof(part));
|
|
||||||
|
|
||||||
var program = DisplayPerimeterTail.ForPosting(part.Program, DisplayPerimeterTail.PartName(part));
|
|
||||||
var contours = Extract(program, part.Location);
|
|
||||||
if (part.BaseDrawing?.IsCutOff == true)
|
|
||||||
{
|
|
||||||
foreach (var contour in contours)
|
|
||||||
{
|
|
||||||
contour.IsPerimeter = false;
|
|
||||||
contour.Kerf = KerfType.None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return contours;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The program's contours with <paramref name="origin"/> added to every point: the part
|
|
||||||
/// location for sheet coordinates, <see cref="Vector.Zero"/> for the part's own frame.
|
|
||||||
/// </summary>
|
|
||||||
public static List<PostContour> Extract(Program program, Vector origin)
|
|
||||||
{
|
|
||||||
var contours = Flatten(program, origin);
|
|
||||||
Classify(contours);
|
|
||||||
return contours;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The program's contours in the <paramref name="origin"/> frame, with no perimeter or kerf
|
|
||||||
/// side chosen yet. A post that splits one part across several calls gathers them here and
|
|
||||||
/// classifies the whole part with <see cref="Classify"/>.
|
|
||||||
/// </summary>
|
|
||||||
public static List<PostContour> Flatten(Program program, Vector origin)
|
|
||||||
{
|
|
||||||
var contours = new List<PostContour>();
|
|
||||||
FlattenInto(program, origin, contours);
|
|
||||||
return contours;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Picks one part's perimeter (marks excluded) and each contour's kerf side. A common-line
|
|
||||||
/// member's outside is its remainders, each compensated on its perimeter's side, so its
|
|
||||||
/// closed contours are all holes; a shared cut has no kerf side.
|
|
||||||
/// </summary>
|
|
||||||
public static void Classify(IReadOnlyList<PostContour> contours)
|
|
||||||
{
|
|
||||||
var remainders = false;
|
|
||||||
foreach (var contour in contours)
|
|
||||||
remainders |= contour.RemainderWinding != null;
|
|
||||||
|
|
||||||
var perimeter = remainders ? -1 : ContourWinding.PerimeterIndex(contours);
|
|
||||||
for (var i = 0; i < contours.Count; i++)
|
|
||||||
{
|
|
||||||
var contour = contours[i];
|
|
||||||
if (contour.RemainderWinding is { } winding)
|
|
||||||
{
|
|
||||||
// G41 leaves the product right of travel: a clockwise perimeter's side.
|
|
||||||
contour.IsPerimeter = true;
|
|
||||||
contour.Kerf = winding == RotationType.CW ? KerfType.Left : KerfType.Right;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
contour.IsPerimeter = i == perimeter;
|
|
||||||
contour.Kerf = contour.IsMark || contour.IsCommonLine
|
|
||||||
? KerfType.None
|
|
||||||
: ContourWinding.CompensationSide(contour, contour.IsPerimeter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Walks one program whose coordinates are relative to <paramref name="origin"/>; hole calls
|
|
||||||
/// recurse with their offset added. A contour starts at each rapid. Moves before the first
|
|
||||||
/// rapid start at the frame origin, where an absolute program begins. A cut after a run of
|
|
||||||
/// suppressed moves starts a new contour pierced where the run ends, so the gap is crossed
|
|
||||||
/// with the beam off rather than cut as a chord. A head-raise marker raises the head for
|
|
||||||
/// the next contour's rapid; <paramref name="raiseFirst"/> carries a marker before the hole
|
|
||||||
/// call that runs this program.
|
|
||||||
/// </summary>
|
|
||||||
private static void FlattenInto(Program program, Vector origin, List<PostContour> contours,
|
|
||||||
bool raiseFirst = false)
|
|
||||||
{
|
|
||||||
if (program == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var codes = program.Codes;
|
|
||||||
if (program.Mode == Mode.Incremental)
|
|
||||||
{
|
|
||||||
var clone = (Program)program.Clone();
|
|
||||||
clone.Mode = Mode.Absolute;
|
|
||||||
codes = clone.Codes;
|
|
||||||
}
|
|
||||||
|
|
||||||
PostContour current = null;
|
|
||||||
Vector? resumeAt = null;
|
|
||||||
var raise = raiseFirst;
|
|
||||||
|
|
||||||
PostContour Start(Vector pierce)
|
|
||||||
{
|
|
||||||
var contour = new PostContour { Pierce = pierce, Raised = raise };
|
|
||||||
raise = false;
|
|
||||||
return contour;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Flush()
|
|
||||||
{
|
|
||||||
if (current != null && (current.LeadIn != null || current.Cuts.Count > 0))
|
|
||||||
contours.Add(current);
|
|
||||||
current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var code in codes)
|
|
||||||
{
|
|
||||||
if (code is Motion { Suppressed: true } suppressed)
|
|
||||||
{
|
|
||||||
resumeAt = suppressed.EndPoint;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (HeadRaise.IsMarker(code))
|
|
||||||
{
|
|
||||||
raise = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CommonLineCuts.RemainderWinding(code) is { } winding)
|
|
||||||
{
|
|
||||||
current ??= Start(origin);
|
|
||||||
current.RemainderWinding = winding;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (HeadHeightLock.IsLock(code) || HeadHeightLock.IsUnlock(code))
|
|
||||||
{
|
|
||||||
current ??= Start(origin);
|
|
||||||
current.HeadLocks.Add((current.Cuts.Count, HeadHeightLock.IsLock(code)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (code)
|
|
||||||
{
|
|
||||||
case RapidMove rapid:
|
|
||||||
Flush();
|
|
||||||
resumeAt = null;
|
|
||||||
current = Start(rapid.EndPoint + origin);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SubProgramCall call:
|
|
||||||
// A hole call is its own contour.
|
|
||||||
Flush();
|
|
||||||
resumeAt = null;
|
|
||||||
FlattenInto(call.Program, origin + call.Offset, contours, raise);
|
|
||||||
raise = false;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Motion motion:
|
|
||||||
if (resumeAt.HasValue)
|
|
||||||
{
|
|
||||||
Flush();
|
|
||||||
current = Start(resumeAt.Value + origin);
|
|
||||||
resumeAt = null;
|
|
||||||
}
|
|
||||||
current ??= Start(origin);
|
|
||||||
|
|
||||||
var absolute = (Motion)motion.Clone();
|
|
||||||
absolute.Offset(origin);
|
|
||||||
|
|
||||||
var layer = LayerOf(motion);
|
|
||||||
if (layer == LayerType.Leadin)
|
|
||||||
{
|
|
||||||
if (current.LeadIn == null)
|
|
||||||
current.LeadIn = absolute;
|
|
||||||
else
|
|
||||||
current.LeadInExtra.Add(absolute);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Contour moves are tagged Display by default and still cut.
|
|
||||||
current.Cuts.Add(absolute);
|
|
||||||
if (layer == LayerType.CommonLine)
|
|
||||||
current.IsCommonLine = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static LayerType LayerOf(Motion motion) =>
|
|
||||||
motion switch
|
|
||||||
{
|
|
||||||
LinearMove linear => linear.Layer,
|
|
||||||
ArcMove arc => arc.Layer,
|
|
||||||
_ => LayerType.Cut,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -37,16 +37,6 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
public double Rotation { get; protected set; }
|
public double Rotation { get; protected set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True once <see cref="Part.CloneAtOffset"/> has handed this instance to a second
|
|
||||||
/// part. A part must copy a shared program before changing it in place, whether or not
|
|
||||||
/// it owns it. Never cleared: the parts sharing it are not tracked, so a program that
|
|
||||||
/// was shared once is treated as shared for good, at the cost of one copy. It belongs
|
|
||||||
/// to the instance, not its content: <see cref="Clone"/> and <see cref="HeadCopy"/>
|
|
||||||
/// build a new program and leave it unset.
|
|
||||||
/// </summary>
|
|
||||||
internal bool SharedByParts { get; set; }
|
|
||||||
|
|
||||||
private void SetModeInc()
|
private void SetModeInc()
|
||||||
{
|
{
|
||||||
if (mode == Mode.Incremental)
|
if (mode == Mode.Incremental)
|
||||||
@@ -113,8 +103,8 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
if (subpgm.Offset.X != 0 || subpgm.Offset.Y != 0)
|
if (subpgm.Offset.X != 0 || subpgm.Offset.Y != 0)
|
||||||
{
|
{
|
||||||
var cos = PortableMath.Cos(angle);
|
var cos = System.Math.Cos(angle);
|
||||||
var sin = PortableMath.Sin(angle);
|
var sin = System.Math.Sin(angle);
|
||||||
var dx = subpgm.Offset.X - origin.X;
|
var dx = subpgm.Offset.X - origin.X;
|
||||||
var dy = subpgm.Offset.Y - origin.Y;
|
var dy = subpgm.Offset.Y - origin.Y;
|
||||||
subpgm.Offset = new Geometry.Vector(
|
subpgm.Offset = new Geometry.Vector(
|
||||||
@@ -168,7 +158,35 @@ namespace OpenNest.CNC
|
|||||||
SetModeInc();
|
SetModeInc();
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual void Offset(Vector voffset) => Offset(voffset.X, voffset.Y);
|
public virtual void Offset(Vector voffset)
|
||||||
|
{
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
@@ -490,21 +508,6 @@ namespace OpenNest.CNC
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A shallow copy holding only the first <paramref name="count"/> codes: the same
|
|
||||||
/// instruction, variable and sub-program instances, mode and rotation.
|
|
||||||
/// </summary>
|
|
||||||
internal Program HeadCopy(int count)
|
|
||||||
{
|
|
||||||
var head = new Program() { mode = this.mode, Rotation = this.Rotation };
|
|
||||||
head.Codes.AddRange(Codes.GetRange(0, count));
|
|
||||||
foreach (var kvp in Variables)
|
|
||||||
head.Variables[kvp.Key] = kvp.Value;
|
|
||||||
foreach (var kvp in SubPrograms)
|
|
||||||
head.SubPrograms[kvp.Key] = kvp.Value;
|
|
||||||
return head;
|
|
||||||
}
|
|
||||||
|
|
||||||
public object Clone()
|
public object Clone()
|
||||||
{
|
{
|
||||||
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
|
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
|
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
|
||||||
{
|
{
|
||||||
// The head never travels to a display perimeter's tail.
|
|
||||||
pgm = DisplayPerimeterTail.WithoutTail(pgm);
|
|
||||||
// Draw the approach to the first rapid destination, or to the program
|
// Draw the approach to the first rapid destination, or to the program
|
||||||
// origin when it starts cutting without a rapid. Only an initial rapid
|
// origin when it starts cutting without a rapid. Only an initial rapid
|
||||||
// (or call arrival) is already represented by this approach; a later
|
// (or call arrival) is already represented by this approach; a later
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest
|
namespace OpenNest
|
||||||
{
|
{
|
||||||
@@ -124,7 +123,7 @@ namespace OpenNest
|
|||||||
// keeps offsets lying exactly on an axis (mirror-symmetric parts) away from the
|
// keeps offsets lying exactly on an axis (mirror-symmetric parts) away from the
|
||||||
// interval edge so floating-point noise cannot flip the choice.
|
// interval edge so floating-point noise cannot flip the choice.
|
||||||
var halfPi = System.Math.PI / 2.0;
|
var halfPi = System.Math.PI / 2.0;
|
||||||
var direction = PortableMath.Atan2(dy, dx);
|
var direction = System.Math.Atan2(dy, dx);
|
||||||
for (var turns = 0; turns < 4; turns++)
|
for (var turns = 0; turns < 4; turns++)
|
||||||
{
|
{
|
||||||
var relative = direction + turns * halfPi - (System.Math.PI - PreferenceMargin);
|
var relative = direction + turns * halfPi - (System.Math.PI - PreferenceMargin);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace OpenNest.Clamps
|
namespace OpenNest
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The sheet edge a <see cref="ClampRail"/> clamp sits on, named relative to the plate
|
/// The sheet edge a <see cref="ClampRail"/> clamp sits on, named relative to the plate
|
||||||
@@ -3,7 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Clamps
|
namespace OpenNest
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The machine's fixed clamp rail: identical rectangular clamps, each on one sheet edge
|
/// The machine's fixed clamp rail: identical rectangular clamps, each on one sheet edge
|
||||||
@@ -26,16 +26,14 @@ namespace OpenNest.Clamps
|
|||||||
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
|
|
||||||
)
|
)
|
||||||
{ }
|
{ }
|
||||||
|
|
||||||
@@ -43,8 +41,7 @@ namespace OpenNest.Clamps
|
|||||||
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);
|
||||||
@@ -63,11 +60,6 @@ namespace OpenNest.Clamps
|
|||||||
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)
|
||||||
@@ -85,7 +77,6 @@ namespace OpenNest.Clamps
|
|||||||
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();
|
||||||
}
|
}
|
||||||
@@ -102,14 +93,6 @@ namespace OpenNest.Clamps
|
|||||||
/// </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
|
||||||
@@ -120,9 +103,9 @@ namespace OpenNest.Clamps
|
|||||||
/// <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, removal time and clearance and these clamps.</summary>
|
/// <summary>A copy with the same size and removal time and these clamps.</summary>
|
||||||
public ClampRail WithClamps(IEnumerable<RailClamp> clamps) =>
|
public ClampRail WithClamps(IEnumerable<RailClamp> clamps) =>
|
||||||
new(Width, Depth, clamps, RemovalMinutes, Clearance);
|
new(Width, Depth, clamps, RemovalMinutes);
|
||||||
|
|
||||||
/// <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}";
|
||||||
@@ -242,22 +225,20 @@ namespace OpenNest.Clamps
|
|||||||
&& 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, Clearance, Clamps.Count);
|
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, 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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Clamps
|
namespace OpenNest
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A fixed rectangular keep-out region in plate coordinates (the sheet's own frame:
|
/// A fixed rectangular keep-out region in plate coordinates (the sheet's own frame:
|
||||||
@@ -2,7 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Clamps;
|
namespace OpenNest;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a plate's <see cref="Plate.ClampZones"/>, stored in the sheet's lower-left frame
|
/// Maps a plate's <see cref="Plate.ClampZones"/>, stored in the sheet's lower-left frame
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using OpenNest.CNC;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.CommonLines
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// One shared cut of a common-line group: a straight span, or spans chained at corners (a Z or
|
|
||||||
/// L shared line). <see cref="Owner"/>, the member of the pair cut first, cuts it as part of its
|
|
||||||
/// own compensated perimeter, which lands on the kerf centerline. <see cref="Partner"/> is cut
|
|
||||||
/// later; its remainder ends in the kerf this cut opened.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record CommonLineSharedCut(CommonLinePath Path, Part Owner, Part Partner);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How a common-line group is cut once (see docs/common-line-cutting.md): its members in cutting
|
|
||||||
/// order, and each shared cut with the member that cuts it.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record CommonLineCutOrder(CommonLineGroup Group, IReadOnlyList<Part> Members,
|
|
||||||
IReadOnlyList<CommonLineSharedCut> Cuts)
|
|
||||||
{
|
|
||||||
/// <summary>The shared cuts <paramref name="member"/> cuts.</summary>
|
|
||||||
public IEnumerable<CommonLineSharedCut> CutsOwnedBy(Part member) =>
|
|
||||||
Cuts.Where(cut => ReferenceEquals(cut.Owner, member));
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The shared paths an earlier member cuts and <paramref name="member"/> therefore never
|
|
||||||
/// does: its remainder ends in their kerf.
|
|
||||||
/// </summary>
|
|
||||||
public IEnumerable<CommonLinePath> PathsCutBefore(Part member) => Cuts
|
|
||||||
.Where(cut => ReferenceEquals(cut.Partner, member))
|
|
||||||
.Select(cut => cut.Path);
|
|
||||||
|
|
||||||
/// <summary>Every shared path <paramref name="member"/> shares, whoever cuts it.</summary>
|
|
||||||
public IEnumerable<CommonLinePath> PathsOf(Part member) => Cuts
|
|
||||||
.Where(cut => ReferenceEquals(cut.Owner, member) || ReferenceEquals(cut.Partner, member))
|
|
||||||
.Select(cut => cut.Path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Single-pass common-line cutting: which member cuts each shared span, and the planner's owned
|
|
||||||
/// clean perimeter with its shared spans marked as never cut by that member.
|
|
||||||
/// </summary>
|
|
||||||
public static class CommonLineCutting
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The cutting order of an intact group: members breadth-first from its first member along
|
|
||||||
/// shared spans (ties in member order), each shared cut owned by the member cut first. A
|
|
||||||
/// member's remainder therefore only ever ends in kerf cut before it.
|
|
||||||
/// </summary>
|
|
||||||
/// <exception cref="ArgumentException">The group is not intact.</exception>
|
|
||||||
public static CommonLineCutOrder Order(CommonLineGroup group, CommonLineCheck check)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(group);
|
|
||||||
ArgumentNullException.ThrowIfNull(check);
|
|
||||||
if (!check.IsIntact)
|
|
||||||
throw new ArgumentException(check.Problems[0].Message, nameof(check));
|
|
||||||
var members = group.Members;
|
|
||||||
int IndexOf(Part part) => members.ToList().FindIndex(member => ReferenceEquals(member, part));
|
|
||||||
var order = new List<Part>();
|
|
||||||
var seen = new bool[members.Count];
|
|
||||||
var queue = new Queue<int>();
|
|
||||||
queue.Enqueue(0);
|
|
||||||
seen[0] = true;
|
|
||||||
while (queue.Count > 0)
|
|
||||||
{
|
|
||||||
var current = members[queue.Dequeue()];
|
|
||||||
order.Add(current);
|
|
||||||
var neighbors = check.Spans
|
|
||||||
.Select(span => ReferenceEquals(span.First, current) ? span.Second
|
|
||||||
: ReferenceEquals(span.Second, current) ? span.First : null)
|
|
||||||
.Where(part => part != null)
|
|
||||||
.Select(IndexOf)
|
|
||||||
.Distinct()
|
|
||||||
.Order();
|
|
||||||
foreach (var next in neighbors)
|
|
||||||
if (!seen[next])
|
|
||||||
{
|
|
||||||
seen[next] = true;
|
|
||||||
queue.Enqueue(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (order.Count != members.Count)
|
|
||||||
throw new ArgumentException("The common-line group has split into separate pieces.", nameof(check));
|
|
||||||
int Rank(Part part) => order.FindIndex(member => ReferenceEquals(member, part));
|
|
||||||
var cuts = CommonLineGeometry.Paths(check.Spans, group.Kerf)
|
|
||||||
.Select(path => Rank(path.First) < Rank(path.Second)
|
|
||||||
? new CommonLineSharedCut(path, path.First, path.Second)
|
|
||||||
: new CommonLineSharedCut(path, path.Second, path.First))
|
|
||||||
.OrderBy(cut => Rank(cut.Owner))
|
|
||||||
.ToList();
|
|
||||||
return new CommonLineCutOrder(group, order, cuts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <see cref="MarkSharedSpans(Program, Vector, IReadOnlyList{CommonLinePath}, double)"/> with
|
|
||||||
/// every span a straight path of its own.
|
|
||||||
/// </summary>
|
|
||||||
public static Program MarkSharedSpans(Program clean, Vector location, IReadOnlyList<CommonLineSpan> spans,
|
|
||||||
double kerf)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(spans);
|
|
||||||
return MarkSharedSpans(clean, location, spans
|
|
||||||
.Select(span => new CommonLinePath(span.First, span.Second, [span], [span.Start, span.End]))
|
|
||||||
.ToArray(), kerf);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A copy of <paramref name="clean"/> (a member's clean program, rotated as placed, in part
|
|
||||||
/// coordinates; placed at <paramref name="location"/>) whose perimeter edges are split at
|
|
||||||
/// every end of the shared <paramref name="paths"/> and whose shared portions are on the
|
|
||||||
/// <see cref="LayerType.Tab"/> layer: material that this member never cuts, because the
|
|
||||||
/// shared cut does. At a path's corner each leg's portion runs to the member's own vertex
|
|
||||||
/// there, so the legs' portions join. The result is absolute. Only straight top-level
|
|
||||||
/// moves can share.
|
|
||||||
/// </summary>
|
|
||||||
/// <exception cref="ArgumentException">A path's leg does not lie along the program's edges.</exception>
|
|
||||||
public static Program MarkSharedSpans(Program clean, Vector location, IReadOnlyList<CommonLinePath> paths,
|
|
||||||
double kerf)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(clean);
|
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
|
||||||
if (!double.IsFinite(kerf) || kerf <= 0)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(kerf));
|
|
||||||
var program = (Program)clean.Clone();
|
|
||||||
program.Mode = Mode.Absolute;
|
|
||||||
var legs = paths.SelectMany(path => Legs(path, location, kerf)).ToArray();
|
|
||||||
var covered = new double[legs.Length];
|
|
||||||
var codes = new List<ICode>();
|
|
||||||
Vector? position = null;
|
|
||||||
foreach (var code in program.Codes)
|
|
||||||
{
|
|
||||||
if (code is LinearMove line && position is { } start
|
|
||||||
&& line.Layer is LayerType.Cut or LayerType.Display)
|
|
||||||
{
|
|
||||||
codes.AddRange(Split(line, start, legs, covered, kerf));
|
|
||||||
position = line.EndPoint;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
codes.Add(code);
|
|
||||||
position = code switch
|
|
||||||
{
|
|
||||||
Motion motion => motion.EndPoint,
|
|
||||||
SubProgramCall => null,
|
|
||||||
_ => position,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
for (var i = 0; i < legs.Length; i++)
|
|
||||||
{
|
|
||||||
var leg = legs[i];
|
|
||||||
// At a corner the member's portion ends at its own vertex, up to the snap off the centerline's.
|
|
||||||
var slack = leg.StartSnap + leg.EndSnap + CommonLineGeometry.GapTolerance;
|
|
||||||
if (covered[i] <= Math.Tolerance.Epsilon
|
|
||||||
|| System.Math.Abs(covered[i] - leg.Start.DistanceTo(leg.End)) > slack)
|
|
||||||
{
|
|
||||||
var (a, b) = (leg.Start + location, leg.End + location);
|
|
||||||
throw new ArgumentException(FormattableString.Invariant(
|
|
||||||
$"The shared span from ({a.X:0.####}, {a.Y:0.####}) to ({b.X:0.####}, {b.Y:0.####}) does not lie along one straight edge of the part's program."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
program.Codes.Clear();
|
|
||||||
program.Codes.AddRange(codes);
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Where a member that cuts <paramref name="paths"/> as part of its own perimeter can start,
|
|
||||||
/// so that each common line leads straight into the rest of the perimeter: for each path,
|
|
||||||
/// the start of the perimeter edge (a top-level straight move of <paramref name="clean"/>)
|
|
||||||
/// along which the member's own travel enters the path, a pierce <paramref name="reach"/>
|
|
||||||
/// back along that edge's axis, and the edge's end. Part coordinates (the program placed at
|
|
||||||
/// <paramref name="location"/>), in path order.
|
|
||||||
/// </summary>
|
|
||||||
public static IReadOnlyList<(Vector Entry, Vector Pierce, Vector Next)> PerimeterEntries(Program clean,
|
|
||||||
Vector location, IReadOnlyList<CommonLinePath> paths, double kerf, double reach)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(clean);
|
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
|
||||||
if (!double.IsFinite(kerf) || kerf <= 0)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(kerf));
|
|
||||||
var program = (Program)clean.Clone();
|
|
||||||
program.Mode = Mode.Absolute;
|
|
||||||
var edges = new List<(Vector Start, Vector End)>();
|
|
||||||
Vector? position = null;
|
|
||||||
foreach (var code in program.Codes)
|
|
||||||
{
|
|
||||||
if (code is LinearMove line && position is { } from && line.Layer is LayerType.Cut or LayerType.Display)
|
|
||||||
edges.Add((from, line.EndPoint));
|
|
||||||
position = code switch
|
|
||||||
{
|
|
||||||
Motion motion => motion.EndPoint,
|
|
||||||
SubProgramCall => null,
|
|
||||||
_ => position,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
var entries = new List<(Vector Entry, Vector Pierce, Vector Next)>();
|
|
||||||
foreach (var path in paths)
|
|
||||||
{
|
|
||||||
var points = path.Points.Select(point => point - location).ToArray();
|
|
||||||
// Each end of the path, with the direction into the path from there.
|
|
||||||
foreach (var (end, inward) in new[]
|
|
||||||
{
|
|
||||||
(points[0], (points[1] - points[0]).Normalize()),
|
|
||||||
(points[^1], (points[^2] - points[^1]).Normalize()),
|
|
||||||
})
|
|
||||||
foreach (var (start, stop) in edges)
|
|
||||||
{
|
|
||||||
var length = start.DistanceTo(stop);
|
|
||||||
if (length <= Math.Tolerance.Epsilon)
|
|
||||||
continue;
|
|
||||||
var along = new Vector((stop.X - start.X) / length, (stop.Y - start.Y) / length);
|
|
||||||
if (System.Math.Abs(Cross(along, inward)) > 1e-6 || Dot(along, inward) <= 0)
|
|
||||||
continue;
|
|
||||||
if (System.Math.Abs(System.Math.Abs(Cross(along, end - start)) - kerf / 2) > CommonLineGeometry.GapTolerance)
|
|
||||||
continue;
|
|
||||||
var t = Dot(end - start, along);
|
|
||||||
if (t < -CommonLineGeometry.GapTolerance || t > length + CommonLineGeometry.GapTolerance)
|
|
||||||
continue;
|
|
||||||
var entry = (start, start - along * reach, stop);
|
|
||||||
if (!entries.Contains(entry))
|
|
||||||
entries.Add(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One straight leg of a shared path in part coordinates. An end at a corner snaps to the
|
|
||||||
// edge's own vertex within kerf/2 · tan(turn/2) of the centerline corner's projection.
|
|
||||||
private readonly record struct Leg(Vector Start, Vector End, double StartSnap, double EndSnap);
|
|
||||||
|
|
||||||
private static IEnumerable<Leg> Legs(CommonLinePath path, Vector location, double kerf)
|
|
||||||
{
|
|
||||||
var points = path.Points.Select(point => point - location).ToArray();
|
|
||||||
var snaps = new double[points.Length];
|
|
||||||
for (var k = 1; k < points.Length - 1; k++)
|
|
||||||
{
|
|
||||||
var din = (points[k] - points[k - 1]).Normalize();
|
|
||||||
var dout = (points[k + 1] - points[k]).Normalize();
|
|
||||||
var turn = PortableMath.Atan2(System.Math.Abs(Cross(din, dout)), Dot(din, dout));
|
|
||||||
snaps[k] = kerf / 2 * PortableMath.Tan(turn / 2) + 2 * CommonLineGeometry.GapTolerance;
|
|
||||||
}
|
|
||||||
for (var k = 0; k + 1 < points.Length; k++)
|
|
||||||
yield return new Leg(points[k], points[k + 1], snaps[k], snaps[k + 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The line split where shared legs one half kerf off it begin and end; shared pieces on Tab.
|
|
||||||
private static IEnumerable<ICode> Split(LinearMove line, Vector start, Leg[] legs, double[] covered,
|
|
||||||
double kerf)
|
|
||||||
{
|
|
||||||
var end = line.EndPoint;
|
|
||||||
var length = start.DistanceTo(end);
|
|
||||||
if (length <= Math.Tolerance.Epsilon)
|
|
||||||
return [line];
|
|
||||||
var direction = new Vector((end.X - start.X) / length, (end.Y - start.Y) / length);
|
|
||||||
var intervals = new List<(double From, double To)>();
|
|
||||||
for (var i = 0; i < legs.Length; i++)
|
|
||||||
{
|
|
||||||
var (a, b, snapA, snapB) = legs[i];
|
|
||||||
var legLength = a.DistanceTo(b);
|
|
||||||
if (legLength <= Math.Tolerance.Epsilon)
|
|
||||||
continue;
|
|
||||||
var along = new Vector((b.X - a.X) / legLength, (b.Y - a.Y) / legLength);
|
|
||||||
if (System.Math.Abs(Cross(direction, along)) > 1e-6)
|
|
||||||
continue;
|
|
||||||
var offset = System.Math.Abs(Cross(direction, a - start));
|
|
||||||
if (System.Math.Abs(offset - kerf / 2) > CommonLineGeometry.GapTolerance)
|
|
||||||
continue;
|
|
||||||
var ta = Snap(Dot(a - start, direction), snapA, length);
|
|
||||||
var tb = Snap(Dot(b - start, direction), snapB, length);
|
|
||||||
var from = System.Math.Max(0, System.Math.Min(ta, tb));
|
|
||||||
var to = System.Math.Min(length, System.Math.Max(ta, tb));
|
|
||||||
if (to - from <= Math.Tolerance.Epsilon)
|
|
||||||
continue;
|
|
||||||
// An end within the snap tolerance of a vertex is that vertex: no sliver is left uncut.
|
|
||||||
if (from <= Math.Tolerance.Epsilon)
|
|
||||||
from = 0;
|
|
||||||
if (length - to <= Math.Tolerance.Epsilon)
|
|
||||||
to = length;
|
|
||||||
covered[i] += to - from;
|
|
||||||
intervals.Add((from, to));
|
|
||||||
}
|
|
||||||
if (intervals.Count == 0)
|
|
||||||
return [line];
|
|
||||||
var pieces = new List<ICode>();
|
|
||||||
var at = 0.0;
|
|
||||||
foreach (var (from, to) in intervals.OrderBy(interval => interval.From))
|
|
||||||
{
|
|
||||||
if (from < at - Math.Tolerance.Epsilon)
|
|
||||||
throw new ArgumentException("Shared spans overlap along one edge.");
|
|
||||||
if (from - at > Math.Tolerance.Epsilon)
|
|
||||||
pieces.Add(new LinearMove(Point(from)) { Layer = line.Layer });
|
|
||||||
pieces.Add(new LinearMove(to >= length ? end : Point(to)) { Layer = LayerType.Tab });
|
|
||||||
at = to;
|
|
||||||
}
|
|
||||||
if (length - at > Math.Tolerance.Epsilon)
|
|
||||||
pieces.Add(new LinearMove(end) { Layer = line.Layer });
|
|
||||||
else if (pieces[^1] is LinearMove last)
|
|
||||||
last.EndPoint = end;
|
|
||||||
return pieces;
|
|
||||||
|
|
||||||
Vector Point(double t) => new(start.X + direction.X * t, start.Y + direction.Y * t);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A corner end within snap of either end of the edge is that end.
|
|
||||||
private static double Snap(double t, double snap, double length)
|
|
||||||
{
|
|
||||||
if (snap <= 0)
|
|
||||||
return t;
|
|
||||||
if (System.Math.Abs(t) <= snap)
|
|
||||||
return 0;
|
|
||||||
if (System.Math.Abs(t - length) <= snap)
|
|
||||||
return length;
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
|
||||||
|
|
||||||
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loaded 100 of 1517 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user