mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-30 17:41:06 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e3be68c11 | ||
|
|
4d55e11318 | ||
|
|
2a128c5a11 | ||
|
|
667beb4334 | ||
|
|
bb6bdc8213 | ||
|
|
b550640ef0 | ||
|
|
4028dafb26 | ||
|
|
7d9f900633 | ||
|
|
27665c0ded | ||
|
|
2c1a8eef42 | ||
|
|
47f9593a41 | ||
|
|
e76864b489 | ||
|
|
7a7b671408 | ||
|
|
d80002e832 | ||
|
|
508b7db0df | ||
|
|
4ca8b0debf | ||
|
|
889e33d179 | ||
|
|
1c2bf16b3e | ||
|
|
6ca9a1243c | ||
|
|
c313d9699e | ||
|
|
73a02cf309 | ||
|
|
cce2585d79 | ||
|
|
2db8f5f841 | ||
|
|
047174432f | ||
|
|
bb21ffdf35 | ||
|
|
7ec11993de | ||
|
|
16d4f706b2 | ||
|
|
42755c9422 | ||
|
|
38de7ab3a3 | ||
|
|
0719476618 | ||
|
|
afdf196754 | ||
|
|
35be95b843 | ||
|
|
4ae50c8a73 | ||
|
|
a0c620690e | ||
|
|
07a9739774 | ||
|
|
6a85593bd4 | ||
|
|
c798be9b95 | ||
|
|
fe216be2b4 | ||
|
|
a1d20bdeb5 | ||
|
|
41460481d6 | ||
|
|
a9396ba065 | ||
|
|
101d7b5a50 | ||
|
|
a0d063bb94 | ||
|
|
c21b5b006e | ||
|
|
55ab8ec4a5 | ||
|
|
662d3daf8c | ||
|
|
360c7136dd | ||
|
|
501d90b825 | ||
|
|
fa7a1f0fce | ||
|
|
672ed09074 | ||
|
|
e5dc67346d | ||
|
|
f22e839850 | ||
|
|
b8e8cc387d | ||
|
|
c6b6678a2f | ||
|
|
4179b53c97 | ||
|
|
c8419761e0 | ||
|
|
16bd92567c | ||
|
|
cd51f7cfda | ||
|
|
6f4e872c03 | ||
|
|
588ae5ed09 | ||
|
|
277ca36f42 | ||
|
|
8dded7f177 | ||
|
|
b65a9485c7 | ||
|
|
63915d6fb6 | ||
|
|
379e3fd433 | ||
|
|
f1468e6f57 | ||
|
|
0be9e2201a | ||
|
|
fcb61f3b3e | ||
|
|
87e0a70878 | ||
|
|
8776eb4734 | ||
|
|
fd2e9c2f56 | ||
|
|
3618b335cb | ||
|
|
7d9c64487d | ||
|
|
cc066b457e | ||
|
|
d2f814f1f9 | ||
|
|
69d8187adc | ||
|
|
4670577110 | ||
|
|
d505a50934 | ||
|
|
bc15c362e1 | ||
|
|
9b4c1f51d0 | ||
|
|
eba15791e7 | ||
|
|
7fe4de4b6d | ||
|
|
f4317bc52f | ||
|
|
207f126b9e | ||
|
|
0f1e5fb568 | ||
|
|
0ede9443de | ||
|
|
58b8467c39 | ||
|
|
eedff85904 | ||
|
|
cc16590881 | ||
|
|
8ad32e4864 | ||
|
|
7198f7a4f7 | ||
|
|
ee842c822f | ||
|
|
512b861739 |
No files matched your search
@@ -19,9 +19,11 @@ jobs:
|
||||
matrix:
|
||||
project:
|
||||
- OpenNest.Tests
|
||||
- OpenNest.Desktop.Tests
|
||||
- OpenNest.Engine.Tests
|
||||
- OpenNest.IO.Tests
|
||||
- OpenNest.Server.Tests
|
||||
- OpenNest.FrontEnd.Tests
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
@@ -70,7 +72,7 @@ jobs:
|
||||
|
||||
windows-desktop:
|
||||
# WinForms tests need a Windows desktop runtime; the Linux job can only
|
||||
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop.
|
||||
# cross-compile them. FrontEnd.Tests also run here, beside the desktop build.
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
name: Development build
|
||||
|
||||
# Every master push whose cross-platform tests pass becomes the rolling
|
||||
# `dev-build` pre-release: X.Y.Z-dev.N, where X.Y.Z is the minor after the
|
||||
# newest release tag and N the commit count. Installed copies take it only
|
||||
# with Tools > Options > Include development builds.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Cross-platform tests']
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dev-build
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
package:
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
|
||||
DOTNET_NOLOGO: '1'
|
||||
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: Resolve development version
|
||||
id: version
|
||||
run: |
|
||||
$releases = git tag --list 'v*' | Where-Object { $_ -match '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } |
|
||||
ForEach-Object { [version]$_.Substring(1) } | Sort-Object
|
||||
if (-not $releases) { throw 'No vX.Y.Z release tag found.' }
|
||||
$latest = @($releases)[-1]
|
||||
$count = (git rev-list --count $env:SOURCE_SHA).Trim()
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Cannot count commits.' }
|
||||
$version = "$($latest.Major).$($latest.Minor + 1).0-dev.$count"
|
||||
Write-Host "Development version $version (after v$latest)"
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
- name: Publish and verify Windows package
|
||||
run: ./scripts/Publish-Windows.ps1 -Version ${{ steps.version.outputs.version }}
|
||||
- name: Upload development build
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: OpenNest-dev-build
|
||||
path: |
|
||||
artifacts/*.zip
|
||||
artifacts/*.sha256
|
||||
artifacts/velopack/OpenNest-win-Setup.exe
|
||||
artifacts/velopack/OpenNest-win-Portable.zip
|
||||
artifacts/velopack/*-full.nupkg
|
||||
artifacts/velopack/releases.win.json
|
||||
artifacts/velopack/RELEASES
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
compression-level: 0
|
||||
|
||||
publish:
|
||||
needs: package
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
VERSION: ${{ needs.package.outputs.version }}
|
||||
steps:
|
||||
- name: Skip if master has moved on
|
||||
id: current
|
||||
run: |
|
||||
head=$(gh api "repos/$GH_REPO/commits/master" --jq .sha)
|
||||
if [ "$head" != "$SOURCE_SHA" ]; then
|
||||
echo "master is at $head; its own run publishes the newer build."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Download development build
|
||||
if: steps.current.outputs.skip != 'true'
|
||||
run: gh run download "$GITHUB_RUN_ID" -n OpenNest-dev-build -D dev
|
||||
- name: Verify checksums
|
||||
if: steps.current.outputs.skip != 'true'
|
||||
working-directory: dev
|
||||
run: |
|
||||
sha256sum -c ./*.zip.sha256
|
||||
(cd velopack && sha256sum -c "../OpenNest.v$VERSION.velopack.sha256")
|
||||
- name: Replace the dev-build pre-release
|
||||
if: steps.current.outputs.skip != 'true'
|
||||
working-directory: dev
|
||||
run: |
|
||||
if gh release view dev-build >/dev/null 2>&1; then
|
||||
gh release delete dev-build --cleanup-tag --yes
|
||||
fi
|
||||
cat > notes.md <<EOF
|
||||
Automatic development build of master at $SOURCE_SHA.
|
||||
|
||||
It may contain unfinished features. Installed copies receive it only with
|
||||
**Tools > Options > Include development builds**; full releases are unaffected.
|
||||
EOF
|
||||
gh release create dev-build ./*.zip ./*.sha256 velopack/* \
|
||||
--target "$SOURCE_SHA" --prerelease --latest=false \
|
||||
--title "OpenNest $VERSION (development build, ${SOURCE_SHA:0:8})" --notes-file notes.md
|
||||
- name: Read back the pre-release
|
||||
if: steps.current.outputs.skip != 'true'
|
||||
run: |
|
||||
gh release view dev-build --json isPrerelease,isDraft,tagName,assets \
|
||||
--jq '"prerelease=\(.isPrerelease) draft=\(.isDraft) assets=\(.assets|length)"' | tee readback.txt
|
||||
grep -qx 'prerelease=true draft=false assets=8' readback.txt
|
||||
[ "$(gh api "repos/$GH_REPO/git/ref/tags/dev-build" --jq .object.sha)" = "$SOURCE_SHA" ]
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
# mini dump in TestResults, instead of holding the runner until the
|
||||
# job timeout.
|
||||
run: |
|
||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Desktop.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||
dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
||||
}
|
||||
|
||||
@@ -225,3 +225,6 @@ OpenNest.Tests/test-config.json
|
||||
# Vendor programming manuals: keep reference copies outside source control.
|
||||
CINCINNATI LASER PROGRAMMING MANUAL.pdf
|
||||
TF5200_programming_manual_en.pdf
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
@@ -22,13 +22,17 @@ dotnet build OpenNest.sln
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
|
||||
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.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.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj -c Release
|
||||
|
||||
# Windows runtime tests
|
||||
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
|
||||
```
|
||||
|
||||
After changing planning, contour preparation or geometry checks, run the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); no plate may rank below its baseline. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
|
||||
On Windows, prefer `scripts/Test-Hidden.ps1 <dotnet test args>` for WinForms tests: it runs them on a hidden desktop so no windows appear or take focus; see [hidden-desktop tests](docs/hidden-desktop-tests.md).
|
||||
|
||||
Only Plan Cutting changes need the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); never run it for nesting engines, fills, UI, posts or file formats. Limit it to the areas the change touches with `OPENNEST_PROBLEM_NESTS_CHECKS` (table in that doc); `part-order` and `entries` plan every nest. No plate may rank below its baseline, and none may join or climb the near-budget list compared with master's run. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
|
||||
|
||||
Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
|
||||
|
||||
@@ -38,8 +42,9 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
|
||||
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
|
||||
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
|
||||
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
|
||||
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
|
||||
- `OpenNest.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`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). WinForms `Properties.Settings` persist to `%APPDATA%\OpenNest\user-settings.json` through `AppDataSettingsProvider`, never inside the Velopack install folder; `OpenNest.WinForms.Tests` redirects them to a temp file, so a test run never writes the user's settings. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
|
||||
- `OpenNest.Desktop` (net8.0): toolkit-neutral desktop logic shared by the WinForms app and `OpenNest.Avalonia`, the cross-platform desktop in progress. It must not reference WinForms/WindowsDesktop, `System.Drawing.Common` or Avalonia (`OpenNest.Desktop.Tests` guards this, including transitive packages); put toolkit-specific rendering, dialogs and settings behind adapters in the front ends.
|
||||
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection and the Python sketch-search prototype (`sketch-search/README.md`). Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
|
||||
|
||||
## Geometry and ownership safeguards
|
||||
|
||||
@@ -48,17 +53,20 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
|
||||
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
|
||||
- `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.
|
||||
- `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.
|
||||
- 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.
|
||||
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. Every post must raise before a marked rapid or refuse the nest.
|
||||
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
|
||||
- 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:
|
||||
|
||||
- [Nest file format](docs/nest-file-format.md)
|
||||
- [Irregular stock](docs/irregular-stock.md): outlined sheets, what honors the outline and what refuses it
|
||||
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
|
||||
- [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
|
||||
- [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in and the stop at a feature's center line) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
|
||||
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
|
||||
- [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
|
||||
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level
|
||||
@@ -70,6 +78,6 @@ Read the relevant contract before changing its behavior:
|
||||
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
|
||||
- [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.
|
||||
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
|
||||
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, opt-in rolling `dev-build` pre-release from green master pushes, the nest server's `/updates` mirror first in Database mode (GitHub fallback), `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
|
||||
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
|
||||
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install.
|
||||
@@ -17,7 +17,10 @@ namespace OpenNest.Avalonia
|
||||
var args = desktop.Args ?? System.Array.Empty<string>();
|
||||
if (args.Length > 0)
|
||||
window.OpenNest(args[0]);
|
||||
// --snapshot <png> [plate]: render the window to an image and exit, for checking the drawing.
|
||||
// --auto-nest: run Auto Nest on the opened nest (with --snapshot, before rendering).
|
||||
if (System.Array.IndexOf(args, "--auto-nest") >= 0)
|
||||
window.AutoNestOnOpen();
|
||||
// --snapshot <png> [plate]: render the window to an image (status line in <png>.txt) and exit.
|
||||
var snapshot = System.Array.IndexOf(args, "--snapshot");
|
||||
if (snapshot >= 0 && snapshot + 1 < args.Length)
|
||||
window.SaveSnapshotAndClose(args[snapshot + 1],
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
<DockPanel>
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="_File">
|
||||
<MenuItem Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
|
||||
<MenuItem x:Name="OpenMenuItem" Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
|
||||
<Separator />
|
||||
<MenuItem Header="E_xit" Click="OnExit" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Nest">
|
||||
<MenuItem x:Name="AutoNestMenuItem" Header="_Auto Nest" Click="OnAutoNest" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_View">
|
||||
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
|
||||
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
|
||||
@@ -21,10 +24,11 @@
|
||||
|
||||
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<Button Content="Open" Click="OnOpen" />
|
||||
<Button x:Name="OpenButton" Content="Open" Click="OnOpen" />
|
||||
<Button Content="Fit" Click="OnFit" />
|
||||
<Button Content="Previous" Click="OnPreviousPlate" />
|
||||
<Button Content="Next" Click="OnNextPlate" />
|
||||
<Button x:Name="AutoNestButton" Content="Auto Nest" Click="OnAutoNest" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using OpenNest.Desktop;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Forms;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Avalonia
|
||||
@@ -13,12 +19,22 @@ namespace OpenNest.Avalonia
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private Nest nest;
|
||||
private Document document;
|
||||
private string fileName;
|
||||
private string notice;
|
||||
private CancellationTokenSource nesting;
|
||||
private Task autoNest = Task.CompletedTask;
|
||||
|
||||
private Nest nest => document?.Nest;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
// As the desktop app: plug-in engines first, then the saved engine selection.
|
||||
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
|
||||
var engineWarning = EngineSelection.LoadSavedSelection();
|
||||
if (!string.IsNullOrEmpty(engineWarning))
|
||||
notice = engineWarning;
|
||||
Canvas.ViewChanged += (_, _) => UpdateStatus();
|
||||
KeyBindings.Add(new KeyBinding
|
||||
{
|
||||
@@ -31,9 +47,11 @@ namespace OpenNest.Avalonia
|
||||
|
||||
public void OpenNest(string path)
|
||||
{
|
||||
if (nesting != null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
nest = new NestReader(path).Read();
|
||||
document = new Document { Nest = new NestReader(path).Read() };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -43,30 +61,44 @@ namespace OpenNest.Avalonia
|
||||
|
||||
fileName = Path.GetFileName(path);
|
||||
Title = $"{fileName} - OpenNest (Avalonia prototype)";
|
||||
notice = null;
|
||||
Canvas.ClearCache();
|
||||
RefreshPlateList(0);
|
||||
}
|
||||
|
||||
private void RefreshPlateList(int selectIndex)
|
||||
{
|
||||
PlateList.ItemsSource = nest.Plates
|
||||
.Select((plate, i) => $"Plate {i + 1} {plate.Size.Length:0.###} x {plate.Size.Width:0.###} ({plate.Parts.Count} parts)")
|
||||
.ToList();
|
||||
PlateList.SelectedIndex = nest.Plates.Count > 0 ? 0 : -1;
|
||||
PlateList.SelectedIndex = nest.Plates.Count > 0 ? System.Math.Clamp(selectIndex, 0, nest.Plates.Count - 1) : -1;
|
||||
}
|
||||
|
||||
/// <summary>Runs Auto Nest once the window opens, for --auto-nest.</summary>
|
||||
public void AutoNestOnOpen() => Opened += (_, _) => autoNest = RunAutoNestAsync();
|
||||
|
||||
public void SaveSnapshotAndClose(string path, int plateNumber)
|
||||
{
|
||||
Opened += async (_, _) =>
|
||||
{
|
||||
if (nest?.Plates.Count > 0)
|
||||
PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1);
|
||||
await autoNest;
|
||||
await System.Threading.Tasks.Task.Delay(500);
|
||||
var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height);
|
||||
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
|
||||
bitmap.Render(this);
|
||||
bitmap.Save(path);
|
||||
// The status line beside the image, so headless checks can read it.
|
||||
File.WriteAllText(path + ".txt", Status.Text);
|
||||
Close();
|
||||
};
|
||||
}
|
||||
|
||||
private async void OnOpen(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (nesting != null)
|
||||
return;
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Open Nest",
|
||||
@@ -80,6 +112,88 @@ namespace OpenNest.Avalonia
|
||||
|
||||
private void OnExit(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private void OnAutoNest(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Stop always cancels and discards the whole proposal.
|
||||
if (nesting != null)
|
||||
{
|
||||
nesting.Cancel();
|
||||
return;
|
||||
}
|
||||
autoNest = RunAutoNestAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nests every drawing's remaining quantity on sheets like the shown plate through the
|
||||
/// shared pipeline, off the UI thread. Open is refused until it finishes, so the nest
|
||||
/// and its drawings stay stable from the request to the commit.
|
||||
/// </summary>
|
||||
private async Task RunAutoNestAsync()
|
||||
{
|
||||
if (nest == null || nesting != null)
|
||||
return;
|
||||
var engineName = EngineSelection.EngineName;
|
||||
var template = Canvas.Plate ?? nest.Plates.FirstOrDefault() ?? nest.PlateDefaults.CreateNew();
|
||||
var request = AutoNestJob.ForRemaining(nest, template, engineName);
|
||||
if (request == null)
|
||||
{
|
||||
ShowNotice("Auto Nest: nothing has a remaining quantity to nest.");
|
||||
return;
|
||||
}
|
||||
|
||||
nesting = new CancellationTokenSource();
|
||||
var token = nesting.Token;
|
||||
SetNesting(true);
|
||||
ShowNotice($"{engineName}: starting...");
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
if (!token.IsCancellationRequested)
|
||||
ShowNotice(p.Description ?? $"{engineName}: plate {p.PlateNumber}");
|
||||
});
|
||||
try
|
||||
{
|
||||
var jobProgress = JobEngineNest.CreateProgress(engineName, progress);
|
||||
var result = await Task.Run(() => NestPipeline.Run(request, jobProgress, token));
|
||||
// Engines that ignore cancellation still have their proposal discarded.
|
||||
token.ThrowIfCancellationRequested();
|
||||
var firstNew = nest.Plates.Count(p => p.Parts.Count > 0);
|
||||
var kept = AutoNestJob.Keep(nest, result);
|
||||
if (kept.Nested)
|
||||
RefreshPlateList(firstNew);
|
||||
ShowNotice(kept.Nested ? $"{engineName}: {kept.Note}" : AutoNestJob.RefusalReason(result));
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
ShowNotice("Auto Nest stopped; nothing was changed.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowNotice($"Auto Nest failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
nesting.Dispose();
|
||||
nesting = null;
|
||||
SetNesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetNesting(bool running)
|
||||
{
|
||||
AutoNestButton.Content = running ? "Stop" : "Auto Nest";
|
||||
AutoNestMenuItem.Header = running ? "_Stop Auto Nest" : "_Auto Nest";
|
||||
OpenButton.IsEnabled = !running;
|
||||
OpenMenuItem.IsEnabled = !running;
|
||||
}
|
||||
|
||||
private void ShowNotice(string text)
|
||||
{
|
||||
notice = text;
|
||||
UpdateStatus();
|
||||
if (Canvas.Plate == null)
|
||||
Status.Text = text;
|
||||
}
|
||||
|
||||
private void OnFit(object sender, RoutedEventArgs e) => Canvas.ZoomToFit();
|
||||
|
||||
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
|
||||
@@ -120,6 +234,8 @@ namespace OpenNest.Avalonia
|
||||
var part = Canvas.HoveredPart ?? Canvas.SelectedPart;
|
||||
if (part != null)
|
||||
text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}";
|
||||
if (notice != null)
|
||||
text += $" | {notice}";
|
||||
Status.Text = text;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<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" />
|
||||
|
||||
@@ -35,7 +35,7 @@ static class NestConsole
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.Error.WriteLine("Nesting cancelled; nothing saved.");
|
||||
Console.Error.WriteLine("Nesting canceled; nothing saved.");
|
||||
return 2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -132,14 +132,26 @@ static class NestConsole
|
||||
|
||||
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|
||||
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|
||||
|| options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
|
||||
|| options.OutlineFile != null || options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size or --template."
|
||||
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size, --outline or --template."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
PrintUsage();
|
||||
@@ -197,6 +209,12 @@ static class NestConsole
|
||||
ApplyTemplate(plate, options);
|
||||
ApplyOverrides(plate, options);
|
||||
|
||||
if (!options.AutoNest && PlateFillService.Refusal(plate) is { } refusal)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {refusal} Use --autonest.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var drawing = ResolveDrawing(nest, options);
|
||||
|
||||
if (drawing == null)
|
||||
@@ -276,6 +294,9 @@ static class NestConsole
|
||||
if (Size.TryParse(args[++i], out var sz))
|
||||
o.PlateSize = sz;
|
||||
break;
|
||||
case "--outline" when i + 1 < args.Length:
|
||||
o.OutlineFile = args[++i];
|
||||
break;
|
||||
case "--check-overlaps":
|
||||
o.CheckOverlaps = true;
|
||||
break;
|
||||
@@ -526,6 +547,13 @@ static class NestConsole
|
||||
|
||||
if (options.PlateSize.HasValue && !hasDxfOnly)
|
||||
plate.Size = options.PlateSize.Value;
|
||||
|
||||
if (options.OutlineFile != null)
|
||||
{
|
||||
// The outline is the sheet: its bounds replace any size given.
|
||||
plate.Outline = SheetOutline.FromGeometry(CadImporter.Import(options.OutlineFile).Entities);
|
||||
Console.WriteLine($"Outline: {options.OutlineFile} ({plate.Outline})");
|
||||
}
|
||||
}
|
||||
|
||||
static Drawing ResolveDrawing(Nest nest, Options options)
|
||||
@@ -996,6 +1024,9 @@ static class NestConsole
|
||||
--quantity <n> Max parts to place (default: 0 = unlimited)
|
||||
--spacing <value> Override part spacing
|
||||
--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)
|
||||
--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
|
||||
@@ -1037,6 +1068,7 @@ static class NestConsole
|
||||
public int Quantity;
|
||||
public double? Spacing;
|
||||
public Size? PlateSize;
|
||||
public string OutlineFile;
|
||||
public bool CheckOverlaps;
|
||||
public bool NoSave;
|
||||
public bool KeepParts;
|
||||
|
||||
@@ -196,7 +196,7 @@ public static class AutomaticCutOffPlanner
|
||||
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.EdgeSpacing.Bottom && y <= zone.Bounds.Top + plate.EdgeSpacing.Bottom);
|
||||
y >= zone.Bounds.Bottom - plate.ClampClearance && y <= zone.Bounds.Top + plate.ClampClearance);
|
||||
if (limited || clamp != null)
|
||||
{
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
@@ -371,7 +371,7 @@ public static class AutomaticCutOffPlanner
|
||||
|
||||
// A full-span line, or one with the limits this plan gives its own vertical lines.
|
||||
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
|
||||
horizontalY.HasValue &&
|
||||
horizontalY.HasValue && cut.Gaps.Count == 0 &&
|
||||
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
|
||||
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
|
||||
|
||||
@@ -460,7 +460,7 @@ public static class AutomaticCutOffPlanner
|
||||
{
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.ClampBlockedCut,
|
||||
$"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.",
|
||||
$"The proposed cut crosses clamp '{blocked.Id}' (including its clearance); it was skipped.",
|
||||
X: x));
|
||||
if (isSeparator)
|
||||
WarnNoSeparator(x);
|
||||
@@ -473,7 +473,7 @@ public static class AutomaticCutOffPlanner
|
||||
var candidate = existing == null
|
||||
? NewVertical(x)
|
||||
: new CutOff(existing.Position, existing.Axis)
|
||||
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
|
||||
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit, Gaps = existing.Gaps };
|
||||
candidate.Regenerate(plate, settings, cache);
|
||||
var program = candidate.Drawing.Program;
|
||||
var usable = HasUsableSegments(program);
|
||||
@@ -522,7 +522,7 @@ public static class AutomaticCutOffPlanner
|
||||
// Share the exact placement-frame predicate with candidate exclusion and final
|
||||
// certification. Infeasible gaps still use nominal lines through AddCandidate.
|
||||
bool CrossesClamp(ClampZone zone, double x) =>
|
||||
x >= zone.Bounds.Left - plate.EdgeSpacing.Bottom && x <= zone.Bounds.Right + plate.EdgeSpacing.Bottom;
|
||||
x >= zone.Bounds.Left - plate.ClampClearance && x <= zone.Bounds.Right + plate.ClampClearance;
|
||||
|
||||
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
|
||||
@@ -544,10 +544,12 @@ public static class AutomaticCutOffPlanner
|
||||
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
|
||||
"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));
|
||||
// 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(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
|
||||
Nonnegative(plate.EdgeSpacing.Bottom)),
|
||||
"Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate));
|
||||
Nonnegative(plate.ClampClearance)),
|
||||
"Clamp zones and clamp clearance must be finite and valid.", nameof(plate));
|
||||
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
||||
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
|
||||
Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
|
||||
@@ -652,7 +654,9 @@ public static class AutomaticCutOffPlanner
|
||||
}
|
||||
}
|
||||
|
||||
// A gapped line never runs full span, whatever its limits.
|
||||
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||
cut.Gaps.Count == 0 &&
|
||||
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
|
||||
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
|
||||
|
||||
@@ -673,6 +677,7 @@ public static class AutomaticCutOffPlanner
|
||||
}
|
||||
|
||||
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||
cut.Gaps.Count == 0 &&
|
||||
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
|
||||
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace OpenNest.Breakoffs
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves and applies <see cref="Breakoff"/>s: the joining line becomes open cuts on the
|
||||
/// break layer between tabs, each travelling with the scrap on its left so the cutting
|
||||
/// break layer between tabs, each traveling with the scrap on its left so the cutting
|
||||
/// strategies pierce and lead in from the scrap.
|
||||
/// </summary>
|
||||
public static class BreakoffBuilder
|
||||
|
||||
@@ -49,7 +49,7 @@ namespace OpenNest.Breakoffs
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab stays on the
|
||||
/// joining line and keeps a gap from its neighbours.
|
||||
/// joining line and keeps a gap from its neighbors.
|
||||
/// </summary>
|
||||
public double ClampTabCenter(int index, double center)
|
||||
{
|
||||
|
||||
@@ -53,7 +53,7 @@ internal enum AutomaticEntryKind
|
||||
|
||||
/// <summary>
|
||||
/// Which automatic entries a perimeter tabbed for support offers (see
|
||||
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalogue.
|
||||
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalog.
|
||||
/// </summary>
|
||||
internal enum SupportEntries
|
||||
{
|
||||
@@ -70,9 +70,11 @@ internal enum SupportEntries
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// owner; nothing here exposes or mutates the underlying shape.
|
||||
/// owner; nothing here exposes or mutates the underlying shape. <paramref name="CornerReach"/>
|
||||
/// is how far a corner fillet's joint lies from the sharp corner it rounds (0 otherwise), so
|
||||
/// ranking can count it on both of that corner's sides.
|
||||
/// </summary>
|
||||
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
|
||||
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
|
||||
|
||||
@@ -7,7 +7,7 @@ using OpenNest.Geometry;
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Pure deterministic ordering of the automatic entry catalogue toward the next cut:
|
||||
/// Pure deterministic ordering of the automatic entry catalog toward the next cut:
|
||||
/// circles rank by outgoing distance; other contours use facing sides, tier and travel,
|
||||
/// then a stable geometric key. It adds no
|
||||
/// 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;
|
||||
}
|
||||
|
||||
// Facing sides from the target relative to the centre, matching the source plan:
|
||||
// horizontal right when the target is right of centre else left; vertical likewise.
|
||||
var centreX = minX + (maxX - minX) * 0.5;
|
||||
var centreY = minY + (maxY - minY) * 0.5;
|
||||
var facingRight = target != null && target.Value.X > centreX;
|
||||
var facingTop = target != null && target.Value.Y > centreY;
|
||||
// Facing sides from the target relative to the center, matching the source plan:
|
||||
// horizontal right when the target is right of center else left; vertical likewise.
|
||||
var centerX = minX + (maxX - minX) * 0.5;
|
||||
var centerY = minY + (maxY - minY) * 0.5;
|
||||
var facingRight = target != null && target.Value.X > centerX;
|
||||
var facingTop = target != null && target.Value.Y > centerY;
|
||||
|
||||
return candidates
|
||||
.Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival)))
|
||||
@@ -107,16 +107,18 @@ internal static class ContourEntryRanking
|
||||
if (target != null)
|
||||
{
|
||||
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
|
||||
// sides within tolerance); count how many of them are facing sides.
|
||||
// sides within tolerance, a fillet's joint within its reach of the corner it
|
||||
// rounds); count how many of them are facing sides.
|
||||
var left = p.X - minX;
|
||||
var right = maxX - p.X;
|
||||
var bottom = p.Y - minY;
|
||||
var top = maxY - p.Y;
|
||||
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
|
||||
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
|
||||
if (facingRight && right <= near) facing++;
|
||||
if (!facingRight && left <= near) facing++;
|
||||
if (facingTop && top <= near) facing++;
|
||||
if (!facingTop && bottom <= near) facing++;
|
||||
}
|
||||
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
|
||||
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
|
||||
|
||||
@@ -62,7 +62,7 @@ public enum CuttingCommitStatus
|
||||
Applied,
|
||||
Stale,
|
||||
InvalidInput,
|
||||
Cancelled,
|
||||
Canceled,
|
||||
Failed
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ internal static class CuttingPlanCommit
|
||||
Action<Plate, Part> beforeInstall)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
||||
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
|
||||
var scope = plans?.ToArray();
|
||||
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.");
|
||||
@@ -126,7 +126,7 @@ internal static class CuttingPlanCommit
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
||||
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
|
||||
}
|
||||
if (difference != null)
|
||||
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
|
||||
@@ -200,7 +200,7 @@ internal static class CuttingPlanCommit
|
||||
|
||||
// Last cancellation point. The boundary below has no await, search or geometry work.
|
||||
if (token.IsCancellationRequested)
|
||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
||||
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
|
||||
|
||||
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States,
|
||||
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
|
||||
|
||||
@@ -41,8 +41,10 @@ public static class ExecutionMotionReader
|
||||
var pos = frame;
|
||||
var first = true;
|
||||
var countBefore = moves.Count;
|
||||
foreach (var code in current.Codes)
|
||||
var raised = HeadRaise.RaisedMask(current.Codes);
|
||||
for (var codeIndex = 0; codeIndex < current.Codes.Count; codeIndex++)
|
||||
{
|
||||
var code = current.Codes[codeIndex];
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
||||
@@ -106,7 +108,8 @@ public static class ExecutionMotionReader
|
||||
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
|
||||
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
|
||||
motion is ArcMove { Rotation: RotationType.CW });
|
||||
moves.Add(new(start, end, rapid, layer, curve));
|
||||
moves.Add(new(start, end, rapid, layer, curve, raised[codeIndex],
|
||||
ReferenceEquals(current, program) ? codeIndex : null));
|
||||
pos = end;
|
||||
first = false;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ internal static class IncrementalClosureDrift
|
||||
var endPoint = last.EndPoint + correction;
|
||||
if (last is ArcMove arc)
|
||||
{
|
||||
// Keep the arc's centre (relative to its start) equidistant from its moved end.
|
||||
// Keep the arc's center (relative to its start) equidistant from its moved end.
|
||||
var chord = endPoint.DistanceTo(new Vector());
|
||||
if (chord <= Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
@@ -121,11 +121,16 @@ public sealed class LeadMaterialSnapshot
|
||||
? "A display perimeter must enclose every other contour."
|
||||
: $"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;
|
||||
for (var i = 0; i < rings.Count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token)))
|
||||
if (Enumerable.Range(0, rings.Count).All(j => i == j || Encloses(i, rings[j][0].Start)))
|
||||
{
|
||||
if (outer >= 0)
|
||||
throw new ArgumentException("Material perimeter is ambiguous.");
|
||||
@@ -142,10 +147,11 @@ public sealed class LeadMaterialSnapshot
|
||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||
if (ringDisplay[outer] && rings.Count == 1)
|
||||
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
||||
var holes = rings.Where((_, i) => i != outer).ToArray();
|
||||
for (var i = 0; i < holes.Length; i++)
|
||||
for (var j = i + 1; j < holes.Length; j++)
|
||||
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.");
|
||||
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
|
||||
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
|
||||
|
||||
@@ -294,13 +294,25 @@ public static class LeadPathValidator
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
var contacts = curve.Contacts(boundary, out var overlap);
|
||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|
||||
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
|
||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint || !AtJoint(p, joint, boundary)))
|
||||
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
|
||||
}
|
||||
// With all other boundary contacts excluded, the connected open path
|
||||
// has constant material membership. Use the native arc midpoint, not
|
||||
// the chord midpoint or endpoints (which can both lie in scrap).
|
||||
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;
|
||||
}
|
||||
@@ -38,20 +38,31 @@ public sealed class OwnedExecution
|
||||
public sealed class ExecutionMotion
|
||||
{
|
||||
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
|
||||
PostVerificationGeometry.Curve curve)
|
||||
PostVerificationGeometry.Curve curve, bool headRaised = false, int? code = null)
|
||||
{
|
||||
Start = start;
|
||||
End = end;
|
||||
Rapid = rapid;
|
||||
Layer = layer;
|
||||
Curve = curve;
|
||||
HeadRaised = headRaised;
|
||||
Code = code;
|
||||
}
|
||||
|
||||
public Vector? Start { get; }
|
||||
public Vector End { get; }
|
||||
public bool Rapid { get; }
|
||||
public LayerType Layer { get; }
|
||||
|
||||
/// <summary>A rapid made with the head raised over parts already cut (<see cref="HeadRaise"/>).</summary>
|
||||
public bool HeadRaised { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The index of the code this motion runs in the read program's own codes; null for a motion
|
||||
/// inside a sub-program and for the rapid a program that opens with a cut is given.
|
||||
/// </summary>
|
||||
public int? Code { get; }
|
||||
public double Length => Curve?.Length ?? 0;
|
||||
internal PostVerificationGeometry.Curve Curve { get; }
|
||||
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
|
||||
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve, HeadRaised, Code);
|
||||
}
|
||||
@@ -82,7 +82,7 @@ public sealed class PlateCuttingState
|
||||
}
|
||||
var cutOffs = plate.CutOffs.Select(c => c == null
|
||||
? throw new ArgumentException("Plate contains a missing cutoff definition.")
|
||||
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit,
|
||||
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit, c.Gaps,
|
||||
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
|
||||
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
||||
|
||||
@@ -157,6 +157,8 @@ public sealed class PlateCuttingState
|
||||
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|
||||
|| !Bits(cutOff.Position.Y, record.Position.Y)
|
||||
|| !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.";
|
||||
}
|
||||
@@ -206,5 +208,6 @@ public sealed class PlateCuttingState
|
||||
List<ContourCutPin> Pins, string PinsFingerprint);
|
||||
|
||||
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
|
||||
double? StartLimit, double? EndLimit, double HeadLockOvercut, bool FarEdgeFirst);
|
||||
double? StartLimit, double? EndLimit, IReadOnlyList<CutOffGap> Gaps, double HeadLockOvercut,
|
||||
bool FarEdgeFirst);
|
||||
}
|
||||
@@ -180,7 +180,7 @@ public sealed class PreparedContours
|
||||
/// <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 honour: a tab on an internal contour.</summary>
|
||||
/// <summary>Resolved pins this preparation could not honor: a tab on an internal contour.</summary>
|
||||
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
||||
|
||||
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
||||
@@ -465,7 +465,9 @@ public sealed class PreparedContours
|
||||
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
|
||||
return Array.Empty<ContourChoice>();
|
||||
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
|
||||
var found = ErodedPierce.Find(shape, ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
|
||||
var depth = System.Math.Max(ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
|
||||
ApplicableLeadInLength(contourOrdinal));
|
||||
var found = ErodedPierce.Find(shape, depth,
|
||||
targets.Select(t => t.Point).ToArray(), max);
|
||||
var entries = new List<ContourChoice>();
|
||||
for (var i = 0; i < found.Pierces.Count; i++)
|
||||
@@ -510,7 +512,7 @@ public sealed class PreparedContours
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The uncapped preferred automatic start catalogue for one contour, in preference then
|
||||
/// The uncapped preferred automatic start catalog for one contour, in preference then
|
||||
/// contour-travel order: convex corners of the contour's own winding, then straight-edge
|
||||
/// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear
|
||||
/// line/line splits, circles and interior points never appear; each geometric point is
|
||||
@@ -526,12 +528,12 @@ public sealed class PreparedContours
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
||||
if (IsSingleCircle(shape))
|
||||
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
|
||||
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalog.");
|
||||
return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The complete uncapped automatic start catalogue: the preferred points of
|
||||
/// The complete uncapped automatic start catalog: the preferred points of
|
||||
/// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc
|
||||
/// midpoints, near-convex-corner points on straight edges, the eight compass points of a
|
||||
/// whole circle, and the exact target-facing closest point toward
|
||||
@@ -539,7 +541,7 @@ public sealed class PreparedContours
|
||||
/// 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
|
||||
/// preferred tier; fallbacks never replace a preferred point at the same geometry. A
|
||||
/// pinned contour's catalogue is its pinned entry alone: nothing may relocate it.
|
||||
/// pinned contour's catalog is its pinned entry alone: nothing may relocate it.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
||||
int contourOrdinal,
|
||||
@@ -560,7 +562,7 @@ public sealed class PreparedContours
|
||||
|
||||
/// <summary>
|
||||
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
|
||||
/// its automatic catalogue then keeps to steadying points unless asked for a fallback.
|
||||
/// its automatic catalog then keeps to steadying points unless asked for a fallback.
|
||||
/// </summary>
|
||||
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
|
||||
|
||||
@@ -601,8 +603,8 @@ public sealed class PreparedContours
|
||||
|
||||
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
|
||||
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
|
||||
=> CataloguePoints(shape, token)
|
||||
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
|
||||
=> CatalogPoints(shape, token)
|
||||
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind, p.CornerReach))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
@@ -647,7 +649,7 @@ public sealed class PreparedContours
|
||||
// endpoint and so never lands on the reflex/cusp vertex at the far end.
|
||||
if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle))
|
||||
{
|
||||
foreach (var corner in CataloguePoints(shape, token)
|
||||
foreach (var corner in CatalogPoints(shape, token)
|
||||
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -761,14 +763,16 @@ public sealed class PreparedContours
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The preferred catalogue points for one contour: for each entity, its convex corner
|
||||
/// The preferred catalog points for one contour: for each entity, its convex corner
|
||||
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
|
||||
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
|
||||
/// plus tangent line/arc joints. The joints of a corner fillet (see <see cref="FilletReach"/>)
|
||||
/// are that corner's convex corners. Reflex, cusp and collinear-split vertices contribute
|
||||
/// nothing; a single whole circle yields no points at all.
|
||||
/// </summary>
|
||||
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
|
||||
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind, double CornerReach)> CatalogPoints(
|
||||
Shape shape, CancellationToken token)
|
||||
{
|
||||
var found = new List<(int, Vector, AutomaticEntryKind)>();
|
||||
var found = new List<(int, Vector, AutomaticEntryKind, double)>();
|
||||
var count = shape.Entities.Count;
|
||||
if (count == 1 && shape.Entities[0] is Circle)
|
||||
{
|
||||
@@ -783,7 +787,7 @@ public sealed class PreparedContours
|
||||
token.ThrowIfCancellationRequested();
|
||||
var entity = shape.Entities[i];
|
||||
|
||||
// Vertex reached by travelling along entity i (its end point), reported under
|
||||
// Vertex reached by traveling along entity i (its end point), reported under
|
||||
// entity i. The closed contour guarantees every vertex appears exactly once
|
||||
// this way; each is classified from the contour's own winding.
|
||||
var vertex = End(entity);
|
||||
@@ -792,29 +796,65 @@ public sealed class PreparedContours
|
||||
switch (corner.Kind)
|
||||
{
|
||||
case ContourCuttingStrategy.CornerKind.Convex:
|
||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
|
||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
|
||||
break;
|
||||
case ContourCuttingStrategy.CornerKind.Smooth
|
||||
when entity is Line && Next(i) is Arc:
|
||||
// A line leaving into an arc: the tangent joint. The reverse travel
|
||||
// order classifies the same joint from the arc, matched below.
|
||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||
found.Add(Joint(i, vertex, (i + 1) % count));
|
||||
break;
|
||||
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
||||
// of a plain straight edge are not preferred automatic starts here.
|
||||
case ContourCuttingStrategy.CornerKind.Smooth
|
||||
when entity is Arc && Next(i) is Line:
|
||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||
found.Add(Joint(i, vertex, i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (entity is Line line)
|
||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
|
||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
|
||||
}
|
||||
return found;
|
||||
|
||||
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
||||
|
||||
(int, Vector, AutomaticEntryKind, double) Joint(int entityOrdinal, Vector vertex, int arcOrdinal) =>
|
||||
FilletReach(shape, arcOrdinal) is { } reach
|
||||
? (entityOrdinal, vertex, AutomaticEntryKind.ConvexCorner, reach)
|
||||
: (entityOrdinal, vertex, AutomaticEntryKind.TangentJoint, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How far the joints of the arc at <paramref name="arcOrdinal"/> lie from the sharp corner it
|
||||
/// rounds, when it is a corner fillet: tangent to straight edges at both ends, turning less
|
||||
/// than a half circle the convex way, and reaching back along each edge no further than the
|
||||
/// edge is long. A fillet's joints start the cut at the corner, where the mismatch when the
|
||||
/// cut closes is least seen, instead of on a straight edge. Null for any other arc.
|
||||
/// </summary>
|
||||
private static double? FilletReach(Shape shape, int arcOrdinal)
|
||||
{
|
||||
var count = shape.Entities.Count;
|
||||
if (shape.Entities[arcOrdinal] is not Arc arc
|
||||
|| shape.Entities[(arcOrdinal + count - 1) % count] is not Line before
|
||||
|| shape.Entities[(arcOrdinal + 1) % count] is not Line after)
|
||||
return null;
|
||||
var sweep = arc.SweepAngle();
|
||||
if (sweep >= System.Math.PI - OpenNest.Math.Tolerance.Epsilon)
|
||||
return null;
|
||||
if (!IsSmooth(before.EndPoint, before) || !IsSmooth(arc.EndPoint(), arc))
|
||||
return null;
|
||||
var winding = ContourCuttingStrategy.DetermineWinding(shape);
|
||||
if (ContourCuttingStrategy.ClassifyTurn((before.EndPoint - before.StartPoint).Normalize(),
|
||||
(after.EndPoint - after.StartPoint).Normalize(), winding) != ContourCuttingStrategy.CornerKind.Convex)
|
||||
return null;
|
||||
var reach = arc.Radius * System.Math.Tan(sweep / 2);
|
||||
return reach <= System.Math.Min(before.Length, after.Length) ? reach : null;
|
||||
|
||||
bool IsSmooth(Vector vertex, Entity entity) =>
|
||||
ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)
|
||||
&& corner.Kind == ContourCuttingStrategy.CornerKind.Smooth;
|
||||
}
|
||||
|
||||
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
||||
@@ -925,20 +965,20 @@ public sealed class PreparedContours
|
||||
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
|
||||
|
||||
/// <summary>
|
||||
/// Representative POINTS per contour ordinal (bounding-box centre) for hole routing
|
||||
/// Representative POINTS per contour ordinal (bounding-box center) for hole routing
|
||||
/// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the
|
||||
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<Vector?> HoleCentres(CancellationToken token = default)
|
||||
internal IReadOnlyList<Vector?> HoleCenters(CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var centres = new Vector?[Count];
|
||||
var centers = new Vector?[Count];
|
||||
for (var contour = 0; contour < PerimeterOrdinal; contour++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
centres[contour] = GetShape(contour).BoundingBox.Center;
|
||||
centers[contour] = GetShape(contour).BoundingBox.Center;
|
||||
}
|
||||
return centres;
|
||||
return centers;
|
||||
}
|
||||
|
||||
/// <summary>The bounding box of contour <paramref name="contourOrdinal"/>, local. A routing hint only.</summary>
|
||||
@@ -960,7 +1000,7 @@ public sealed class PreparedContours
|
||||
/// 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 centre sits beside the chord's midpoint) is never cut.
|
||||
/// stored center sits beside the chord's midpoint) is never cut.
|
||||
/// </summary>
|
||||
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
|
||||
{
|
||||
|
||||
@@ -54,9 +54,11 @@ public sealed class ReleasedContourState
|
||||
/// As <see cref="Check(OwnedExecution, Vector?, int, bool, CancellationToken)"/>, but a
|
||||
/// 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
|
||||
/// state advances exactly as it would without ownership.
|
||||
/// state advances exactly as it would without ownership. Crossings made by a rapid with the
|
||||
/// head raised are returned apart as well, as Raised: they are the raise's reason, not a fault.
|
||||
/// </summary>
|
||||
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check(
|
||||
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed,
|
||||
IReadOnlyList<PostVerificationFinding> Raised) Check(
|
||||
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
|
||||
CancellationToken token)
|
||||
{
|
||||
@@ -67,8 +69,10 @@ public sealed class ReleasedContourState
|
||||
moves[0] = moves[0].WithStart(arrival);
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
var fixedFindings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
|
||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
|
||||
var raisedFindings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings,
|
||||
raisedFindings: raisedFindings);
|
||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly(), raisedFindings.AsReadOnly());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -97,7 +101,7 @@ public sealed class ReleasedContourState
|
||||
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
||||
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
|
||||
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
|
||||
bool proveOpenCuts = false, int contoursBefore = 0)
|
||||
bool proveOpenCuts = false, int contoursBefore = 0, List<PostVerificationFinding> raisedFindings = null)
|
||||
{
|
||||
var current = new CutGroup();
|
||||
var unfinished = new List<CutGroup>();
|
||||
@@ -166,16 +170,22 @@ public sealed class ReleasedContourState
|
||||
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
|
||||
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
|
||||
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
|
||||
if (PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
|
||||
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||
$"Direct XY rapid crosses or touches 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)
|
||||
}, owned);
|
||||
if (!PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
|
||||
continue;
|
||||
var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
|
||||
obstacle.Part, (move.HeadRaised ? "Head-raised" : "Direct") + " XY rapid crosses or touches "
|
||||
+ $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}.")
|
||||
{
|
||||
Location = (start + move.End) * 0.5,
|
||||
RapidStart = start,
|
||||
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)
|
||||
@@ -333,7 +343,7 @@ public sealed class ReleasedContourState
|
||||
/// 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
|
||||
/// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and
|
||||
/// tab strength are not modelled, exactly as for a single open contour.
|
||||
/// tab strength are not modeled, exactly as for a single open contour.
|
||||
/// </summary>
|
||||
private const double JointTolerance = 1e-5;
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ internal static class SeamOvercut
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The angle travelled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary>
|
||||
/// <summary>The angle traveled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary>
|
||||
private static double Travel(Vector center, Vector from, Vector to, bool clockwise)
|
||||
{
|
||||
var start = System.Math.Atan2(from.Y - center.Y, from.X - center.X);
|
||||
|
||||
@@ -314,7 +314,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var shape = shapes[choice.ContourOrdinal];
|
||||
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
|
||||
exactCirclePrograms: true, scrapPierce: choice.Pierce);
|
||||
exactCirclePrograms: true, scrapPierce: choice.Pierce,
|
||||
stopAtCenterLine: contourParameters?[choice.ContourOrdinal] == null);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -440,7 +441,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
Entity entity,
|
||||
ContourType? forceType = null,
|
||||
bool exactCirclePrograms = false,
|
||||
Vector? scrapPierce = null
|
||||
Vector? scrapPierce = null,
|
||||
bool stopAtCenterLine = false
|
||||
)
|
||||
{
|
||||
var contourType = forceType ?? DetectContourType(shape);
|
||||
@@ -472,6 +474,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
leadIn = StraightLeadIn(point, holePierce, out normal);
|
||||
else
|
||||
{
|
||||
if (stopAtCenterLine)
|
||||
leadIn = StopAtCenterLine(shape, point, leadIn, normal, winding);
|
||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
||||
Parameters.GetEffectivePierceClearance());
|
||||
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius(), winding);
|
||||
@@ -524,7 +528,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
||||
Parameters.GetEffectivePierceClearance(), out leadInNormal);
|
||||
if (contourType == ContourType.Internal)
|
||||
{
|
||||
if (stopAtCenterLine)
|
||||
leadIn = StopAtCenterLine(shape, point, leadIn, leadInNormal, turn);
|
||||
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius(), turn);
|
||||
}
|
||||
}
|
||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
|
||||
|
||||
@@ -867,10 +875,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
|
||||
/// not a corner.
|
||||
/// </summary>
|
||||
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding)
|
||||
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding) =>
|
||||
ClassifyTurn(corner.TangentIn, corner.TangentOut, winding);
|
||||
|
||||
/// <summary>
|
||||
/// The corner a contour of <paramref name="winding"/> would make turning from travel
|
||||
/// direction <paramref name="tangentIn"/> to <paramref name="tangentOut"/>, classified
|
||||
/// exactly as a shared vertex is (see <see cref="TryClassifyAutomaticStartCorner"/>).
|
||||
/// </summary>
|
||||
internal static CornerKind ClassifyTurn(Vector tangentIn, Vector tangentOut, RotationType winding)
|
||||
{
|
||||
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
|
||||
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
|
||||
var cross = tangentIn.X * tangentOut.Y - tangentIn.Y * tangentOut.X;
|
||||
var dot = tangentIn.DotProduct(tangentOut);
|
||||
var turn = winding == RotationType.CCW ? cross : -cross;
|
||||
|
||||
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
|
||||
@@ -1050,10 +1066,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// returned as given. Otherwise the pierce moves onto the line from
|
||||
/// <paramref name="point"/> toward the hole's pole of inaccessibility (the centre of a
|
||||
/// round hole), as close to the contour as the zone allows, and the lead becomes a
|
||||
/// straight line from there, generated with <paramref name="normal"/>. A hole too small
|
||||
/// returned as given. Otherwise the pierce moves straight in from the wall to the hole's
|
||||
/// center line, or the lead's own length in when that comes first (see
|
||||
/// <see cref="StopAtCenterLine"/>). A lead that cannot keep the zone there moves onto the
|
||||
/// line from <paramref name="point"/> toward the nearest point that keeps it (see
|
||||
/// <see cref="ErodedPierce"/>) or else the hole's pole of inaccessibility (the center of a
|
||||
/// round hole), as close to the contour as the zone allows. Either way the lead becomes
|
||||
/// a straight line from there, generated with <paramref name="normal"/>. A hole too small
|
||||
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and
|
||||
/// 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
|
||||
@@ -1071,29 +1090,42 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var polygon = circle == null
|
||||
? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance))
|
||||
: null;
|
||||
if (Clear(leadIn.GetPiercePoint(point, normal, turn)))
|
||||
var configured = leadIn.GetPiercePoint(point, normal, turn);
|
||||
if (Clear(configured))
|
||||
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,
|
||||
System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells);
|
||||
if (!Inside(pole))
|
||||
return leadIn;
|
||||
|
||||
// Clearance grows monotonically toward the pole of a convex hole, so the bisection
|
||||
// The pole of a letter can be a stroke away: head for the stroke beside the entry, on
|
||||
// its center line when the lead reaches that far.
|
||||
var reach = configured.DistanceTo(point);
|
||||
var toward = pole;
|
||||
if (circle == null && ErodedPierce.Find(hole, System.Math.Max(pierceRadius, reach), new[] { point }, 1)
|
||||
.Pierces is [var eroded] && Clear(eroded))
|
||||
toward = eroded;
|
||||
|
||||
// Clearance grows monotonically along this line in a convex hole, so the bisection
|
||||
// finds the shortest clear lead; in other holes it still ends on a clear point.
|
||||
var pierce = pole;
|
||||
if (Clear(pole))
|
||||
var pierce = toward;
|
||||
if (Clear(toward) && (reach < pierceRadius || toward == pole))
|
||||
{
|
||||
double low = 0, high = 1;
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
var middle = (low + high) / 2;
|
||||
if (Clear(point + (pole - point) * middle))
|
||||
if (Clear(point + (toward - point) * middle))
|
||||
high = middle;
|
||||
else
|
||||
low = middle;
|
||||
}
|
||||
pierce = point + (pole - point) * high;
|
||||
pierce = point + (toward - point) * high;
|
||||
}
|
||||
|
||||
var length = pierce.DistanceTo(point);
|
||||
@@ -1114,6 +1146,97 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
&& hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a cutout's lead-in out of the cutout's far half. A lead longer than the cutout is
|
||||
/// deep at its entry, measured straight in from the wall to the center line where the walls
|
||||
/// meet as the cutout erodes, reaches past the center line or runs along a narrow stroke, as
|
||||
/// does one whose own line gets closer to a wall again. It becomes a straight one, generated
|
||||
/// with <paramref name="normal"/>, from that center line (the center of a round hole) or
|
||||
/// from the lead's own length in when that comes first. Any other lead keeps its shape, and
|
||||
/// one that leaves a cutout other than a round hole is left for the lead check to refuse.
|
||||
/// </summary>
|
||||
public static LeadIn StopAtCenterLine(Shape hole, Vector point, LeadIn leadIn, double normal, RotationType turn)
|
||||
{
|
||||
if (leadIn is NoLeadIn || !hole.IsClosed())
|
||||
return leadIn;
|
||||
var pierce = leadIn.GetPiercePoint(point, normal, turn);
|
||||
var length = pierce.DistanceTo(point);
|
||||
if (!double.IsFinite(length) || length <= Math.Tolerance.Epsilon)
|
||||
return leadIn;
|
||||
|
||||
var circle = hole.Entities.Count == 1 ? hole.Entities[0] as Circle : null;
|
||||
var along = pierce - point;
|
||||
var inward = Inward(normal);
|
||||
// A pierce as far from every wall as it is deep has not come near another wall.
|
||||
var inside = circle == null || pierce.DistanceTo(circle.Center) < circle.Radius;
|
||||
if (inside && Clearance(hole, pierce) >= along.X * inward.X + along.Y * inward.Y - Math.Tolerance.Epsilon)
|
||||
return leadIn;
|
||||
|
||||
var depth = CenterLineDepth(hole, point, normal, length);
|
||||
if (depth <= Math.Tolerance.Epsilon
|
||||
|| inside && depth >= length - Math.Tolerance.Epsilon && !PeaksBeforeItsEnd(hole, point, along))
|
||||
return leadIn;
|
||||
if (circle == null)
|
||||
{
|
||||
var polygon = hole.ToPolygonWithTolerance(Math.Tolerance.ChainTolerance);
|
||||
for (var i = 1; i <= CenterLineSteps; i++)
|
||||
if (!polygon.ContainsPoint(point + along * ((double)i / CenterLineSteps)))
|
||||
return leadIn;
|
||||
}
|
||||
return new LineLeadIn { Length = depth, ApproachAngle = 90 };
|
||||
}
|
||||
|
||||
// How far a straight line in from the wall runs, up to maxDepth, before it reaches the
|
||||
// center line: the distance to the wall grows at a steady rate until a second wall is as
|
||||
// near, then levels off along a stroke or falls.
|
||||
private static double CenterLineDepth(Shape hole, Vector point, double normal, double maxDepth)
|
||||
{
|
||||
if (hole.Entities.Count == 1 && hole.Entities[0] is Circle circle)
|
||||
return System.Math.Min(maxDepth, circle.Radius);
|
||||
|
||||
var inward = Inward(normal);
|
||||
var step = maxDepth / CenterLineSteps;
|
||||
var rate = Clearance(hole, point + inward * step) / step;
|
||||
// A corner's curved lead keeps an edge's normal, which runs along the other edge.
|
||||
if (rate < MinimumCenterLineRate)
|
||||
return maxDepth;
|
||||
bool Rising(double depth) => Clearance(hole, point + inward * (depth + step / CenterLineSteps))
|
||||
- Clearance(hole, point + inward * depth) >= rate / 2 * (step / CenterLineSteps);
|
||||
for (var i = 1; i < CenterLineSteps; i++)
|
||||
{
|
||||
if (Rising(step * i))
|
||||
continue;
|
||||
double low = step * (i - 1), high = step * i;
|
||||
for (var refine = 0; refine < CenterLineRefinements; refine++)
|
||||
{
|
||||
var middle = (low + high) / 2;
|
||||
if (Rising(middle))
|
||||
low = middle;
|
||||
else
|
||||
high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
private static bool PeaksBeforeItsEnd(Shape hole, Vector point, Vector along)
|
||||
{
|
||||
var peak = 0.0;
|
||||
for (var i = 1; i <= CenterLineSteps; i++)
|
||||
{
|
||||
var clearance = Clearance(hole, point + along * ((double)i / CenterLineSteps));
|
||||
if (clearance < peak - Math.Tolerance.Epsilon)
|
||||
return true;
|
||||
peak = System.Math.Max(peak, clearance);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double Clearance(Shape hole, Vector point) => hole.ClosestPointTo(point, out _).DistanceTo(point);
|
||||
|
||||
private static Vector Inward(double normal) => new(System.Math.Cos(normal), System.Math.Sin(normal));
|
||||
|
||||
/// <summary>
|
||||
/// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the
|
||||
/// normal to generate it with.
|
||||
@@ -1127,6 +1250,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
// Bounds the pole search on long, thin holes; the result is checked before use.
|
||||
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)
|
||||
{
|
||||
return contourType switch
|
||||
@@ -1323,7 +1451,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a break cut (travelling with its scrap on the left) is entered: the end with more
|
||||
/// Where a break cut (traveling with its scrap on the left) is entered: the end with more
|
||||
/// scrap before <paramref name="contours"/>, the unit direction into the scrap there, and
|
||||
/// whether that is the cut's far end.
|
||||
/// </summary>
|
||||
|
||||
@@ -19,10 +19,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
/// <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 pierce clearance when the cutout has room for it, else
|
||||
/// almost to its pole of inaccessibility. Each target, an entry the planner ranked, gets the
|
||||
/// 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 centre line next to the entry, and
|
||||
/// half that depth: a letter stroke is pierced on its own center line next to the entry, and
|
||||
/// only a target with no stroke beside it falls back to the deepest place. The lead then runs
|
||||
/// straight from the pierce to the wall.
|
||||
/// </summary>
|
||||
@@ -30,7 +30,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
/// <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 centre line, with room for every place the
|
||||
/// not fit: deep enough to stay near the center line, with room for every place the
|
||||
/// cutout is about as wide as its widest.
|
||||
/// </summary>
|
||||
public const double DepthFraction = 0.9;
|
||||
@@ -47,10 +47,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
/// too small to erode is pierced at its pole for every target. Nothing for an open or
|
||||
/// degenerate contour.
|
||||
/// </summary>
|
||||
public static ErodedPierces Find(Shape hole, double clearance, IReadOnlyList<Vector> targets, int max)
|
||||
public static ErodedPierces Find(Shape hole, double depth, IReadOnlyList<Vector> targets, int max)
|
||||
{
|
||||
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|
||||
|| !double.IsFinite(clearance))
|
||||
|| !double.IsFinite(depth))
|
||||
return ErodedPierces.None;
|
||||
|
||||
// The inscribed radius, from the true wall: the deepest any erosion can go.
|
||||
@@ -64,10 +64,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
|
||||
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.
|
||||
var depth = System.Math.Min(System.Math.Max(clearance, 0), inscribed * DepthFraction);
|
||||
depth = System.Math.Min(System.Math.Max(depth, 0), inscribed * DepthFraction);
|
||||
var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision));
|
||||
var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
|
||||
if (depth + margin >= inscribed - tolerance)
|
||||
@@ -84,7 +86,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
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 centre line, near
|
||||
// narrower than the cutout's widest place is still pierced on its own center line, near
|
||||
// the entry, rather than only at the widest place.
|
||||
var pierces = new List<Vector>();
|
||||
var shallowest = depth;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
/// <summary>
|
||||
/// A rapid the head must make raised because it passes over parts already cut, which may have
|
||||
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing: a
|
||||
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>. Rapid checks accept
|
||||
/// a crossing on a marked rapid. Posts write their machine's head raise before every marked rapid,
|
||||
/// and refuse to post a marked program when they have none, since the rapid is unsafe head down.
|
||||
/// See docs/cutting-planner.md.
|
||||
/// </summary>
|
||||
public static class HeadRaise
|
||||
{
|
||||
/// <summary>The comment immediately before a rapid made with the head raised.</summary>
|
||||
public const string Marker = "HEAD RAISE";
|
||||
|
||||
public static bool IsMarker(ICode code) => code is Comment { Value: Marker };
|
||||
|
||||
/// <summary>Whether <paramref name="program"/>'s own codes hold a marker.</summary>
|
||||
public static bool IsMarked(Program program)
|
||||
{
|
||||
if (program?.Codes == null)
|
||||
return false;
|
||||
foreach (var code in program.Codes)
|
||||
if (IsMarker(code))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For each of <paramref name="codes"/>, whether it is a rapid made with the head raised. A
|
||||
/// marker must be followed directly by a rapid; anything else is a malformed program
|
||||
/// (<see cref="ArgumentException"/>), since a post could raise before the wrong move.
|
||||
/// </summary>
|
||||
public static bool[] RaisedMask(IReadOnlyList<ICode> codes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(codes);
|
||||
var mask = new bool[codes.Count];
|
||||
for (var i = 0; i < codes.Count; i++)
|
||||
{
|
||||
if (!IsMarker(codes[i]))
|
||||
continue;
|
||||
if (i + 1 >= codes.Count || codes[i + 1] is not RapidMove)
|
||||
throw new ArgumentException("A head-raise marker is not followed by a rapid.");
|
||||
mask[i + 1] = true;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A copy of <paramref name="program"/> with a marker before each of its own codes at
|
||||
/// <paramref name="rapids"/>, which must be rapids not marked already.
|
||||
/// </summary>
|
||||
public static Program Mark(Program program, IReadOnlyCollection<int> rapids)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(program);
|
||||
ArgumentNullException.ThrowIfNull(rapids);
|
||||
var marked = RaisedMask(program.Codes);
|
||||
var copy = (Program)program.Clone();
|
||||
for (var i = copy.Codes.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!rapids.Contains(i))
|
||||
continue;
|
||||
if (copy.Codes[i] is not RapidMove || marked[i])
|
||||
throw new ArgumentException("Only an unmarked rapid can be marked for a head raise.");
|
||||
copy.Codes.Insert(i, new Comment(Marker));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses a nest a post cannot write, before any output is created: a marked rapid needs
|
||||
/// the post's head-raise code, and its markers must be well formed.
|
||||
/// </summary>
|
||||
public static void EnsurePostable(Nest nest, bool hasCode, string postName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
foreach (var plate in nest.Plates)
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part?.Program == null || !IsMarked(part.Program))
|
||||
continue;
|
||||
var name = DisplayPerimeterTail.PartName(part);
|
||||
if (!hasCode)
|
||||
throw new InvalidOperationException(
|
||||
$"{name} has rapids that must raise the head over parts already cut, but the {postName} "
|
||||
+ "post has no head-raise code. Enter it in the post's configuration before posting.");
|
||||
try
|
||||
{
|
||||
RaisedMask(part.Program.Codes);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,16 @@ namespace OpenNest
|
||||
double width,
|
||||
double depth,
|
||||
IEnumerable<double> centers,
|
||||
double removalMinutes = DefaultRemovalMinutes
|
||||
double removalMinutes = DefaultRemovalMinutes,
|
||||
double? clearance = null
|
||||
)
|
||||
: this(
|
||||
width,
|
||||
depth,
|
||||
(centers ?? throw new ArgumentNullException(nameof(centers)))
|
||||
.Select(c => new RailClamp(ClampEdge.OriginSide, c)),
|
||||
removalMinutes
|
||||
removalMinutes,
|
||||
clearance
|
||||
)
|
||||
{ }
|
||||
|
||||
@@ -41,7 +43,8 @@ namespace OpenNest
|
||||
double width,
|
||||
double depth,
|
||||
IEnumerable<RailClamp> clamps,
|
||||
double removalMinutes = DefaultRemovalMinutes
|
||||
double removalMinutes = DefaultRemovalMinutes,
|
||||
double? clearance = null
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(clamps);
|
||||
@@ -60,6 +63,11 @@ namespace OpenNest
|
||||
nameof(removalMinutes),
|
||||
"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>();
|
||||
foreach (var clamp in clamps)
|
||||
@@ -77,6 +85,7 @@ namespace OpenNest
|
||||
Width = width;
|
||||
Depth = depth;
|
||||
RemovalMinutes = removalMinutes;
|
||||
Clearance = clearance;
|
||||
Clamps = sorted.ToArray();
|
||||
Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray();
|
||||
}
|
||||
@@ -93,6 +102,14 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
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>
|
||||
/// 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
|
||||
@@ -103,9 +120,9 @@ namespace OpenNest
|
||||
/// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary>
|
||||
public IReadOnlyList<double> Centers { get; }
|
||||
|
||||
/// <summary>A copy with the same size and removal time and these clamps.</summary>
|
||||
/// <summary>A copy with the same size, removal time and clearance and these clamps.</summary>
|
||||
public ClampRail WithClamps(IEnumerable<RailClamp> clamps) =>
|
||||
new(Width, Depth, clamps, RemovalMinutes);
|
||||
new(Width, Depth, clamps, RemovalMinutes, Clearance);
|
||||
|
||||
/// <summary>The <c>clamp-N</c> id of the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
|
||||
public static string IdOf(int index) => $"clamp-{index + 1}";
|
||||
@@ -225,20 +242,22 @@ namespace OpenNest
|
||||
&& Width.Equals(other.Width)
|
||||
&& Depth.Equals(other.Depth)
|
||||
&& RemovalMinutes.Equals(other.RemovalMinutes)
|
||||
&& Nullable.Equals(Clearance, other.Clearance)
|
||||
&& Clamps.SequenceEqual(other.Clamps);
|
||||
|
||||
public override bool Equals(object obj) => Equals(obj as ClampRail);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clamps.Count);
|
||||
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clearance, Clamps.Count);
|
||||
foreach (var clamp in Clamps)
|
||||
hash = HashCode.Combine(hash, clamp);
|
||||
return hash;
|
||||
}
|
||||
|
||||
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) =>
|
||||
clamp.Edge == ClampEdge.OriginSide
|
||||
|
||||
+131
-13
@@ -11,9 +11,13 @@ namespace OpenNest
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// <summary>A stretch of a cut-off's line, along its axis, left uncut.</summary>
|
||||
public readonly record struct CutOffGap(double Start, double End);
|
||||
|
||||
public class CutOff
|
||||
{
|
||||
private const double OffsetTolerance = 0.001;
|
||||
private IReadOnlyList<CutOffGap> gaps = System.Array.Empty<CutOffGap>();
|
||||
|
||||
public Vector Position { get; set; }
|
||||
public CutOffAxis Axis { get; set; }
|
||||
@@ -21,6 +25,66 @@ namespace OpenNest
|
||||
public double? EndLimit { get; set; }
|
||||
public Drawing Drawing { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stretches between the limits left uncut, so the line splits into pieces as it does
|
||||
/// around a part; a trim of a middle piece makes one from crossing line to crossing line
|
||||
/// (<see cref="CutOffTrim"/>). Kept sorted along the axis, with touching gaps merged and
|
||||
/// empty or nonfinite ones dropped.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CutOffGap> Gaps
|
||||
{
|
||||
get => gaps;
|
||||
set => gaps = Normalize(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The nominal line from <paramref name="start"/> to <paramref name="end"/> with its gaps
|
||||
/// taken out, in ascending order: the pieces a consumer of the limits treats as cut.
|
||||
/// </summary>
|
||||
public IReadOnlyList<(double Start, double End)> Pieces(double start, double end) =>
|
||||
Pieces(start, end, gaps);
|
||||
|
||||
internal static IReadOnlyList<(double Start, double End)> Pieces(double start, double end,
|
||||
IReadOnlyList<CutOffGap> gaps)
|
||||
{
|
||||
var (low, high) = (System.Math.Min(start, end), System.Math.Max(start, end));
|
||||
var pieces = new List<(double Start, double End)>();
|
||||
if (gaps.Count == 0)
|
||||
{
|
||||
pieces.Add((low, high));
|
||||
return pieces;
|
||||
}
|
||||
var current = low;
|
||||
foreach (var gap in gaps)
|
||||
{
|
||||
if (gap.End <= current || gap.Start >= high)
|
||||
continue;
|
||||
if (gap.Start > current)
|
||||
pieces.Add((current, gap.Start));
|
||||
current = System.Math.Max(current, gap.End);
|
||||
}
|
||||
if (current < high)
|
||||
pieces.Add((current, high));
|
||||
return pieces;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CutOffGap> Normalize(IEnumerable<CutOffGap> value)
|
||||
{
|
||||
var sorted = (value ?? Enumerable.Empty<CutOffGap>())
|
||||
.Where(g => double.IsFinite(g.Start) && double.IsFinite(g.End) && g.End > g.Start)
|
||||
.OrderBy(g => g.Start)
|
||||
.ToList();
|
||||
var merged = new List<CutOffGap>();
|
||||
foreach (var gap in sorted)
|
||||
{
|
||||
if (merged.Count > 0 && gap.Start <= merged[^1].End + Math.Tolerance.Epsilon)
|
||||
merged[^1] = merged[^1] with { End = System.Math.Max(merged[^1].End, gap.End) };
|
||||
else
|
||||
merged.Add(gap);
|
||||
}
|
||||
return merged.Count == 0 ? System.Array.Empty<CutOffGap>() : merged.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the
|
||||
/// segment reaching that edge cuts with the head height locked; zero for none. Stock can
|
||||
@@ -189,7 +253,7 @@ namespace OpenNest
|
||||
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts
|
||||
/// one continuous straight line on the definition's axis across the whole sheet, so it
|
||||
/// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
|
||||
/// line on the sheet edge, a stale or unrecognised program) does not separate the sheet.
|
||||
/// line on the sheet edge, a stale or unrecognized program) does not separate the sheet.
|
||||
/// </summary>
|
||||
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet)
|
||||
{
|
||||
@@ -262,7 +326,8 @@ namespace OpenNest
|
||||
{
|
||||
var bounds = plate.BoundingBox(includeParts: false);
|
||||
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
|
||||
var overtravel = LockedFarEdge(plate) is { High: true } ? 0 : settings.Overtravel;
|
||||
var lockedFarEdge = LockedFarEdge(plate);
|
||||
var overtravel = lockedFarEdge is { High: true } ? 0 : settings.Overtravel;
|
||||
|
||||
double lineStart,
|
||||
lineEnd,
|
||||
@@ -281,6 +346,13 @@ namespace OpenNest
|
||||
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
|
||||
}
|
||||
|
||||
// An outlined sheet is cut only where the line crosses material: each crossing is a
|
||||
// span of its own, started at the material's edge and overtraveled past the far
|
||||
// one, within the user's limits.
|
||||
var spans = plate.Outline == null
|
||||
? new List<(double Start, double End)> { (lineStart, lineEnd) }
|
||||
: MaterialSpans(plate, cutPosition, lineStart, lineEnd, settings.Overtravel, lockedFarEdge);
|
||||
|
||||
var exclusions = new List<(double Start, double End)>();
|
||||
|
||||
foreach (var part in plate.Parts)
|
||||
@@ -313,6 +385,8 @@ namespace OpenNest
|
||||
);
|
||||
exclusions.AddRange(partExclusions);
|
||||
}
|
||||
foreach (var gap in gaps)
|
||||
exclusions.Add((gap.Start, gap.End));
|
||||
|
||||
exclusions.Sort((a, b) => a.Start.CompareTo(b.Start));
|
||||
var merged = new List<(double Start, double End)>();
|
||||
@@ -325,27 +399,71 @@ namespace OpenNest
|
||||
}
|
||||
|
||||
var segments = new List<(double Start, double End)>();
|
||||
var current = lineStart;
|
||||
|
||||
foreach (var ex in merged)
|
||||
foreach (var (spanStart, spanEnd) in spans)
|
||||
{
|
||||
var clampedStart = System.Math.Max(ex.Start, lineStart);
|
||||
var clampedEnd = System.Math.Min(ex.End, lineEnd);
|
||||
var current = spanStart;
|
||||
|
||||
if (clampedStart > current)
|
||||
segments.Add((current, clampedStart));
|
||||
foreach (var ex in merged)
|
||||
{
|
||||
var clampedStart = System.Math.Max(ex.Start, spanStart);
|
||||
var clampedEnd = System.Math.Min(ex.End, spanEnd);
|
||||
|
||||
current = System.Math.Max(current, clampedEnd);
|
||||
if (clampedStart > current)
|
||||
segments.Add((current, clampedStart));
|
||||
|
||||
current = System.Math.Max(current, clampedEnd);
|
||||
}
|
||||
|
||||
if (current < spanEnd)
|
||||
segments.Add((current, spanEnd));
|
||||
}
|
||||
|
||||
if (current < lineEnd)
|
||||
segments.Add((current, lineEnd));
|
||||
|
||||
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the line at <paramref name="cutPosition"/> crosses the sheet's outlined material,
|
||||
/// in ascending order along the axis and clipped to the line's limits. Each span starts at
|
||||
/// the material edge and runs <paramref name="overtravel"/> past the far one unless a limit
|
||||
/// or a head-locked far edge ends it there.
|
||||
/// </summary>
|
||||
private List<(double Start, double End)> MaterialSpans(Plate plate, double cutPosition,
|
||||
double lineStart, double lineEnd, double overtravel, (double Edge, bool High)? lockedFarEdge)
|
||||
{
|
||||
var vertical = Axis == CutOffAxis.Vertical;
|
||||
var crossings = new List<double>();
|
||||
foreach (var path in plate.Outline.Material(plate.Outline.PlacementOrigin(plate.Quadrant)))
|
||||
{
|
||||
for (var i = 0; i < path.Count; i++)
|
||||
{
|
||||
var a = path[i];
|
||||
var b = path[(i + 1) % path.Count];
|
||||
var (aAcross, aAlong) = vertical ? (a.x, a.y) : (a.y, a.x);
|
||||
var (bAcross, bAlong) = vertical ? (b.x, b.y) : (b.y, b.x);
|
||||
// Half-open on the across axis, so a line through a vertex counts one edge.
|
||||
if (aAcross <= cutPosition == bAcross <= cutPosition)
|
||||
continue;
|
||||
crossings.Add(aAlong + (bAlong - aAlong) * (cutPosition - aAcross) / (bAcross - aAcross));
|
||||
}
|
||||
}
|
||||
crossings.Sort();
|
||||
|
||||
var spans = new List<(double Start, double End)>();
|
||||
for (var i = 0; i + 1 < crossings.Count; i += 2)
|
||||
{
|
||||
var start = System.Math.Max(crossings[i], lineStart);
|
||||
var end = crossings[i + 1];
|
||||
var limited = EndLimit != null && end >= EndLimit.Value
|
||||
|| lockedFarEdge is var (edge, high) && high && end == edge;
|
||||
end = System.Math.Min(limited ? end : end + overtravel, lineEnd);
|
||||
if (end > start)
|
||||
spans.Add((start, end));
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
private static readonly List<(double Start, double End)> EmptyExclusions = new();
|
||||
|
||||
private List<(double Start, double End)> GetPartExclusions(
|
||||
|
||||
@@ -8,9 +8,9 @@ namespace OpenNest
|
||||
/// One placed cutting segment of a cut-off: the part that cuts it, where its cut runs along
|
||||
/// the cut-off's axis, and the span of the nominal line it stands for, from the end of the
|
||||
/// segment before it (or the line's start) to the start of the one after it (or the line's
|
||||
/// end). The parts the nominal line passes through lie in the gaps between segments, so each
|
||||
/// is inside the span of the segment on either side of it; a segment is cut before exactly
|
||||
/// those parts.
|
||||
/// end), stopping short of a <see cref="CutOff.Gaps">gap</see> between them. The parts the
|
||||
/// nominal line passes through lie in the gaps between segments, so each is inside the span
|
||||
/// of the segment on either side of it; a segment is cut before exactly those parts.
|
||||
/// </summary>
|
||||
public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd);
|
||||
|
||||
@@ -55,6 +55,14 @@ namespace OpenNest
|
||||
var (part, start, end) = measured[i];
|
||||
var spanStart = i == 0 ? System.Math.Min(low, start) : measured[i - 1].End;
|
||||
var spanEnd = i == measured.Count - 1 ? System.Math.Max(high, end) : measured[i + 1].Start;
|
||||
// A gap between two segments belongs to neither: each span stops at its edge.
|
||||
foreach (var gap in definition.Gaps)
|
||||
{
|
||||
if (i > 0 && gap.End <= start + Math.Tolerance.Epsilon && gap.End > spanStart)
|
||||
spanStart = gap.End;
|
||||
if (i < measured.Count - 1 && gap.Start >= end - Math.Tolerance.Epsilon && gap.Start < spanEnd)
|
||||
spanEnd = gap.Start;
|
||||
}
|
||||
result.Add(new CutOffSegment(part, start, end, spanStart, spanEnd));
|
||||
}
|
||||
result.AddRange(whole);
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// The limits and gaps one cut-off takes when a <see cref="CutOffEdit"/> is applied; null
|
||||
/// <paramref name="Gaps"/> keeps the cut-off's gaps.
|
||||
/// </summary>
|
||||
public sealed record CutOffLimitChange(CutOff CutOff, double? StartLimit, double? EndLimit,
|
||||
IReadOnlyList<CutOffGap> Gaps = null)
|
||||
{
|
||||
/// <summary>The gaps the cut-off has once the change is applied.</summary>
|
||||
public IReadOnlyList<CutOffGap> NewGaps => Gaps ?? CutOff.Gaps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A worked-out trim, extend or swap: the new limits and gaps of each cut-off it changes, or
|
||||
/// why it was refused. Nothing changes until <see cref="Apply"/>; the caller then regenerates
|
||||
/// the plate's cut-offs and places their segments in the cutting order.
|
||||
/// </summary>
|
||||
public sealed class CutOffEdit
|
||||
{
|
||||
private CutOffEdit(IReadOnlyList<CutOffLimitChange> changes, string refusal, string notice)
|
||||
{
|
||||
Changes = changes;
|
||||
Refusal = refusal;
|
||||
Notice = notice;
|
||||
}
|
||||
|
||||
public IReadOnlyList<CutOffLimitChange> Changes { get; }
|
||||
|
||||
/// <summary>Why the edit cannot be made; null when it can.</summary>
|
||||
public string Refusal { get; }
|
||||
|
||||
/// <summary>Something the operator should know once it is made, such as a head-locked overcut turning off.</summary>
|
||||
public string Notice { get; }
|
||||
|
||||
public bool Succeeded => Refusal == null;
|
||||
|
||||
internal static CutOffEdit Refused(string reason) => new(System.Array.Empty<CutOffLimitChange>(), reason, null);
|
||||
|
||||
internal static CutOffEdit Of(IReadOnlyList<CutOffLimitChange> changes, string notice) => new(changes, null, notice);
|
||||
|
||||
public void Apply()
|
||||
{
|
||||
if (!Succeeded)
|
||||
throw new System.InvalidOperationException(Refusal);
|
||||
foreach (var change in Changes)
|
||||
{
|
||||
change.CutOff.StartLimit = change.StartLimit;
|
||||
change.CutOff.EndLimit = change.EndLimit;
|
||||
if (change.Gaps != null)
|
||||
change.CutOff.Gaps = change.Gaps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trims and extends cut-offs against the cut-offs that cross them, by changing only their
|
||||
/// start and end limits and their gaps (see docs/automatic-scrap-cutoffs.md, "Trim and
|
||||
/// extend"). A trim stops on the crossing line's center, inside its kerf; trimming a piece
|
||||
/// between two crossing lines leaves a gap from one center to the other. An extend closes the
|
||||
/// gap at the picked end, or runs the line's end to the next crossing line beyond it, or to
|
||||
/// the sheet edge, which clears the limit.
|
||||
/// </summary>
|
||||
public static class CutOffTrim
|
||||
{
|
||||
public const string NotOnPlate = "That cut-off is not on this plate.";
|
||||
public const string NothingToTrimTo = "No cut-off crosses this line to trim it to; delete it instead.";
|
||||
public const string InGap = "That stretch of the line is already uncut.";
|
||||
public const string TooShort = "Trimming here would leave a line shorter than the minimum segment length.";
|
||||
public const string AtSheetEdge = "This end already runs to the sheet edge.";
|
||||
public const string NotAJunction = "These cut-offs do not meet with one running through and the other stopping at it.";
|
||||
public const string GapAtJunction = "A gap in one of these lines meets the junction; extend to close it before swapping.";
|
||||
public const string OvercutOff = "The head-locked overcut is off while this line stops short of the far edge.";
|
||||
public const string OvercutOn = "The head-locked overcut is back on at the far edge.";
|
||||
|
||||
/// <summary>
|
||||
/// Removes the piece of <paramref name="target"/> picked at <paramref name="pick"/>, between
|
||||
/// the crossing cut-offs on either side of it, or a gap or the line's end where there is no
|
||||
/// crossing on that side. An end piece moves that end's limit; a piece between two crossing
|
||||
/// lines becomes a gap, merged with any gap it touches.
|
||||
/// </summary>
|
||||
public static CutOffEdit Trim(Plate plate, CutOff target, Vector pick, CutOffSettings settings)
|
||||
{
|
||||
if (!plate.CutOffs.Contains(target))
|
||||
return CutOffEdit.Refused(NotOnPlate);
|
||||
var (start, end) = NominalSpan(plate, target);
|
||||
var at = System.Math.Clamp(Along(target, pick), start, end);
|
||||
var pieces = target.Pieces(start, end);
|
||||
var piece = pieces.Where(p => at >= p.Start - Tolerance.Epsilon && at <= p.End + Tolerance.Epsilon)
|
||||
.Cast<(double Start, double End)?>().FirstOrDefault();
|
||||
if (piece is not var (pieceStart, pieceEnd))
|
||||
return CutOffEdit.Refused(InGap);
|
||||
|
||||
var crossings = Crossings(plate, target)
|
||||
.Where(c => c > pieceStart + Tolerance.Epsilon && c < pieceEnd - Tolerance.Epsilon)
|
||||
.ToList();
|
||||
var low = crossings.Where(c => c <= at).DefaultIfEmpty(pieceStart).Max();
|
||||
var high = crossings.Where(c => c > at).DefaultIfEmpty(pieceEnd).Min();
|
||||
if (pieces.Count == 1 && low == start && high == end)
|
||||
return CutOffEdit.Refused(NothingToTrimTo);
|
||||
|
||||
var remaining = new List<(double Start, double End)>();
|
||||
foreach (var (from, to) in pieces)
|
||||
{
|
||||
if (from < low)
|
||||
remaining.Add((from, System.Math.Min(to, low)));
|
||||
if (to > high)
|
||||
remaining.Add((System.Math.Max(from, high), to));
|
||||
}
|
||||
var minimum = System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon);
|
||||
if (remaining.Count == 0
|
||||
|| remaining.Any(p => (p.End == low || p.Start == high) && p.End - p.Start < minimum))
|
||||
return CutOffEdit.Refused(TooShort);
|
||||
return Single(plate, Change(target, start, end, remaining));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lengthens <paramref name="target"/> at the end of the piece nearer <paramref name="pick"/>:
|
||||
/// a gap there closes; the line's own end runs to the next crossing cut-off beyond it, or to
|
||||
/// the sheet edge when there is none. A cut-off sitting at the current end is skipped, so
|
||||
/// picking again extends one more step. A pick inside a gap closes that gap.
|
||||
/// </summary>
|
||||
public static CutOffEdit Extend(Plate plate, CutOff target, Vector pick)
|
||||
{
|
||||
if (!plate.CutOffs.Contains(target))
|
||||
return CutOffEdit.Refused(NotOnPlate);
|
||||
var (start, end) = NominalSpan(plate, target);
|
||||
var at = Along(target, pick);
|
||||
var gaps = target.Gaps;
|
||||
var inside = gaps.Where(g => at > g.Start && at < g.End).ToList();
|
||||
if (inside.Count == 0)
|
||||
{
|
||||
var pieces = target.Pieces(start, end);
|
||||
var piece = pieces.OrderBy(p => Distance(at, p)).First();
|
||||
var high = System.Math.Abs(at - piece.End) <= System.Math.Abs(at - piece.Start);
|
||||
var edge = high ? piece.End : piece.Start;
|
||||
inside = gaps.Where(g => System.Math.Abs((high ? g.Start : g.End) - edge) <= Tolerance.Epsilon).ToList();
|
||||
if (inside.Count == 0)
|
||||
{
|
||||
return ExtendEnd(plate, target, high, out var limit)
|
||||
? Single(plate, new CutOffLimitChange(target,
|
||||
high ? target.StartLimit : limit, high ? limit : target.EndLimit))
|
||||
: CutOffEdit.Refused(AtSheetEdge);
|
||||
}
|
||||
}
|
||||
return Single(plate, new CutOffLimitChange(target, target.StartLimit, target.EndLimit,
|
||||
gaps.Except(inside).ToList()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops a new <paramref name="target"/> at the first crossing cut-off from its start edge,
|
||||
/// the origin side unless <paramref name="settings"/> cut toward the origin. It stays full
|
||||
/// length, with no changes, when nothing crosses it or the line left would be shorter than
|
||||
/// the minimum segment length. <paramref name="target"/> need not be on the plate yet.
|
||||
/// </summary>
|
||||
public static CutOffEdit StopAtFirstCrossing(Plate plate, CutOff target, CutOffSettings settings)
|
||||
{
|
||||
var (start, end) = NominalSpan(plate, target);
|
||||
var crossings = Crossings(plate, target)
|
||||
.Where(c => c > start + Tolerance.Epsilon && c < end - Tolerance.Epsilon)
|
||||
.ToList();
|
||||
if (crossings.Count == 0)
|
||||
return CutOffEdit.Of(System.Array.Empty<CutOffLimitChange>(), null);
|
||||
|
||||
var towardOrigin = settings?.CutDirection == CutDirection.TowardOrigin;
|
||||
var startsHigh = towardOrigin == CutOff.FarEdgeIsHigh(target.Axis, plate.Quadrant);
|
||||
var stop = startsHigh ? crossings.Max() : crossings.Min();
|
||||
var remaining = startsHigh ? end - stop : stop - start;
|
||||
if (remaining < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
|
||||
return CutOffEdit.Of(System.Array.Empty<CutOffLimitChange>(), null);
|
||||
return startsHigh
|
||||
? Single(plate, new CutOffLimitChange(target, stop, target.EndLimit))
|
||||
: Single(plate, new CutOffLimitChange(target, target.StartLimit, stop));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Swaps which of two meeting cut-offs runs through: the one that stopped at the junction
|
||||
/// extends past it (as <see cref="Extend"/> does), and the one that ran through is trimmed
|
||||
/// there, losing the side away from the plate's origin and any gaps on it. Only the
|
||||
/// origin-side sheet edge is certain, so the trimmed line keeps starting there. Refused when
|
||||
/// a gap in either line meets the junction.
|
||||
/// </summary>
|
||||
public static CutOffEdit Swap(Plate plate, CutOff first, CutOff second, CutOffSettings settings)
|
||||
{
|
||||
if (!plate.CutOffs.Contains(first) || !plate.CutOffs.Contains(second))
|
||||
return CutOffEdit.Refused(NotOnPlate);
|
||||
if (first != second && first.Axis != second.Axis
|
||||
&& (TouchesGap(first, Line(second)) || TouchesGap(second, Line(first))))
|
||||
return CutOffEdit.Refused(GapAtJunction);
|
||||
if (Junction(plate, first, second) is not var (through, stopper, point))
|
||||
return CutOffEdit.Refused(NotAJunction);
|
||||
|
||||
var (start, end) = NominalSpan(plate, through);
|
||||
var farHigh = CutOff.FarEdgeIsHigh(through.Axis, plate.Quadrant);
|
||||
var kept = through.Pieces(start, end)
|
||||
.Select(p => farHigh ? (p.Start, System.Math.Min(p.End, point)) : (System.Math.Max(p.Start, point), p.End))
|
||||
.Where(p => p.Item2 > p.Item1)
|
||||
.ToList();
|
||||
var adjacent = kept.FirstOrDefault(p => (farHigh ? p.Item2 : p.Item1) == point);
|
||||
if (kept.Count == 0
|
||||
|| adjacent.Item2 - adjacent.Item1 < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
|
||||
return CutOffEdit.Refused(TooShort);
|
||||
var throughChange = farHigh
|
||||
? new CutOffLimitChange(through, through.StartLimit, point, GapsBetween(kept))
|
||||
: new CutOffLimitChange(through, point, through.EndLimit, GapsBetween(kept));
|
||||
|
||||
var (stopStart, stopEnd) = NominalSpan(plate, stopper);
|
||||
var stopperPoint = Line(through);
|
||||
var high = System.Math.Abs(stopperPoint - stopEnd) <= System.Math.Abs(stopperPoint - stopStart);
|
||||
if (!ExtendEnd(plate, stopper, high, out var limit))
|
||||
return CutOffEdit.Refused(NotAJunction);
|
||||
var stopperChange = high
|
||||
? new CutOffLimitChange(stopper, stopper.StartLimit, limit)
|
||||
: new CutOffLimitChange(stopper, limit, stopper.EndLimit);
|
||||
|
||||
var notices = new[] { Notice(plate, throughChange), Notice(plate, stopperChange) }
|
||||
.Where(n => n != null).Distinct().ToList();
|
||||
return CutOffEdit.Of(new[] { stopperChange, throughChange },
|
||||
notices.Count == 0 ? null : string.Join(" ", notices));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two cut-offs meeting within <paramref name="tolerance"/> of <paramref name="point"/>,
|
||||
/// one running through the junction and the other stopping at it; null when there is none.
|
||||
/// </summary>
|
||||
public static (CutOff Through, CutOff Stopper)? JunctionAt(Plate plate, Vector point, double tolerance)
|
||||
{
|
||||
if (plate?.CutOffs == null)
|
||||
return null;
|
||||
(CutOff, CutOff)? best = null;
|
||||
var bestDistance = double.MaxValue;
|
||||
foreach (var vertical in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical))
|
||||
foreach (var horizontal in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Horizontal))
|
||||
{
|
||||
if (Junction(plate, vertical, horizontal) is not var (through, stopper, _))
|
||||
continue;
|
||||
var distance = new Vector(vertical.Position.X, horizontal.Position.Y).DistanceTo(point);
|
||||
if (distance <= tolerance && distance < bestDistance)
|
||||
{
|
||||
best = (through, stopper);
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where <paramref name="cutOff"/>'s line runs along its axis: its limits, or the sheet
|
||||
/// edges where it has none. Overtravel is not part of the nominal line, and neither does it
|
||||
/// take out the gaps (see <see cref="CutOff.Pieces(double, double)"/>).
|
||||
/// </summary>
|
||||
public static (double Start, double End) NominalSpan(Plate plate, CutOff cutOff)
|
||||
{
|
||||
var sheet = plate.BoundingBox(includeParts: false);
|
||||
var vertical = cutOff.Axis == CutOffAxis.Vertical;
|
||||
return (cutOff.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
|
||||
cutOff.EndLimit ?? (vertical ? sheet.Top : sheet.Right));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The stretches of the nominal line a change removes and adds, along the axis, in ascending
|
||||
/// order: what a preview marks.
|
||||
/// </summary>
|
||||
public static (IReadOnlyList<(double Start, double End)> Removed, IReadOnlyList<(double Start, double End)> Added)
|
||||
Difference(Plate plate, CutOffLimitChange change)
|
||||
{
|
||||
var cutOff = change.CutOff;
|
||||
var sheet = plate.BoundingBox(includeParts: false);
|
||||
var vertical = cutOff.Axis == CutOffAxis.Vertical;
|
||||
var before = cutOff.Pieces(NominalSpan(plate, cutOff).Start, NominalSpan(plate, cutOff).End);
|
||||
var after = CutOff.Pieces(change.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
|
||||
change.EndLimit ?? (vertical ? sheet.Top : sheet.Right), change.NewGaps);
|
||||
return (Subtract(before, after), Subtract(after, before));
|
||||
}
|
||||
|
||||
private static List<(double Start, double End)> Subtract(
|
||||
IReadOnlyList<(double Start, double End)> from, IReadOnlyList<(double Start, double End)> remove)
|
||||
{
|
||||
var result = new List<(double Start, double End)>();
|
||||
foreach (var piece in from)
|
||||
{
|
||||
var current = piece.Start;
|
||||
foreach (var (start, end) in remove.Where(r => r.End > piece.Start && r.Start < piece.End))
|
||||
{
|
||||
if (start > current + Tolerance.Epsilon)
|
||||
result.Add((current, start));
|
||||
current = System.Math.Max(current, end);
|
||||
}
|
||||
if (piece.End > current + Tolerance.Epsilon)
|
||||
result.Add((current, piece.End));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static (CutOff Through, CutOff Stopper, double Point)? Junction(Plate plate, CutOff first, CutOff second)
|
||||
{
|
||||
if (first == second || first.Axis == second.Axis)
|
||||
return null;
|
||||
var firstAt = Line(second);
|
||||
var secondAt = Line(first);
|
||||
if (TouchesGap(first, firstAt) || TouchesGap(second, secondAt))
|
||||
return null;
|
||||
var (firstStart, firstEnd) = NominalSpan(plate, first);
|
||||
var (secondStart, secondEnd) = NominalSpan(plate, second);
|
||||
if (!Within(firstAt, firstStart, firstEnd) || !Within(secondAt, secondStart, secondEnd))
|
||||
return null;
|
||||
var firstStops = AtEnd(firstAt, firstStart, firstEnd);
|
||||
var secondStops = AtEnd(secondAt, secondStart, secondEnd);
|
||||
if (firstStops == secondStops)
|
||||
return null;
|
||||
return firstStops ? (second, first, secondAt) : (first, second, firstAt);
|
||||
}
|
||||
|
||||
// Whether one of the cut-off's gaps contains or ends at the value along its axis.
|
||||
private static bool TouchesGap(CutOff cutOff, double along) =>
|
||||
cutOff.Gaps.Any(g => Within(along, g.Start, g.End));
|
||||
|
||||
// The limit an end takes when extended: the next crossing line beyond it, or null for the
|
||||
// sheet edge. False when that end already runs to the sheet edge.
|
||||
private static bool ExtendEnd(Plate plate, CutOff target, bool high, out double? limit)
|
||||
{
|
||||
limit = null;
|
||||
if ((high ? target.EndLimit : target.StartLimit) == null)
|
||||
return false;
|
||||
var (start, end) = NominalSpan(plate, target);
|
||||
var crossings = Crossings(plate, target);
|
||||
if (high)
|
||||
{
|
||||
var next = crossings.Where(c => c > end + Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Min();
|
||||
limit = double.IsNaN(next) ? null : next;
|
||||
}
|
||||
else
|
||||
{
|
||||
var next = crossings.Where(c => c < start - Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Max();
|
||||
limit = double.IsNaN(next) ? null : next;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Where the perpendicular cut-offs whose cut pieces reach this one cross it, along its axis.
|
||||
private static List<double> Crossings(Plate plate, CutOff target)
|
||||
{
|
||||
var line = Line(target);
|
||||
return plate.CutOffs
|
||||
.Where(other => other != target && other.Axis != target.Axis)
|
||||
.Where(other =>
|
||||
{
|
||||
var (start, end) = NominalSpan(plate, other);
|
||||
return other.Pieces(start, end).Any(p => Within(line, p.Start, p.End));
|
||||
})
|
||||
.Select(Line)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// The change that leaves exactly the given pieces of the nominal line from start to end: a
|
||||
// limit stays null while its end stays at the sheet edge.
|
||||
private static CutOffLimitChange Change(CutOff target, double start, double end,
|
||||
IReadOnlyList<(double Start, double End)> pieces)
|
||||
{
|
||||
var first = pieces[0].Start;
|
||||
var last = pieces[^1].End;
|
||||
return new CutOffLimitChange(target,
|
||||
target.StartLimit == null && first == start ? null : first,
|
||||
target.EndLimit == null && last == end ? null : last,
|
||||
GapsBetween(pieces));
|
||||
}
|
||||
|
||||
private static List<CutOffGap> GapsBetween(IReadOnlyList<(double Start, double End)> pieces)
|
||||
{
|
||||
var gaps = new List<CutOffGap>();
|
||||
for (var i = 1; i < pieces.Count; i++)
|
||||
if (pieces[i].Start > pieces[i - 1].End)
|
||||
gaps.Add(new CutOffGap(pieces[i - 1].End, pieces[i].Start));
|
||||
return gaps;
|
||||
}
|
||||
|
||||
private static CutOffEdit Single(Plate plate, CutOffLimitChange change) =>
|
||||
CutOffEdit.Of(new[] { change }, Notice(plate, change));
|
||||
|
||||
private static string Notice(Plate plate, CutOffLimitChange change)
|
||||
{
|
||||
var cutOff = change.CutOff;
|
||||
if (!(cutOff.HeadLockOvercut > 0))
|
||||
return null;
|
||||
var high = CutOff.FarEdgeIsHigh(cutOff.Axis, plate.Quadrant);
|
||||
var before = (high ? cutOff.EndLimit : cutOff.StartLimit) == null;
|
||||
var after = (high ? change.EndLimit : change.StartLimit) == null;
|
||||
return before == after ? null : after ? OvercutOn : OvercutOff;
|
||||
}
|
||||
|
||||
// The cut-off's own coordinate across its axis: X for a vertical line, Y for a horizontal one.
|
||||
private static double Line(CutOff cutOff) =>
|
||||
cutOff.Axis == CutOffAxis.Vertical ? cutOff.Position.X : cutOff.Position.Y;
|
||||
|
||||
private static double Along(CutOff cutOff, Vector point) =>
|
||||
cutOff.Axis == CutOffAxis.Vertical ? point.Y : point.X;
|
||||
|
||||
private static double Distance(double value, (double Start, double End) piece) =>
|
||||
value < piece.Start ? piece.Start - value : value > piece.End ? value - piece.End : 0;
|
||||
|
||||
private static bool Within(double value, double start, double end) =>
|
||||
value >= start - Tolerance.Epsilon && value <= end + Tolerance.Epsilon;
|
||||
|
||||
private static bool AtEnd(double value, double start, double end) =>
|
||||
System.Math.Abs(value - start) <= Tolerance.Epsilon || System.Math.Abs(value - end) <= Tolerance.Epsilon;
|
||||
}
|
||||
}
|
||||
@@ -33,15 +33,21 @@ public static class ClampCutDiagnostics
|
||||
var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
|
||||
var low = farHigh ? 0 : overcut;
|
||||
var high = farHigh ? overcut : 0;
|
||||
var start = cut.Axis == CutOffAxis.Vertical
|
||||
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom - low)
|
||||
: new Vector(cut.StartLimit ?? sheet.Left - low, cut.Position.Y);
|
||||
var end = cut.Axis == CutOffAxis.Vertical
|
||||
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top + high)
|
||||
: new Vector(cut.EndLimit ?? sheet.Right + high, cut.Position.Y);
|
||||
var vertical = cut.Axis == CutOffAxis.Vertical;
|
||||
var from = cut.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left) - low;
|
||||
var to = cut.EndLimit ?? (vertical ? sheet.Top : sheet.Right) + high;
|
||||
// A clamp wholly inside a gap meets no cut.
|
||||
foreach (var zone in zones)
|
||||
if (Intersects(start, end, zone.Bounds, out var location))
|
||||
findings.Add(new CutOffClampFinding(index, zone.Id, location));
|
||||
foreach (var (pieceStart, pieceEnd) in cut.Pieces(from, to))
|
||||
{
|
||||
var start = vertical ? new Vector(cut.Position.X, pieceStart) : new Vector(pieceStart, cut.Position.Y);
|
||||
var end = vertical ? new Vector(cut.Position.X, pieceEnd) : new Vector(pieceEnd, cut.Position.Y);
|
||||
if (Intersects(start, end, zone.Bounds, out var location))
|
||||
{
|
||||
findings.Add(new CutOffClampFinding(index, zone.Id, location));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings.AsReadOnly();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
@@ -12,7 +13,9 @@ public sealed class OverlapGeometryStamp
|
||||
private readonly Plate plate;
|
||||
private readonly Entry[] entries;
|
||||
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
|
||||
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End, long Overcut)[] cutoffs;
|
||||
// Gaps are replaced, never edited in place, so their reference stands for their content.
|
||||
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End,
|
||||
IReadOnlyList<CutOffGap> Gaps, long Overcut)[] cutoffs;
|
||||
private readonly (long Width, long Length, int Quadrant) sheet;
|
||||
|
||||
private OverlapGeometryStamp(Plate plate)
|
||||
@@ -25,7 +28,7 @@ public sealed class OverlapGeometryStamp
|
||||
zones = new (string, long, long, long, long)[plate.ClampZones.Count];
|
||||
for (var i = 0; i < zones.Length; i++)
|
||||
zones[i] = ZoneState(plate.ClampZones[i]);
|
||||
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?, long)[plate.CutOffs.Count];
|
||||
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?, IReadOnlyList<CutOffGap>, long)[plate.CutOffs.Count];
|
||||
for (var i = 0; i < cutoffs.Length; i++)
|
||||
cutoffs[i] = CutOffState(plate.CutOffs[i]);
|
||||
}
|
||||
@@ -36,12 +39,12 @@ public sealed class OverlapGeometryStamp
|
||||
zone?.Bounds is not { } box ? default
|
||||
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
|
||||
|
||||
private static (CutOff, CutOffAxis, long, long, long?, long?, long) CutOffState(CutOff cut) =>
|
||||
private static (CutOff, CutOffAxis, long, long, long?, long?, IReadOnlyList<CutOffGap>, long) CutOffState(CutOff cut) =>
|
||||
cut == null ? default
|
||||
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
|
||||
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
|
||||
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null,
|
||||
Bits(cut.HeadLockOvercut));
|
||||
cut.Gaps, Bits(cut.HeadLockOvercut));
|
||||
|
||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||
|
||||
|
||||
@@ -48,13 +48,13 @@ internal static class OverlapMarker
|
||||
point = default;
|
||||
var minY = outline.Min(vertex => vertex.Y);
|
||||
var maxY = outline.Max(vertex => vertex.Y);
|
||||
var centre = (minY + maxY) / 2;
|
||||
var center = (minY + maxY) / 2;
|
||||
double below = minY, above = maxY;
|
||||
foreach (var vertex in outline.Concat(holes.SelectMany(hole => hole)))
|
||||
{
|
||||
if (vertex.Y <= centre && vertex.Y > below)
|
||||
if (vertex.Y <= center && vertex.Y > below)
|
||||
below = vertex.Y;
|
||||
else if (vertex.Y > centre && vertex.Y < above)
|
||||
else if (vertex.Y > center && vertex.Y < above)
|
||||
above = vertex.Y;
|
||||
}
|
||||
var y = (below + above) / 2;
|
||||
|
||||
@@ -31,11 +31,17 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||
}
|
||||
// Sampling can hide a crossing or tangency between curves. Reject native
|
||||
// contour contact before asking the polygon approximation about containment.
|
||||
// A contact lies within both entities' bounds, so shapes whose bounds are clear by more
|
||||
// than any native query's allowance cannot touch: a part with thousands of holes does
|
||||
// not query every pair.
|
||||
var boxes = shapes.Select(shape => shape.BoundingBox).ToList();
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
for (var j = 0; j < i; j++)
|
||||
{
|
||||
if (ClearOfNativeQueries(boxes[i], boxes[j]))
|
||||
continue;
|
||||
shapes[i].Intersects(shapes[j], out var intersections);
|
||||
if (intersections.Count > 0)
|
||||
throw new ArgumentException("Native material contours cross or touch.");
|
||||
@@ -44,23 +50,70 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
|
||||
var outer = ordered[0];
|
||||
var holes = ordered.Skip(1).ToList();
|
||||
var outerBounds = BoundsOf(outer);
|
||||
var holeBounds = holes.Select(BoundsOf).ToList();
|
||||
for (var i = 0; i < holes.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (BoundariesTouch(outer, holes[i], cancellationToken)
|
||||
|| !Inside(outer, holes[i].Vertices[0]))
|
||||
if (BoundariesTouch(outer, outerBounds, holes[i], holeBounds[i], cancellationToken)
|
||||
|| !Inside(outer, outerBounds, holes[i].Vertices[0]))
|
||||
throw new ArgumentException("Contours must have one outer with strictly internal holes.");
|
||||
for (var j = 0; j < i; j++)
|
||||
{
|
||||
if (BoundariesTouch(holes[i], holes[j], cancellationToken)
|
||||
|| Inside(holes[i], holes[j].Vertices[0])
|
||||
|| Inside(holes[j], holes[i].Vertices[0]))
|
||||
// Exact polygon tests: holes whose vertex bounds are apart can neither touch
|
||||
// nor contain each other's vertices.
|
||||
if (!Overlap(holeBounds[i], holeBounds[j]))
|
||||
continue;
|
||||
if (BoundariesTouch(holes[i], holeBounds[i], holes[j], holeBounds[j], cancellationToken)
|
||||
|| Inside(holes[i], holeBounds[i], holes[j].Vertices[0])
|
||||
|| Inside(holes[j], holeBounds[j], holes[i].Vertices[0]))
|
||||
throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
|
||||
}
|
||||
}
|
||||
return new OverlapMaterial(outer, holes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The gap beyond which no native intersection query counts two entities as touching: ten
|
||||
/// times the 0.00001 bounding-box allowance of native intersections, as the planner's
|
||||
/// <c>PostVerificationGeometry.Extent.ClearMargin</c>.
|
||||
/// </summary>
|
||||
private const double NativeClearMargin = 1e-3;
|
||||
|
||||
// True only when both boxes are finite and apart by more than the native allowance on some axis.
|
||||
private static bool ClearOfNativeQueries(Box a, Box b) =>
|
||||
IsFinite(a) && IsFinite(b)
|
||||
&& (a.Right + NativeClearMargin < b.Left || b.Right + NativeClearMargin < a.Left
|
||||
|| a.Top + NativeClearMargin < b.Bottom || b.Top + NativeClearMargin < a.Bottom);
|
||||
|
||||
private static bool IsFinite(Box box) => double.IsFinite(box.Left) && double.IsFinite(box.Right)
|
||||
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top);
|
||||
|
||||
/// <summary>Exact vertex bounds of a sampled polygon, for skipping segment and winding tests.</summary>
|
||||
private readonly record struct Bounds(double MinX, double MinY, double MaxX, double MaxY)
|
||||
{
|
||||
internal bool Contains(Vector point) =>
|
||||
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
|
||||
}
|
||||
|
||||
private static Bounds BoundsOf(Polygon polygon)
|
||||
{
|
||||
double minX = double.PositiveInfinity, minY = double.PositiveInfinity,
|
||||
maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
|
||||
foreach (var vertex in polygon.Vertices)
|
||||
{
|
||||
minX = System.Math.Min(minX, vertex.X);
|
||||
minY = System.Math.Min(minY, vertex.Y);
|
||||
maxX = System.Math.Max(maxX, vertex.X);
|
||||
maxY = System.Math.Max(maxY, vertex.Y);
|
||||
}
|
||||
return new(minX, minY, maxX, maxY);
|
||||
}
|
||||
|
||||
// Closed-interval overlap: two segments that touch share a point inside both bounds.
|
||||
private static bool Overlap(Bounds a, Bounds b) =>
|
||||
a.MaxX >= b.MinX && b.MaxX >= a.MinX && a.MaxY >= b.MinY && b.MaxY >= a.MinY;
|
||||
|
||||
internal OverlapMaterial Transform(double rotation, Vector offset) =>
|
||||
new(TransformPolygon(Outer, rotation, offset),
|
||||
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
|
||||
@@ -171,13 +224,23 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||
throw new ArgumentException("Material contour could not be completely triangulated.");
|
||||
}
|
||||
|
||||
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken)
|
||||
// Exact: a segment of a whose bounds are clear of b's bounds touches no segment of b.
|
||||
private static bool BoundariesTouch(Polygon a, Bounds aBounds, Polygon b, Bounds bBounds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Overlap(aBounds, bBounds))
|
||||
return false;
|
||||
for (var i = 0; i + 1 < a.Vertices.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var start = a.Vertices[i];
|
||||
var end = a.Vertices[i + 1];
|
||||
var segment = new Bounds(System.Math.Min(start.X, end.X), System.Math.Min(start.Y, end.Y),
|
||||
System.Math.Max(start.X, end.X), System.Math.Max(start.Y, end.Y));
|
||||
if (!Overlap(segment, bBounds))
|
||||
continue;
|
||||
for (var j = 0; j + 1 < b.Vertices.Count; j++)
|
||||
if (SegmentsTouch(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1]))
|
||||
if (SegmentsTouch(start, end, b.Vertices[j], b.Vertices[j + 1]))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -200,6 +263,10 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||
&& point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y);
|
||||
|
||||
// Boundary contact is rejected before this winding-number test is used for topology.
|
||||
// A point outside the polygon's vertex bounds has winding zero.
|
||||
private static bool Inside(Polygon polygon, Bounds bounds, Vector point) =>
|
||||
bounds.Contains(point) && Inside(polygon, point);
|
||||
|
||||
private static bool Inside(Polygon polygon, Vector point)
|
||||
{
|
||||
var winding = 0;
|
||||
|
||||
@@ -348,10 +348,10 @@ public static class PlateOverlapAnalyzer
|
||||
new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top),
|
||||
new Vector(zone.Left, zone.Bottom), // Regions repeat their closing vertex exactly.
|
||||
};
|
||||
var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
|
||||
var center = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
|
||||
return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
||||
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, centre,
|
||||
centre);
|
||||
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, center,
|
||||
center);
|
||||
}
|
||||
|
||||
private static Polygon RectanglePolygon(Box box, Vector offset)
|
||||
|
||||
@@ -95,7 +95,7 @@ public sealed class PlateOverlapPair
|
||||
/// its pole of inaccessibility, or an interior scan-line point when a thin island defeats
|
||||
/// the bounded pole search. Markers, hit testing and inspection rings use this point.
|
||||
/// A tolerance-free tiny-clamp finding (a clamp narrower than the chord tolerance) keeps
|
||||
/// its conservative whole-clamp region, so its marker is the clamp's centre, within half
|
||||
/// its conservative whole-clamp region, so its marker is the clamp's center, within half
|
||||
/// the clamp's size of the material. Returned by value.
|
||||
/// </summary>
|
||||
public Vector Marker { get; }
|
||||
|
||||
@@ -49,7 +49,7 @@ internal static class PostVerificationGeometry
|
||||
/// <summary>
|
||||
/// The relative rounding allowance of native line/circle intersections. Their discriminant
|
||||
/// b^2 - 4ac cancels: its absolute error stays below about 30 units of roundoff times
|
||||
/// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's centre to the line's start), so a
|
||||
/// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's center to the line's start), so a
|
||||
/// spurious root can lie at most about 1e-7 times the span of both extents beyond the true
|
||||
/// distance between line and circle (about 2e-9 is seen in practice). A long lead beside a
|
||||
/// small circle needs the full check; a short lead beside a nearby part does not.
|
||||
@@ -68,6 +68,10 @@ internal static class PostVerificationGeometry
|
||||
return IsClearOf(other, ClearMargin + reach + SpanRounding * span);
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="point"/> lies within the extent, its edges included.</summary>
|
||||
internal bool Contains(Vector point) =>
|
||||
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
|
||||
|
||||
/// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary>
|
||||
internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit
|
||||
&& (y > MaxY + ClearMargin || y < MinY - ClearMargin);
|
||||
@@ -397,9 +401,9 @@ internal static class PostVerificationGeometry
|
||||
points.Add(point);
|
||||
// Existing exact line/ray contact semantics guard native queries which
|
||||
// suppress very short or nearly parallel intersections. Uncertainty refuses.
|
||||
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other))
|
||||
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other, points))
|
||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this))
|
||||
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this, points))
|
||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||
return points;
|
||||
|
||||
@@ -415,16 +419,35 @@ internal static class PostVerificationGeometry
|
||||
/// endpoint (both stop half-way when both endpoints are contained): a line meeting a
|
||||
/// tangent arc at their shared vertex is otherwise rediscovered there as a contact the
|
||||
/// native query missed, because rounding can drop the tangent root of its quadratic.
|
||||
/// For the same reason a ray hit within <see cref="RaySeamTolerance"/> of a
|
||||
/// <paramref name="recorded"/> contact is that contact: an arc lead ending tangent on a
|
||||
/// straight edge, mid-edge, has its end recorded and its double root dropped.
|
||||
/// </summary>
|
||||
private static bool ExactLineContact(Curve line, Curve curve)
|
||||
private static bool ExactLineContact(Curve line, Curve curve, IReadOnlyList<Vector> recorded)
|
||||
{
|
||||
var direction = (line.End - line.Start) * (1 / line.Length);
|
||||
var startRecorded = curve.Contains(line.Start);
|
||||
var endRecorded = curve.Contains(line.End);
|
||||
var forward = endRecorded ? startRecorded ? line.Length / 2 : 0 : line.Length;
|
||||
var backward = startRecorded ? endRecorded ? line.Length / 2 : 0 : line.Length;
|
||||
return (forward > 0 && curve.ContactAfterStart(line.Start, direction, forward))
|
||||
|| (backward > 0 && curve.ContactAfterStart(line.End, direction * -1, backward));
|
||||
return (forward > 0 && Unrecorded(line.Start, direction, forward))
|
||||
|| (backward > 0 && Unrecorded(line.End, direction * -1, backward));
|
||||
|
||||
bool Unrecorded(Vector origin, Vector ray, double length)
|
||||
{
|
||||
var hits = new List<double>();
|
||||
curve.ContactAfterStart(origin, ray, length, hits);
|
||||
foreach (var distance in hits)
|
||||
{
|
||||
var hit = origin + ray * distance;
|
||||
var known = false;
|
||||
foreach (var point in recorded)
|
||||
known |= point.DistanceTo(hit) <= RaySeamTolerance;
|
||||
if (!known)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -450,11 +473,11 @@ internal static class PostVerificationGeometry
|
||||
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|
||||
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
|
||||
|
||||
// How far past r^2 a squared distance from the centre still counts as touching the arc.
|
||||
// How far past r^2 a squared distance from the center still counts as touching the arc.
|
||||
// For small arcs this band reaches well beyond the radius (sqrt(1e-8) = 1e-4 as r -> 0).
|
||||
private double ContactSlack => Epsilon * System.Math.Max(1, Radius * 2);
|
||||
|
||||
// The largest distance from the centre that ContactAfterStart can count as contact.
|
||||
// The largest distance from the center that ContactAfterStart can count as contact.
|
||||
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
|
||||
|
||||
internal bool ContactAfterStart(Vector origin, Vector direction, double length,
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace OpenNest.Geometry
|
||||
/// <summary>
|
||||
/// Polygon overlap test with hole subtraction, used by engine and validation per-pair
|
||||
/// checks. It was written as the reference for a GPU kernel; that port is on hold in
|
||||
/// favour of Clipper2, starting with the material-overlap diagnostic. Engine callers
|
||||
/// favor of Clipper2, starting with the material-overlap diagnostic. Engine callers
|
||||
/// stay on this kernel until a Clipper2 near-contact differential and the layout gate
|
||||
/// pass.
|
||||
/// <para>
|
||||
|
||||
@@ -44,7 +44,7 @@ namespace OpenNest.Geometry
|
||||
/// <summary>
|
||||
/// Aligns <paramref name="newProgram"/> onto <paramref name="oldProgram"/>.
|
||||
/// Both programs are treated as read-only. Throws
|
||||
/// <see cref="OperationCanceledException"/> only when cancelled before any
|
||||
/// <see cref="OperationCanceledException"/> only when canceled before any
|
||||
/// measurable work; work limits reached mid-run are reported as reasons on
|
||||
/// the result instead.
|
||||
/// </summary>
|
||||
|
||||
@@ -10,14 +10,14 @@ namespace OpenNest.Geometry
|
||||
public static void Optimize(IList<Arc> arcs) =>
|
||||
MergePass(
|
||||
arcs,
|
||||
(list, item, i) => list.GetCoradialArs(item, i),
|
||||
(a, b) => a.IsCoradialTo(b),
|
||||
(Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined)
|
||||
);
|
||||
|
||||
public static void Optimize(IList<Line> lines) =>
|
||||
MergePass(
|
||||
lines,
|
||||
(list, item, i) => list.GetCollinearLines(item, i),
|
||||
(a, b) => a.IsCollinearTo(b),
|
||||
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined)
|
||||
);
|
||||
|
||||
@@ -61,7 +61,7 @@ namespace OpenNest.Geometry
|
||||
|
||||
private static void MergePass<T>(
|
||||
IList<T> items,
|
||||
Func<IList<T>, T, int, List<T>> findCandidates,
|
||||
Func<T, T, bool> related,
|
||||
TryJoin<T> tryJoin
|
||||
)
|
||||
where T : class
|
||||
@@ -69,7 +69,7 @@ namespace OpenNest.Geometry
|
||||
for (var i = 0; i < items.Count; ++i)
|
||||
{
|
||||
var item = items[i];
|
||||
var candidates = findCandidates(items, item, i);
|
||||
var candidates = FindRelated(items, item, i, related);
|
||||
var index = 0;
|
||||
|
||||
while (index < candidates.Count)
|
||||
@@ -214,62 +214,41 @@ namespace OpenNest.Geometry
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<Line> GetCollinearLines(
|
||||
this IList<Line> lines,
|
||||
Line line,
|
||||
int startIndex
|
||||
/// <summary>
|
||||
/// The items from <paramref name="startIndex"/> on, other than <paramref name="item"/>
|
||||
/// itself, that are <paramref name="related"/> to it.
|
||||
/// </summary>
|
||||
private static List<T> FindRelated<T>(
|
||||
IList<T> items,
|
||||
T item,
|
||||
int startIndex,
|
||||
Func<T, T, bool> related
|
||||
)
|
||||
where T : class
|
||||
{
|
||||
var collinearLines = new List<Line>();
|
||||
var matches = new List<T>();
|
||||
|
||||
Parallel.For(
|
||||
startIndex,
|
||||
lines.Count,
|
||||
items.Count,
|
||||
index =>
|
||||
{
|
||||
var compareLine = lines[index];
|
||||
var candidate = items[index];
|
||||
|
||||
if (Object.ReferenceEquals(line, compareLine))
|
||||
if (Object.ReferenceEquals(item, candidate))
|
||||
return;
|
||||
|
||||
if (!line.IsCollinearTo(compareLine))
|
||||
if (!related(item, candidate))
|
||||
return;
|
||||
|
||||
lock (collinearLines)
|
||||
lock (matches)
|
||||
{
|
||||
collinearLines.Add(compareLine);
|
||||
matches.Add(candidate);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return collinearLines;
|
||||
}
|
||||
|
||||
private static List<Arc> GetCoradialArs(this IList<Arc> arcs, Arc arc, int startIndex)
|
||||
{
|
||||
var coradialArcs = new List<Arc>();
|
||||
|
||||
Parallel.For(
|
||||
startIndex,
|
||||
arcs.Count,
|
||||
index =>
|
||||
{
|
||||
var compareArc = arcs[index];
|
||||
|
||||
if (Object.ReferenceEquals(arc, compareArc))
|
||||
return;
|
||||
|
||||
if (!arc.IsCoradialTo(compareArc))
|
||||
return;
|
||||
|
||||
lock (coradialArcs)
|
||||
{
|
||||
coradialArcs.Add(compareArc);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return coradialArcs;
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,7 +192,10 @@ namespace OpenNest.Geometry
|
||||
double t;
|
||||
pts = new List<Vector>();
|
||||
|
||||
if (det.IsEqualTo(0))
|
||||
// A small circle's whole discriminant is tiny: tangent also means the line stays at its rim.
|
||||
var halfChord = det / (4 * a);
|
||||
var depth = circle.Radius - System.Math.Sqrt(System.Math.Max(circle.Radius * circle.Radius - halfChord, 0));
|
||||
if (det.IsEqualTo(0) && depth <= Tolerance.Epsilon)
|
||||
{
|
||||
t = -b / (2 * a);
|
||||
var pt1 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y);
|
||||
|
||||
@@ -69,7 +69,7 @@ public static class KeepOutOverlap
|
||||
/// <summary>
|
||||
/// True when the material (perimeter minus holes) shares positive area with the open
|
||||
/// rectangle: some boundary edge runs through its interior, or its interior lies wholly
|
||||
/// inside the material (tested at its centre).
|
||||
/// inside the material (tested at its center).
|
||||
/// </summary>
|
||||
public static bool MaterialEnters(Polygon perimeter, IReadOnlyList<Polygon> holes, Box box)
|
||||
{
|
||||
@@ -80,12 +80,12 @@ public static class KeepOutOverlap
|
||||
if (RingEnters(hole, box))
|
||||
return true;
|
||||
|
||||
var centre = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2);
|
||||
if (!Contains(perimeter, centre))
|
||||
var center = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2);
|
||||
if (!Contains(perimeter, center))
|
||||
return false;
|
||||
if (holes != null)
|
||||
foreach (var hole in holes)
|
||||
if (Contains(hole, centre))
|
||||
if (Contains(hole, center))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class MaximalRectangles
|
||||
/// <remarks>
|
||||
/// The grid has a line through every vertex coordinate plus <paramref name="divisions"/>
|
||||
/// evenly spaced lines per axis. A cell is free only when no edge passes through its
|
||||
/// interior and its centre is inside the region, so results are exact for regions whose
|
||||
/// interior and its center is inside the region, so results are exact for regions whose
|
||||
/// edges are all horizontal or vertical. Slanted and curved edges are followed as a
|
||||
/// staircase: results stay inside, but can fall short of the true maximum by up to about
|
||||
/// one cell on each side. Rotate the region to search other rectangle angles.
|
||||
@@ -64,9 +64,9 @@ public static class MaximalRectangles
|
||||
var crossings = new List<double>();
|
||||
for (var r = 0; r < rows; r++)
|
||||
{
|
||||
// Even-odd scan along the row's centre line. No vertex lies on it, and an edge that
|
||||
// Even-odd scan along the row's center line. No vertex lies on it, and an edge that
|
||||
// meets it strictly inside a cell has already marked that cell crossed, so each
|
||||
// uncrossed cell is on the same side as its centre.
|
||||
// uncrossed cell is on the same side as its center.
|
||||
var y = (ys[r] + ys[r + 1]) / 2;
|
||||
crossings.Clear();
|
||||
foreach (var path in paths)
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace OpenNest.Geometry
|
||||
/// <summary>
|
||||
/// Bounded search for callers that cannot afford refinement along a long thin shape:
|
||||
/// stops after about <paramref name="maxCells"/> distance evaluations and returns the best
|
||||
/// cell centre so far. When the starting grid would exceed a quarter of the budget it is
|
||||
/// cell center so far. When the starting grid would exceed a quarter of the budget it is
|
||||
/// coarsened so the whole shape is sampled first. The result can lie outside a shape
|
||||
/// thinner than the remaining refinement, so callers must check it.
|
||||
/// </summary>
|
||||
@@ -157,7 +157,7 @@ namespace OpenNest.Geometry
|
||||
|
||||
halfCell = cell.HalfSize / 2;
|
||||
|
||||
// Far from the origin a half-step may no longer move the centre; such a cell
|
||||
// Far from the origin a half-step may no longer move the center; such a cell
|
||||
// cannot be split, and its identical children would multiply without end.
|
||||
if (cell.X - halfCell == cell.X || cell.X + halfCell == cell.X
|
||||
|| cell.Y - halfCell == cell.Y || cell.Y + halfCell == cell.Y)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
@@ -750,64 +750,25 @@ namespace OpenNest.Geometry
|
||||
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
|
||||
/// making the method independent of the original contour winding direction.
|
||||
/// </summary>
|
||||
internal Shape OffsetOutward(double distance)
|
||||
{
|
||||
var poly = ToPolygon();
|
||||
|
||||
if (
|
||||
poly == null
|
||||
|| poly.Vertices.Count < 3
|
||||
|| poly.RotationDirection() == RotationType.CW
|
||||
)
|
||||
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
|
||||
// Shape is CCW — reverse to CW so Left offset goes outward.
|
||||
var copy = new Shape();
|
||||
|
||||
for (var i = Entities.Count - 1; i >= 0; i--)
|
||||
{
|
||||
switch (Entities[i])
|
||||
{
|
||||
case Line l:
|
||||
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
|
||||
break;
|
||||
case Arc a:
|
||||
copy.Entities.Add(
|
||||
new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed)
|
||||
{
|
||||
Layer = a.Layer,
|
||||
}
|
||||
);
|
||||
break;
|
||||
case Circle c:
|
||||
copy.Entities.Add(
|
||||
new Circle(c.Center, c.Radius)
|
||||
{
|
||||
Layer = c.Layer,
|
||||
Rotation = RotationType.CW,
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
}
|
||||
internal Shape OffsetOutward(double distance) =>
|
||||
OffsetLeftWithWinding(distance, RotationType.CW);
|
||||
|
||||
/// <summary>
|
||||
/// Offsets the shape inward by the given distance.
|
||||
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
|
||||
/// making the method independent of the original contour winding direction.
|
||||
/// </summary>
|
||||
internal Shape OffsetInward(double distance)
|
||||
internal Shape OffsetInward(double distance) =>
|
||||
OffsetLeftWithWinding(distance, RotationType.CCW);
|
||||
|
||||
/// <summary>
|
||||
/// Offsets Left after normalizing the contour to <paramref name="winding"/>.
|
||||
/// </summary>
|
||||
private Shape OffsetLeftWithWinding(double distance, RotationType winding)
|
||||
{
|
||||
var poly = ToPolygon();
|
||||
|
||||
if (
|
||||
poly == null
|
||||
|| poly.Vertices.Count < 3
|
||||
|| poly.RotationDirection() == RotationType.CCW
|
||||
)
|
||||
if (poly == null || poly.Vertices.Count < 3 || poly.RotationDirection() == winding)
|
||||
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
|
||||
// Create a reversed copy to avoid mutating shared entity objects.
|
||||
@@ -833,7 +794,7 @@ namespace OpenNest.Geometry
|
||||
new Circle(c.Center, c.Radius)
|
||||
{
|
||||
Layer = c.Layer,
|
||||
Rotation = RotationType.CCW,
|
||||
Rotation = winding,
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -37,10 +37,15 @@ namespace OpenNest.Geometry
|
||||
internal const double IncidenceTolerance = 1e-7;
|
||||
|
||||
// Drawing data fixes arc centers to finite precision, so a tangent joint's arc end
|
||||
// can sit a little past its neighbour's end. A contact this close to a joint takes
|
||||
// the joint's tangents; otherwise the neighbour reads as a second boundary there.
|
||||
// can sit a little past its neighbor's end. A contact this close to a joint takes
|
||||
// the joint's tangents; otherwise the neighbor reads as a second boundary there.
|
||||
internal const double JointTolerance = 1e-6;
|
||||
|
||||
// Loops are recovered with ShapeBuilder's chaining tolerance: a source contour that
|
||||
// closes within it (PEP rounding leaves 1e-6 gaps) is a loop everywhere else, and an
|
||||
// unrecovered loop leaves every contact with the whole part unresolved and blocking.
|
||||
internal const double ChainTolerance = Tolerance.ChainTolerance;
|
||||
|
||||
private readonly List<Entity> entities;
|
||||
private readonly int[] loopOf;
|
||||
private readonly int[] previous;
|
||||
@@ -373,7 +378,7 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance;
|
||||
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= ChainTolerance;
|
||||
|
||||
private static double SignedArea(List<Entity> entities, int first, int last)
|
||||
{
|
||||
|
||||
@@ -791,7 +791,11 @@ namespace OpenNest.Geometry
|
||||
return vertices.ToArray();
|
||||
}
|
||||
|
||||
private static void AddArcExtremeVertices(HashSet<Vector> points, Arc arc)
|
||||
/// <summary>
|
||||
/// Adds the cardinal extreme points of an arc (0°, 90°, 180°, 270°)
|
||||
/// if they fall within the arc's angular span.
|
||||
/// </summary>
|
||||
internal static void AddArcExtremeVertices(ICollection<Vector> points, Arc arc)
|
||||
{
|
||||
var a1 = arc.StartAngle;
|
||||
var a2 = arc.EndAngle;
|
||||
|
||||
+128
-32
@@ -98,6 +98,9 @@ namespace OpenNest
|
||||
// A turned sheet is the same sheet; any other size is no longer the coded item.
|
||||
if (stockItemCodes.Count > 0 && !SameSheet(size, value))
|
||||
stockItemCodes = Array.Empty<string>();
|
||||
// An outline is a particular sheet; any other size is a rectangle again.
|
||||
if (outline != null && (outline.Size.Width != value.Width || outline.Size.Length != value.Length))
|
||||
outline = null;
|
||||
size = value;
|
||||
ResolveClampRail();
|
||||
}
|
||||
@@ -105,6 +108,26 @@ namespace OpenNest
|
||||
|
||||
private Size size;
|
||||
|
||||
/// <summary>
|
||||
/// The sheet's material when it is not a rectangle (see <see cref="SheetOutline"/>), in
|
||||
/// plate coordinates; null for a rectangular sheet. Setting one makes <see cref="Size"/> its
|
||||
/// bounding box, and setting any other size drops it. Only the Irregular whole-job engine
|
||||
/// nests on an outlined sheet; cut-offs are trimmed to it, automatic cut-offs refuse it, and
|
||||
/// <see cref="Area"/> is its material area.
|
||||
/// </summary>
|
||||
public SheetOutline Outline
|
||||
{
|
||||
get { return outline; }
|
||||
set
|
||||
{
|
||||
outline = value;
|
||||
if (value != null)
|
||||
Size = value.Size;
|
||||
}
|
||||
}
|
||||
|
||||
private SheetOutline outline;
|
||||
|
||||
private static bool SameSheet(Size a, Size b) =>
|
||||
a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
|
||||
|
||||
@@ -170,6 +193,14 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public ClampRail ClampRail { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The gap part material keeps from every clamp zone: the rail's own
|
||||
/// <see cref="OpenNest.ClampRail.Clearance"/>, else the bottom edge spacing (D6), never
|
||||
/// negative. Part spacing never applies to clamps.
|
||||
/// </summary>
|
||||
public double ClampClearance =>
|
||||
System.Math.Max(0, ClampRail?.Clearance ?? EdgeSpacing.Bottom);
|
||||
|
||||
/// <summary>
|
||||
/// The machine support bed this sheet rests on (its nest's, see
|
||||
/// <see cref="Nest.SetSupportBed"/>), or null for none. Display, analysis and support
|
||||
@@ -303,11 +334,13 @@ namespace OpenNest
|
||||
public void RegenerateCutOffs(CutOffSettings settings)
|
||||
{
|
||||
var saved = new Dictionary<CutOff, List<(int Index, double Start, double End)>>();
|
||||
var places = new List<int>();
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!Parts[i].BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
places.Add(i);
|
||||
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
|
||||
if (cutoff != null)
|
||||
{
|
||||
@@ -343,7 +376,7 @@ namespace OpenNest
|
||||
indices[k] = previous = best >= 0 ? best : previous;
|
||||
}
|
||||
return indices;
|
||||
});
|
||||
}, _ => places);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -353,38 +386,78 @@ namespace OpenNest
|
||||
/// from it are added at the end. Callers must remove existing cut-off parts first.
|
||||
/// </summary>
|
||||
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence) =>
|
||||
RegenerateCutOffs(settings, (cutoff, extents) =>
|
||||
sequence != null && sequence.TryGetValue(cutoff, out var index)
|
||||
? Enumerable.Range(index, extents.Count).ToArray() : null);
|
||||
RegenerateCutOffs(settings, null, sequence);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="RegenerateCutOffs(CutOffSettings, IReadOnlyDictionary{CutOff, int})"/>,
|
||||
/// but each cut-off lists one index per segment in ascending order along its axis. A list
|
||||
/// whose length differs from the regenerated segment count places the segments
|
||||
/// consecutively from its smallest index.
|
||||
/// consecutively at its smallest index.
|
||||
/// </summary>
|
||||
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> sequence) =>
|
||||
RegenerateCutOffs(settings, sequence, null);
|
||||
|
||||
/// <summary>
|
||||
/// Places each cut-off's regenerated segments where its saved segments were, among the
|
||||
/// parts saved around them: <paramref name="places"/> lists every saved segment's place;
|
||||
/// <paramref name="firstPlaces"/> gives only the first, for files that recorded no more
|
||||
/// (their segments were consecutive, but how many is unknown). Settings other than the
|
||||
/// saved ones can regenerate a different number of segments; the parts keep their
|
||||
/// places relative to the cut-offs either way. Where only a first place is known the
|
||||
/// count is a guess, so a cut-off that splits the sheet goes last, as sequencing puts it.
|
||||
/// </summary>
|
||||
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> places,
|
||||
IReadOnlyDictionary<CutOff, int> firstPlaces) =>
|
||||
RegenerateCutOffs(settings, (cutoff, extents) =>
|
||||
{
|
||||
if (sequence == null || !sequence.TryGetValue(cutoff, out var indices) || indices == null || indices.Count == 0)
|
||||
return null;
|
||||
if (indices.Count != extents.Count)
|
||||
return Enumerable.Range(indices.Min(), extents.Count).ToArray();
|
||||
// Program order can run toward the origin; the file lists segments ascending.
|
||||
var ascending = Enumerable.Range(0, extents.Count).OrderBy(k => extents[k].Start).ToArray();
|
||||
var result = new int[extents.Count];
|
||||
for (var rank = 0; rank < ascending.Length; rank++)
|
||||
result[ascending[rank]] = indices[rank];
|
||||
return result;
|
||||
});
|
||||
if (places != null && places.TryGetValue(cutoff, out var indices) && indices is { Count: > 0 })
|
||||
{
|
||||
if (indices.Count != extents.Count)
|
||||
return Enumerable.Repeat(indices.Min(), extents.Count).ToArray();
|
||||
// Program order can run toward the origin; the file lists segments ascending.
|
||||
var ascending = Enumerable.Range(0, extents.Count).OrderBy(k => extents[k].Start).ToArray();
|
||||
var result = new int[extents.Count];
|
||||
for (var rank = 0; rank < ascending.Length; rank++)
|
||||
result[ascending[rank]] = indices[rank];
|
||||
return result;
|
||||
}
|
||||
return firstPlaces != null && firstPlaces.TryGetValue(cutoff, out var first)
|
||||
? Enumerable.Repeat(first, extents.Count).ToArray() : null;
|
||||
}, counts =>
|
||||
{
|
||||
var saved = new HashSet<int>();
|
||||
foreach (var list in places?.Values ?? [])
|
||||
saved.UnionWith(list ?? []);
|
||||
if (firstPlaces == null)
|
||||
return saved;
|
||||
// A cut-off saved by its first place alone held consecutive places, as many as it
|
||||
// has segments now unless that runs into the next cut-off's first place.
|
||||
var starts = saved.Concat(firstPlaces.Values).Distinct().Order().ToArray();
|
||||
foreach (var (cutoff, first) in firstPlaces)
|
||||
{
|
||||
var next = starts.FirstOrDefault(start => start > first, int.MaxValue);
|
||||
var count = System.Math.Max(1, System.Math.Min(counts.GetValueOrDefault(cutoff, 1), next - first));
|
||||
saved.UnionWith(Enumerable.Range(first, count));
|
||||
}
|
||||
return saved;
|
||||
}, cutoff => firstPlaces != null && firstPlaces.ContainsKey(cutoff));
|
||||
|
||||
// sequence gives a cut-off's segment indices in program order from its segments' extents
|
||||
// along its axis (NaN when unmeasurable), or null to append them.
|
||||
// sequence gives a cut-off's saved place for each segment in program order, from its
|
||||
// segments' extents along its axis (NaN when unmeasurable), or null to append them.
|
||||
// savedPlaces gives every place cut-off segments held in the saved order, from each
|
||||
// cut-off's regenerated segment count; the parts held the others, in their order.
|
||||
// separateLast says which cut-offs go after everything when they split the sheet.
|
||||
private void RegenerateCutOffs(CutOffSettings settings,
|
||||
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence)
|
||||
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence,
|
||||
Func<IReadOnlyDictionary<CutOff, int>, IEnumerable<int>> savedPlaces,
|
||||
Func<CutOff, bool> separateLast = null)
|
||||
{
|
||||
var cache = BuildPerimeterCache(this);
|
||||
var sheet = BoundingBox(includeParts: false);
|
||||
var placed = new List<(int Index, int Order, Part Part)>();
|
||||
var separating = new List<Part>();
|
||||
var counts = new Dictionary<CutOff, int>();
|
||||
var partCount = Parts.Count;
|
||||
|
||||
// Regenerate and materialize each cut-off's segments
|
||||
foreach (var cutoff in CutOffs)
|
||||
@@ -399,20 +472,34 @@ namespace OpenNest
|
||||
var parts = programs.Select(program => new Part(cutoff.Drawing, program)).ToArray();
|
||||
var extents = parts.Select(part => CutOffSegments.Extent(part, vertical) ?? (double.NaN, double.NaN)).ToArray();
|
||||
var indices = sequence?.Invoke(cutoff, extents);
|
||||
counts[cutoff] = parts.Length;
|
||||
for (var k = 0; k < parts.Length; k++)
|
||||
{
|
||||
if (indices != null && k < indices.Count)
|
||||
// A line splitting the sheet comes after every part and every other cut-off.
|
||||
if (separateLast?.Invoke(cutoff) == true && CutOff.SeparatesSheet(parts[k], cutoff, sheet))
|
||||
separating.Add(parts[k]);
|
||||
else if (indices != null && k < indices.Count)
|
||||
placed.Add((indices[k], placed.Count, parts[k]));
|
||||
else
|
||||
Parts.Add(parts[k]);
|
||||
}
|
||||
}
|
||||
|
||||
// Lowest index first: each insert then lands on its saved index, because every part
|
||||
// sequenced before it is already in place. Segments sharing an index keep their
|
||||
// program order (a later insert at the same index lands before an earlier one).
|
||||
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenByDescending(p => p.Order))
|
||||
Parts.Insert(System.Math.Clamp(index, 0, Parts.Count), part);
|
||||
// Saved places count every cut-off segment then on the plate, which may now be more or
|
||||
// fewer. A segment goes after the parts saved before it and the segments placed before
|
||||
// it, so a changed count never shifts a later cut-off or part. Segments sharing a place
|
||||
// keep their program order.
|
||||
var saved = (savedPlaces?.Invoke(counts) ?? []).Distinct().Order().ToArray();
|
||||
var inserted = 0;
|
||||
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenBy(p => p.Order))
|
||||
{
|
||||
var savedBefore = Array.BinarySearch(saved, index);
|
||||
savedBefore = savedBefore < 0 ? ~savedBefore : savedBefore;
|
||||
var partsBefore = System.Math.Clamp(index - savedBefore, 0, partCount);
|
||||
Parts.Insert(partsBefore + inserted++, part);
|
||||
}
|
||||
foreach (var part in separating)
|
||||
Parts.Add(part);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -456,10 +543,13 @@ namespace OpenNest
|
||||
|
||||
// Leads are cutting paths in scrap, not boundary edges. Chaining them
|
||||
// into the outline can make a closed part appear open and replace its
|
||||
// recesses with a convex hull. Keep the full geometry for the existing
|
||||
// conservative fallback when the material contour really is open.
|
||||
// recesses with a convex hull. A cut-off may cross a lead, so the open-
|
||||
// contour hull also leaves them out: hulling a lead's pierce point would
|
||||
// stop cut-offs far short of the part.
|
||||
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
|
||||
e.Layer != SpecialLayers.Leadout).ToList();
|
||||
if (outline.Count == 0)
|
||||
return null;
|
||||
var profile = new Geometry.ShapeProfile(outline);
|
||||
|
||||
if (profile.Perimeter.IsClosed())
|
||||
@@ -469,7 +559,7 @@ namespace OpenNest
|
||||
return perimeter;
|
||||
}
|
||||
|
||||
var points = entities.CollectPoints();
|
||||
var points = outline.CollectPoints();
|
||||
if (points.Count < 3)
|
||||
return null;
|
||||
|
||||
@@ -526,7 +616,9 @@ namespace OpenNest
|
||||
{
|
||||
const double oneAndHalfPI = System.Math.PI * 1.5;
|
||||
|
||||
var turned = Outline?.Rotated90(rotationDirection);
|
||||
Size = new Size(Size.Length, Size.Width);
|
||||
Outline = turned;
|
||||
|
||||
// After Size swap above, new Size.Width = old Length (old X extent),
|
||||
// new Size.Length = old Width (old Y extent).
|
||||
@@ -632,10 +724,12 @@ namespace OpenNest
|
||||
}
|
||||
|
||||
Rotate(System.Math.PI, centerpt);
|
||||
Outline = Outline?.Rotated180();
|
||||
}
|
||||
else
|
||||
{
|
||||
Rotate(System.Math.PI);
|
||||
Outline = Outline?.Rotated180();
|
||||
Quadrant = (Quadrant + 2) % 4;
|
||||
|
||||
if (Quadrant == 0)
|
||||
@@ -841,7 +935,8 @@ namespace OpenNest
|
||||
/// </example>
|
||||
public void AutoSize(double roundingFactor = 1.0)
|
||||
{
|
||||
if (Parts.Count == 0)
|
||||
// An outlined sheet is a particular piece of material; it cannot be resized to fit.
|
||||
if (Parts.Count == 0 || Outline != null)
|
||||
return;
|
||||
|
||||
var bounds = Parts.GetBoundingBox();
|
||||
@@ -890,7 +985,7 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
|
||||
{
|
||||
if (Parts.Count == 0)
|
||||
if (Parts.Count == 0 || Outline != null)
|
||||
return default;
|
||||
|
||||
var bounds = Parts.GetBoundingBox();
|
||||
@@ -942,12 +1037,13 @@ namespace OpenNest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the area of the top surface of the plate.
|
||||
/// Gets the area of the top surface of the plate: the material inside its
|
||||
/// <see cref="Outline"/> when it has one, otherwise the whole rectangle.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public double Area()
|
||||
{
|
||||
return Size.Width * Size.Length;
|
||||
return Outline?.Area ?? Size.Width * Size.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -133,6 +133,7 @@ namespace OpenNest
|
||||
var followsDefaults = trailing != null
|
||||
&& trailing.Parts.Count == 0
|
||||
&& trailing.CutOffs.Count == 0
|
||||
&& trailing.Outline == null
|
||||
&& trailing.Size.Width == defaults.Size.Width
|
||||
&& trailing.Size.Length == defaults.Size.Length
|
||||
&& trailing.Quadrant == defaults.Quadrant
|
||||
|
||||
@@ -38,10 +38,14 @@ public sealed class PlateRemnant
|
||||
/// (no limit short of either sheet edge) with no part material beyond it defines the drop: the
|
||||
/// sheet past its nominal position, less the plate's kerf for each bounding cut-off line. It
|
||||
/// wins over a larger edge offcut. Otherwise the drop is the largest edge offcut that
|
||||
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks.
|
||||
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks. A plate
|
||||
/// with an <see cref="Plate.Outline"/> reports none.
|
||||
/// </summary>
|
||||
public static PlateRemnant Expected(Plate plate, double minimumDimension)
|
||||
{
|
||||
// No rectangular strip is guaranteed inside an outlined sheet.
|
||||
if (plate.Outline != null)
|
||||
return null;
|
||||
// A part with no material (marks only) bounds nothing.
|
||||
var bounds = plate.Parts
|
||||
.Where(p => !p.BaseDrawing.IsCutOff && p.Program != null)
|
||||
@@ -148,8 +152,8 @@ public sealed class PlateRemnant
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the nominal line runs edge to edge. A head-locked overcut only extends the
|
||||
/// program past the far edge, so the nominal limits decide.
|
||||
/// Whether the nominal line runs edge to edge with no gap on the sheet. A head-locked
|
||||
/// overcut only extends the program past the far edge, so the nominal limits decide.
|
||||
/// </summary>
|
||||
private static bool SpansSheet(CutOff cutOff, Box sheet)
|
||||
{
|
||||
@@ -157,7 +161,8 @@ public sealed class PlateRemnant
|
||||
var alongMin = vertical ? sheet.Bottom : sheet.Left;
|
||||
var alongMax = vertical ? sheet.Top : sheet.Right;
|
||||
return (cutOff.StartLimit is not double start || start <= alongMin + Tolerance.Epsilon)
|
||||
&& (cutOff.EndLimit is not double end || end >= alongMax - Tolerance.Epsilon);
|
||||
&& (cutOff.EndLimit is not double end || end >= alongMax - Tolerance.Epsilon)
|
||||
&& !cutOff.Gaps.Any(gap => gap.End > alongMin + Tolerance.Epsilon && gap.Start < alongMax - Tolerance.Epsilon);
|
||||
}
|
||||
|
||||
private static bool Reaches(Box material, bool vertical, double from, double to)
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// The material of a sheet that is not a rectangle: a remnant, a drop, or a sheet with a
|
||||
/// burned or damaged area. A closed polygon in plate coordinates (the sheet's lower-left
|
||||
/// frame, as <see cref="Plate.ClampZones"/>) with optional obstacle polygons for areas inside
|
||||
/// it that must stay empty. The polygon lies inside the true material and every obstacle
|
||||
/// polygon contains its true area, so chording never adds room. Its bounding box starts at
|
||||
/// the origin and is the sheet's <see cref="Size"/>; rectangular code paths keep using that
|
||||
/// box. Immutable.
|
||||
/// </summary>
|
||||
public sealed class SheetOutline : IEquatable<SheetOutline>
|
||||
{
|
||||
/// <summary>Chord deviation when importing arcs; the outline is also eroded, and each
|
||||
/// obstacle inflated, by this much so the stored polygons never reach past the true edges.</summary>
|
||||
public const double ChordTolerance = SpacingOutline.ChordTolerance;
|
||||
|
||||
private const int Precision = ClipperBridge.Precision;
|
||||
private const double ArcTolerance = 0.001;
|
||||
private const double MiterLimit = 2.0;
|
||||
|
||||
private readonly PathD outline;
|
||||
private readonly PathsD obstacles;
|
||||
private readonly PathsD material;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an outline from its vertices and optional obstacles, each a simple polygon of at
|
||||
/// least three finite vertices with a positive area. Winding is normalized and the whole
|
||||
/// is translated so the outline's bounds start at the origin; an obstacle wholly outside
|
||||
/// the outline is dropped.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">A polygon is missing, degenerate, nonfinite or
|
||||
/// self-intersecting.</exception>
|
||||
public SheetOutline(IEnumerable<Vector> points, IEnumerable<IEnumerable<Vector>> obstacles = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(points);
|
||||
var path = SimplePolygon(points, "outline");
|
||||
var box = Clipper.GetBounds(path);
|
||||
var shift = new PointD(-box.left, -box.top); // RectD top is the minimum Y.
|
||||
outline = Clipper.TranslatePath(path, shift.x, shift.y);
|
||||
Size = new Size(box.Height, box.Width);
|
||||
|
||||
this.obstacles = new PathsD();
|
||||
if (obstacles != null)
|
||||
{
|
||||
var index = 0;
|
||||
foreach (var obstacle in obstacles)
|
||||
{
|
||||
index++;
|
||||
var obstaclePath = Clipper.TranslatePath(SimplePolygon(obstacle, $"obstacle {index}"), shift.x, shift.y);
|
||||
var inside = Clipper.Intersect(new PathsD { outline }, new PathsD { obstaclePath }, FillRule.NonZero, Precision);
|
||||
if (inside.Sum(Clipper.Area) > 0)
|
||||
this.obstacles.Add(obstaclePath);
|
||||
}
|
||||
}
|
||||
|
||||
material = Clipper.Difference(new PathsD { outline }, this.obstacles, FillRule.NonZero, Precision);
|
||||
Area = material.Sum(Clipper.Area);
|
||||
if (!(Area > 0))
|
||||
throw new ArgumentException("The obstacles leave no material inside the outline.", nameof(obstacles));
|
||||
|
||||
Points = outline.Select(p => new Vector(p.x, p.y)).ToArray();
|
||||
Obstacles = this.obstacles
|
||||
.Select(o => (IReadOnlyList<Vector>)o.Select(p => new Vector(p.x, p.y)).ToArray())
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Vertices of the outline, counterclockwise, in plate coordinates.</summary>
|
||||
public IReadOnlyList<Vector> Points { get; }
|
||||
|
||||
/// <summary>Vertices of each obstacle, counterclockwise, in plate coordinates.</summary>
|
||||
public IReadOnlyList<IReadOnlyList<Vector>> Obstacles { get; }
|
||||
|
||||
/// <summary>The bounding box of the outline: the plate's size.</summary>
|
||||
public Size Size { get; }
|
||||
|
||||
/// <summary>Material area: the outline less its obstacles.</summary>
|
||||
public double Area { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the plate's lower-left corner lies in the placement frame of its parts for
|
||||
/// <paramref name="quadrant"/>: -Length on X in quadrants 2 and 3, -Width on Y in 3 and 4,
|
||||
/// exactly as <see cref="ClampZoneFrame"/> and the engine stock translate.
|
||||
/// </summary>
|
||||
public Vector PlacementOrigin(int quadrant) =>
|
||||
new(quadrant is 2 or 3 ? -Size.Length : 0, quadrant is 3 or 4 ? -Size.Width : 0);
|
||||
|
||||
/// <summary>
|
||||
/// The outline less its obstacles, translated by <paramref name="origin"/>: outer paths
|
||||
/// positive, holes negative.
|
||||
/// </summary>
|
||||
public PathsD Material(Vector origin) => Translate(material, origin);
|
||||
|
||||
/// <summary>
|
||||
/// The material eroded by <paramref name="inset"/> (round joins) and translated by
|
||||
/// <paramref name="origin"/>: where a part may lie when it must keep <paramref name="inset"/>
|
||||
/// from every edge and obstacle. Empty when nothing is left. A nonpositive inset returns
|
||||
/// the material itself.
|
||||
/// </summary>
|
||||
public PathsD Usable(double inset, Vector origin)
|
||||
{
|
||||
if (!(inset > 0))
|
||||
return Material(origin);
|
||||
var eroded = Clipper.InflatePaths(material, -inset, JoinType.Round, EndType.Polygon,
|
||||
MiterLimit, Precision, ArcTolerance);
|
||||
eroded.RemoveAll(p => p.Count < 3);
|
||||
return Translate(eroded, origin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a region lies inside the material eroded by <paramref name="inset"/>, allowing
|
||||
/// <paramref name="slack"/> outside it: nothing of <paramref name="region"/> (outer paths
|
||||
/// positive, holes negative, in the frame of <paramref name="origin"/>) is left after
|
||||
/// subtracting the usable area grown by the slack. The test is exact on the stored polygons;
|
||||
/// it never loosens for arcs.
|
||||
/// </summary>
|
||||
public bool Holds(PathsD region, double inset, Vector origin, double slack)
|
||||
{
|
||||
var usable = Usable(inset, origin);
|
||||
if (slack > 0)
|
||||
{
|
||||
usable = Clipper.InflatePaths(usable, slack, JoinType.Miter, EndType.Polygon,
|
||||
MiterLimit, Precision, ArcTolerance);
|
||||
}
|
||||
var outside = Clipper.Difference(region, usable, FillRule.NonZero, Precision);
|
||||
return outside.Sum(p => System.Math.Abs(Clipper.Area(p))) <= 0;
|
||||
}
|
||||
|
||||
/// <summary>This outline turned a quarter turn about the origin, re-normalized to start
|
||||
/// at the origin, as <see cref="Plate.Rotate90"/> turns the sheet.</summary>
|
||||
public SheetOutline Rotated90(RotationType direction)
|
||||
{
|
||||
Vector Turn(Vector p) => direction == RotationType.CW ? new Vector(p.Y, -p.X) : new Vector(-p.Y, p.X);
|
||||
return new SheetOutline(Points.Select(Turn), Obstacles.Select(o => o.Select(Turn)));
|
||||
}
|
||||
|
||||
/// <summary>This outline turned half a turn, re-normalized to start at the origin.</summary>
|
||||
public SheetOutline Rotated180() =>
|
||||
new(Points.Select(p => new Vector(-p.X, -p.Y)), Obstacles.Select(o => o.Select(p => new Vector(-p.X, -p.Y))));
|
||||
|
||||
/// <summary>
|
||||
/// An outline from imported geometry: the largest closed contour becomes the outline and
|
||||
/// every other closed contour an obstacle. Arcs are chorded at <see cref="ChordTolerance"/>,
|
||||
/// then the outline is eroded and each obstacle inflated by that much, so the stored
|
||||
/// polygons stay within the true edges. Marks and open contours are ignored.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">No closed contour with a positive area.</exception>
|
||||
public static SheetOutline FromGeometry(IEnumerable<Entity> entities)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entities);
|
||||
var material = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList();
|
||||
ShapeProfile profile;
|
||||
try
|
||||
{
|
||||
profile = material.Count == 0 ? null : new ShapeProfile(material);
|
||||
}
|
||||
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
|
||||
{
|
||||
throw new ArgumentException("The geometry has no closed outline.", nameof(entities), exception);
|
||||
}
|
||||
if (profile?.Perimeter == null)
|
||||
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
|
||||
|
||||
var perimeter = ClipperBridge.ToPath(ClipperBridge.Flatten(profile.Perimeter, ChordTolerance, circumscribe: false), positive: true);
|
||||
if (perimeter.Count < 3 || !(Clipper.Area(perimeter) > 0))
|
||||
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
|
||||
var outer = Clipper.InflatePaths(new PathsD { perimeter },
|
||||
-ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
|
||||
var outline = outer.Where(p => p.Count >= 3).OrderByDescending(Clipper.Area).FirstOrDefault()
|
||||
?? throw new ArgumentException("The outline is too thin to hold material.", nameof(entities));
|
||||
|
||||
var obstacles = new List<IEnumerable<Vector>>();
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
var hole = ClipperBridge.ToPath(ClipperBridge.Flatten(cutout, ChordTolerance, circumscribe: false), positive: true);
|
||||
if (hole.Count < 3 || !(Clipper.Area(hole) > 0))
|
||||
continue;
|
||||
var grown = Clipper.InflatePaths(new PathsD { hole },
|
||||
ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
|
||||
foreach (var path in grown.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
|
||||
obstacles.Add(path.Select(p => new Vector(p.x, p.y)).ToList());
|
||||
}
|
||||
return new SheetOutline(outline.Select(p => new Vector(p.x, p.y)), obstacles);
|
||||
}
|
||||
|
||||
public bool Equals(SheetOutline other)
|
||||
{
|
||||
if (other is null)
|
||||
return false;
|
||||
if (ReferenceEquals(this, other))
|
||||
return true;
|
||||
if (Points.Count != other.Points.Count || Obstacles.Count != other.Obstacles.Count)
|
||||
return false;
|
||||
for (var i = 0; i < Points.Count; i++)
|
||||
if (!Same(Points[i], other.Points[i]))
|
||||
return false;
|
||||
for (var k = 0; k < Obstacles.Count; k++)
|
||||
{
|
||||
if (Obstacles[k].Count != other.Obstacles[k].Count)
|
||||
return false;
|
||||
for (var i = 0; i < Obstacles[k].Count; i++)
|
||||
if (!Same(Obstacles[k][i], other.Obstacles[k][i]))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public override bool Equals(object obj) => Equals(obj as SheetOutline);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new System.HashCode();
|
||||
hash.Add(Points.Count);
|
||||
hash.Add(Obstacles.Count);
|
||||
foreach (var p in Points)
|
||||
{
|
||||
hash.Add(BitConverter.DoubleToInt64Bits(p.X));
|
||||
hash.Add(BitConverter.DoubleToInt64Bits(p.Y));
|
||||
}
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"{Size} outline, {Points.Count} vertices" + (Obstacles.Count == 0 ? "" : $", {Obstacles.Count} obstacle(s)");
|
||||
|
||||
/// <summary>Exact bits: two outlines are the same stock only when nothing moved.</summary>
|
||||
private static bool Same(Vector a, Vector b) =>
|
||||
BitConverter.DoubleToInt64Bits(a.X) == BitConverter.DoubleToInt64Bits(b.X)
|
||||
&& BitConverter.DoubleToInt64Bits(a.Y) == BitConverter.DoubleToInt64Bits(b.Y);
|
||||
|
||||
private static PathsD Translate(PathsD paths, Vector origin) =>
|
||||
origin.X == 0 && origin.Y == 0
|
||||
? new PathsD(paths.Select(p => new PathD(p)))
|
||||
: Clipper.TranslatePaths(paths, origin.X, origin.Y);
|
||||
|
||||
/// <summary>A counterclockwise copy without the closing or repeated vertices, after
|
||||
/// checking it is one simple polygon of positive area.</summary>
|
||||
private static PathD SimplePolygon(IEnumerable<Vector> points, string what)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(points, nameof(points));
|
||||
var path = new PathD();
|
||||
foreach (var p in points)
|
||||
{
|
||||
if (!double.IsFinite(p.X) || !double.IsFinite(p.Y))
|
||||
throw new ArgumentException($"The {what} has a nonfinite vertex.", nameof(points));
|
||||
if (path.Count > 0 && path[^1].x == p.X && path[^1].y == p.Y)
|
||||
continue;
|
||||
path.Add(new PointD(p.X, p.Y));
|
||||
}
|
||||
if (path.Count > 1 && path[0].x == path[^1].x && path[0].y == path[^1].y)
|
||||
path.RemoveAt(path.Count - 1);
|
||||
if (path.Count < 3)
|
||||
throw new ArgumentException($"The {what} needs at least three distinct vertices.", nameof(points));
|
||||
var area = Clipper.Area(path);
|
||||
if (!(System.Math.Abs(area) > 0))
|
||||
throw new ArgumentException($"The {what} has no area.", nameof(points));
|
||||
if (area < 0)
|
||||
path.Reverse();
|
||||
// Start at the lowest-left vertex so equal polygons list their vertices alike.
|
||||
var start = 0;
|
||||
for (var i = 1; i < path.Count; i++)
|
||||
if (path[i].y < path[start].y || path[i].y == path[start].y && path[i].x < path[start].x)
|
||||
start = i;
|
||||
if (start != 0)
|
||||
path = new PathD(path.Skip(start).Concat(path.Take(start)));
|
||||
// A simple polygon unions to itself: one path of the same area. A figure eight or a
|
||||
// bow tie does not, and a sheet edge that crosses itself is not a sheet.
|
||||
var union = Clipper.Union(new PathsD { path }, new PathsD(), FillRule.NonZero, Precision);
|
||||
var scale = System.Math.Pow(10, -Precision);
|
||||
if (union.Count != 1 || System.Math.Abs(Clipper.Area(union[0]) - System.Math.Abs(area)) > scale * (path.Count + 1) * Perimeter(path))
|
||||
throw new ArgumentException($"The {what} must be a simple polygon that does not cross itself.", nameof(points));
|
||||
return path;
|
||||
}
|
||||
|
||||
private static double Perimeter(PathD path)
|
||||
{
|
||||
var length = 0.0;
|
||||
for (var i = 0; i < path.Count; i++)
|
||||
{
|
||||
var a = path[i];
|
||||
var b = path[(i + 1) % path.Count];
|
||||
length += System.Math.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
|
||||
}
|
||||
return length;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,13 +252,17 @@ public static class DrawingSplitter
|
||||
&& line.Position < bounds.Top - OpenNest.Math.Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
private static List<Box> BuildClipRegions(List<SplitLine> sortedLines, Box bounds)
|
||||
/// <summary>
|
||||
/// The grid of pieces the split lines cut <paramref name="bounds"/> into, row by row
|
||||
/// from the bottom-left.
|
||||
/// </summary>
|
||||
public static List<Box> BuildClipRegions(IEnumerable<SplitLine> lines, Box bounds)
|
||||
{
|
||||
var verticals = sortedLines
|
||||
var verticals = lines
|
||||
.Where(l => l.Axis == CutOffAxis.Vertical)
|
||||
.OrderBy(l => l.Position)
|
||||
.ToList();
|
||||
var horizontals = sortedLines
|
||||
var horizontals = lines
|
||||
.Where(l => l.Axis == CutOffAxis.Horizontal)
|
||||
.OrderBy(l => l.Position)
|
||||
.ToList();
|
||||
|
||||
@@ -360,19 +360,19 @@ namespace OpenNest.Support
|
||||
yield break;
|
||||
|
||||
var step = perimeter / TabSamples;
|
||||
var travelled = 0.0;
|
||||
var traveled = 0.0;
|
||||
var next = step;
|
||||
for (var i = 0; i < outline.Count; i++)
|
||||
{
|
||||
var a = outline[i];
|
||||
var b = outline[(i + 1) % outline.Count];
|
||||
var length = a.DistanceTo(b);
|
||||
while (length > 0 && next < travelled + length)
|
||||
while (length > 0 && next < traveled + length)
|
||||
{
|
||||
yield return a + (b - a) * ((next - travelled) / length);
|
||||
yield return a + (b - a) * ((next - traveled) / length);
|
||||
next += step;
|
||||
}
|
||||
travelled += length;
|
||||
traveled += length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace OpenNest.TabbedCutouts
|
||||
public double Length { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// One open cut per span of the contour between tabs, in contour order, travelling
|
||||
/// One open cut per span of the contour between tabs, in contour order, traveling
|
||||
/// counterclockwise (the slug on the left).
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyList<Entity>> Pieces { get; internal set; }
|
||||
@@ -65,7 +65,7 @@ namespace OpenNest.TabbedCutouts
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab keeps a
|
||||
/// gap from its neighbours around the contour and does not pass the contour's start
|
||||
/// gap from its neighbors around the contour and does not pass the contour's start
|
||||
/// (which would reorder the tabs).
|
||||
/// </summary>
|
||||
public double ClampTabCenter(int index, double center)
|
||||
|
||||
@@ -17,7 +17,7 @@ public interface IBrowsePage
|
||||
/// <summary>
|
||||
/// Browse state shared by the paged server lists: the requested search, sort and page, and the
|
||||
/// last page applied. Every change issues one bounded server query. A response that a later
|
||||
/// request superseded is discarded (and the earlier request is cancelled), so an older result
|
||||
/// request superseded is discarded (and the earlier request is canceled), so an older result
|
||||
/// can never replace a newer one. Call from one thread (the UI thread).
|
||||
/// </summary>
|
||||
public abstract class BrowseSession<TPage, TSort> : IDisposable
|
||||
@@ -130,7 +130,7 @@ public abstract class BrowseSession<TPage, TSort> : IDisposable
|
||||
private async Task<bool> RunAsync()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
// Supersede before cancelling: a request that completes synchronously when cancelled
|
||||
// Supersede before canceling: a request that completes synchronously when canceled
|
||||
// must already see itself as superseded. Each source is released by its own request.
|
||||
var previous = _pending;
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class ClampRailEditor
|
||||
Width = initial.Width;
|
||||
Depth = initial.Depth;
|
||||
RemovalMinutes = initial.RemovalMinutes;
|
||||
Clearance = initial.Clearance;
|
||||
_clamps.AddRange(initial.Clamps.Order());
|
||||
}
|
||||
|
||||
@@ -35,6 +36,9 @@ public sealed class ClampRailEditor
|
||||
/// length, so it is never converted between units.</summary>
|
||||
public double RemovalMinutes { get; set; }
|
||||
|
||||
/// <summary>The gap parts keep from every clamp; null uses each sheet's bottom edge spacing.</summary>
|
||||
public double? Clearance { get; set; }
|
||||
|
||||
/// <summary>Whether saved machine defaults give new nests these clamps.</summary>
|
||||
public bool ApplyToNewNests { get; set; }
|
||||
|
||||
@@ -91,6 +95,7 @@ public sealed class ClampRailEditor
|
||||
Width = converted.Width;
|
||||
Depth = converted.Depth;
|
||||
RemovalMinutes = converted.RemovalMinutes;
|
||||
Clearance = converted.Clearance;
|
||||
ApplyToNewNests = settings.Enabled;
|
||||
_clamps.Clear();
|
||||
_clamps.AddRange(converted.Clamps.Distinct().Order());
|
||||
@@ -99,10 +104,10 @@ public sealed class ClampRailEditor
|
||||
/// <summary>
|
||||
/// The edited rail, or null when no clamp is listed. Throws
|
||||
/// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth, or a
|
||||
/// negative/non-finite removal time.
|
||||
/// negative/non-finite removal time or clearance.
|
||||
/// </summary>
|
||||
public ClampRail? BuildRail() =>
|
||||
_clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes);
|
||||
_clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes, Clearance);
|
||||
|
||||
/// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary>
|
||||
public void ApplyTo(Nest nest)
|
||||
@@ -128,6 +133,11 @@ public sealed class ClampRailEditor
|
||||
nameof(RemovalMinutes),
|
||||
"Clamp removal time must be finite and zero or more."
|
||||
);
|
||||
if (Clearance is double clearance && (!double.IsFinite(clearance) || clearance < 0))
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(Clearance),
|
||||
"Clamp clearance must be finite and zero or more."
|
||||
);
|
||||
var settings = new ClampSettings
|
||||
{
|
||||
Enabled = ApplyToNewNests && _clamps.Count > 0,
|
||||
@@ -135,6 +145,7 @@ public sealed class ClampRailEditor
|
||||
Width = Width,
|
||||
Depth = Depth,
|
||||
RemovalMinutes = RemovalMinutes,
|
||||
Clearance = Clearance,
|
||||
};
|
||||
settings.SetClamps(_clamps);
|
||||
return settings;
|
||||
|
||||
@@ -65,6 +65,12 @@ public sealed class ClampSettings
|
||||
/// </summary>
|
||||
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
|
||||
|
||||
/// <summary>
|
||||
/// The gap parts keep from every clamp, in <see cref="Units"/>; null uses each sheet's
|
||||
/// bottom edge spacing (<see cref="ClampRail.Clearance"/>).
|
||||
/// </summary>
|
||||
public double? Clearance { get; set; }
|
||||
|
||||
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
|
||||
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
|
||||
|
||||
@@ -133,6 +139,7 @@ public sealed class ClampSettings
|
||||
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
|
||||
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
|
||||
RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
|
||||
Clearance = rail?.Clearance,
|
||||
};
|
||||
if (rail != null)
|
||||
settings.SetClamps(rail.Clamps);
|
||||
@@ -150,11 +157,12 @@ public sealed class ClampSettings
|
||||
return null;
|
||||
|
||||
var converted = ConvertedTo(units);
|
||||
return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes);
|
||||
return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes,
|
||||
converted.Clearance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A copy with width, depth and centers expressed in <paramref name="units"/>
|
||||
/// A copy with width, depth, clearance and centers expressed in <paramref name="units"/>
|
||||
/// (inches and millimeters convert at 25.4); <see cref="Enabled"/> and
|
||||
/// <see cref="RemovalMinutes"/> (a time, not a length) are kept unscaled.
|
||||
/// </summary>
|
||||
@@ -168,6 +176,7 @@ public sealed class ClampSettings
|
||||
Width = Width * scale,
|
||||
Depth = Depth * scale,
|
||||
RemovalMinutes = RemovalMinutes,
|
||||
Clearance = Clearance * scale,
|
||||
Centers = Centers.Select(c => c * scale).ToArray(),
|
||||
FarSideCenters = FarSideCenters.Select(c => c * scale).ToArray(),
|
||||
OriginEndCenters = OriginEndCenters.Select(c => c * scale).ToArray(),
|
||||
@@ -207,7 +216,7 @@ public sealed class ClampSettings
|
||||
{
|
||||
json = File.ReadAllText(path);
|
||||
}
|
||||
catch (Exception ex) when (IsUnreadableFile(ex))
|
||||
catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
|
||||
{
|
||||
status = ClampSettingsStatus.Invalid;
|
||||
return Fallback;
|
||||
@@ -238,7 +247,7 @@ public sealed class ClampSettings
|
||||
foreach (var property in document.RootElement.EnumerateObject())
|
||||
values[property.Name] = property.Value.Clone();
|
||||
}
|
||||
catch (Exception ex) when (IsUnreadableFile(ex))
|
||||
catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
|
||||
{
|
||||
status = ClampSettingsStatus.Invalid;
|
||||
return settings;
|
||||
@@ -256,7 +265,7 @@ public sealed class ClampSettings
|
||||
|
||||
if (values.TryGetValue("units", out var units))
|
||||
{
|
||||
if (TryParseUnits(units, out var parsed))
|
||||
if (SettingsFile.TryParseUnits(units, out var parsed))
|
||||
settings.Units = parsed;
|
||||
else
|
||||
ignored = true;
|
||||
@@ -290,6 +299,15 @@ public sealed class ClampSettings
|
||||
ignored = true;
|
||||
}
|
||||
|
||||
// Missing or null keeps the bottom edge spacing; anything else must be a length.
|
||||
if (values.TryGetValue("clearance", out var clearance) && clearance.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
if (TryLength(clearance, out var value) && value >= 0)
|
||||
settings.Clearance = value;
|
||||
else
|
||||
ignored = true;
|
||||
}
|
||||
|
||||
if (values.TryGetValue("centers", out var centers))
|
||||
settings.Centers = ReadCenters(centers, ref ignored);
|
||||
if (values.TryGetValue("farSideCenters", out var farSide))
|
||||
@@ -329,24 +347,7 @@ public sealed class ClampSettings
|
||||
/// </summary>
|
||||
public void Save(string path, int maxRetries = 3)
|
||||
{
|
||||
var json = ToJson();
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < maxRetries - 1)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
SettingsFile.Write(path, ToJson(), maxRetries);
|
||||
}
|
||||
|
||||
/// <summary>The settings as <see cref="Save"/> writes them, for the shared copy.</summary>
|
||||
@@ -362,6 +363,8 @@ public sealed class ClampSettings
|
||||
["removalMinutes"] = RemovalMinutes,
|
||||
["centers"] = Centers,
|
||||
};
|
||||
if (Clearance is double clearance)
|
||||
dto["clearance"] = clearance;
|
||||
// Other edges are written only when used, so origin-side-only files stay unchanged.
|
||||
if (FarSideCenters.Count > 0)
|
||||
dto["farSideCenters"] = FarSideCenters;
|
||||
@@ -392,35 +395,4 @@ public sealed class ClampSettings
|
||||
&& double.IsFinite(value)
|
||||
&& double.IsFinite(value * MillimetersPerInch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts only a defined unit name (case-insensitive); Enum.TryParse would also accept
|
||||
/// numeric strings and comma-joined names.
|
||||
/// </summary>
|
||||
private static bool TryParseUnits(JsonElement element, out Units units)
|
||||
{
|
||||
units = default;
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
return false;
|
||||
|
||||
var text = element.GetString();
|
||||
foreach (var candidate in Enum.GetValues<Units>())
|
||||
{
|
||||
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
units = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsUnreadableFile(Exception ex) =>
|
||||
ex
|
||||
is JsonException
|
||||
or IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or System.Security.SecurityException;
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000980001",
|
||||
"schemaVersion": 1,
|
||||
"name": "CL-980",
|
||||
"type": "laser",
|
||||
"units": "inches",
|
||||
"materials": [
|
||||
{
|
||||
"name": "Mild Steel",
|
||||
"grade": "A36",
|
||||
"density": 0.2836,
|
||||
"thicknesses": [
|
||||
{
|
||||
"value": 0.060,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.075,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.105,
|
||||
"kerf": 0.010,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
|
||||
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.135,
|
||||
"kerf": 0.010,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
|
||||
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120", "60x144" ]
|
||||
},
|
||||
{
|
||||
"value": 0.1875,
|
||||
"kerf": 0.012,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
|
||||
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120", "60x144" ]
|
||||
},
|
||||
{
|
||||
"value": 0.250,
|
||||
"kerf": 0.012,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
|
||||
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120", "60x144" ]
|
||||
},
|
||||
{
|
||||
"value": 0.375,
|
||||
"kerf": 0.016,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.375, "angle": 90.0, "radius": 0.1875 },
|
||||
"leadOut": { "type": "Line", "length": 0.1875, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.3125, "minSegmentLength": 1.25, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "60x120", "60x144", "72x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.500,
|
||||
"kerf": 0.020,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.5, "angle": 90.0, "radius": 0.25 },
|
||||
"leadOut": { "type": "Line", "length": 0.25, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.375, "minSegmentLength": 1.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "60x120", "60x144", "72x120" ]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Stainless Steel",
|
||||
"grade": "304",
|
||||
"density": 0.289,
|
||||
"thicknesses": [
|
||||
{
|
||||
"value": 0.060,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.075,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.105,
|
||||
"kerf": 0.010,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
|
||||
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.250,
|
||||
"kerf": 0.014,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
|
||||
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Aluminum",
|
||||
"grade": "5052",
|
||||
"density": 0.097,
|
||||
"thicknesses": [
|
||||
{
|
||||
"value": 0.060,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.080,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.125,
|
||||
"kerf": 0.010,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
|
||||
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.250,
|
||||
"kerf": 0.014,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
|
||||
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"id": "76974005-5de9-4de6-8ef0-4e4fa1ac6e98",
|
||||
"schemaVersion": 1,
|
||||
"name": "Generic Laser",
|
||||
"type": "laser",
|
||||
"units": "inches",
|
||||
"materials": [
|
||||
{
|
||||
"name": "Mild Steel",
|
||||
"grade": "CR",
|
||||
"density": 0.2836,
|
||||
"thicknesses": [
|
||||
{"value": 0.0598, "name": "16 ga", "kerf": 0.006, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.0747, "name": "14 ga", "kerf": 0.006, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1046, "name": "12 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1196, "name": "11 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1345, "name": "10 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Mild Steel",
|
||||
"grade": "HR",
|
||||
"density": 0.2836,
|
||||
"thicknesses": [
|
||||
{"value": 0.0747, "name": "14 ga", "kerf": 0.006, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1046, "name": "12 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1196, "name": "11 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1345, "name": "10 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1875, "name": "3/16", "kerf": 0.01, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.25, "name": "1/4", "kerf": 0.01, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.375, "name": "3/8", "kerf": 0.012, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.3125, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.3125, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.25, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.5, "name": "1/2", "kerf": 0.014, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.375, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.375, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Mild Steel",
|
||||
"grade": "HR P&O",
|
||||
"density": 0.2836,
|
||||
"thicknesses": [
|
||||
{"value": 0.0747, "name": "14 ga", "kerf": 0.006, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1046, "name": "12 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1196, "name": "11 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1345, "name": "10 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Stainless Steel",
|
||||
"grade": "304",
|
||||
"density": 0.289,
|
||||
"thicknesses": [
|
||||
{"value": 0.0625, "name": "16 ga", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.0781, "name": "14 ga", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1094, "name": "12 ga", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.125, "name": "11 ga", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1406, "name": "10 ga", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1875, "name": "3/16", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.25, "name": "1/4", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Stainless Steel",
|
||||
"grade": "316",
|
||||
"density": 0.289,
|
||||
"thicknesses": [
|
||||
{"value": 0.0625, "name": "16 ga", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.0781, "name": "14 ga", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1094, "name": "12 ga", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.125, "name": "11 ga", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1406, "name": "10 ga", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1875, "name": "3/16", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.25, "name": "1/4", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Aluminum",
|
||||
"grade": "5052",
|
||||
"density": 0.097,
|
||||
"thicknesses": [
|
||||
{"value": 0.063, "name": "0.063", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.08, "name": "0.080", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.09, "name": "0.090", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.125, "name": "1/8", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1875, "name": "3/16", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.25, "name": "1/4", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Aluminum",
|
||||
"grade": "6061",
|
||||
"density": 0.0975,
|
||||
"thicknesses": [
|
||||
{"value": 0.063, "name": "0.063", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.08, "name": "0.080", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.09, "name": "0.090", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.125, "name": "1/8", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.1875, "name": "3/16", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
|
||||
{"value": 0.25, "name": "1/4", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -27,20 +27,9 @@ public sealed class EngineSelectionSettings
|
||||
/// <summary>Missing, unreadable or corrupt settings safely use Default without writing.</summary>
|
||||
public static EngineSelectionSettings Load(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return new();
|
||||
|
||||
try
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<EngineSelectionSettings>(
|
||||
File.ReadAllText(path), JsonOptions) ?? new();
|
||||
settings.EngineName = NormalizeName(settings.EngineName);
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
var settings = SettingsFile.Read<EngineSelectionSettings>(path, JsonOptions) ?? new();
|
||||
settings.EngineName = NormalizeName(settings.EngineName);
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,22 +70,7 @@ public sealed class EngineSelectionSettings
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new EngineSelectionSettings { EngineName = NormalizeName(EngineName) }, JsonOptions);
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
|
||||
for (var attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < 2)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
SettingsFile.Write(path, json);
|
||||
}
|
||||
|
||||
private static string NormalizeName(string? name) =>
|
||||
|
||||
@@ -79,6 +79,14 @@ public interface INestRepository
|
||||
Task<int> AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException("This repository does not allocate nest names.");
|
||||
|
||||
/// <summary>
|
||||
/// The ledger item codes on the plates of every open (Draft, Quote or To Be Cut) nest,
|
||||
/// except <paramref name="excludeNestId"/> when it is not empty.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException("This repository does not list stock item codes.");
|
||||
|
||||
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
|
||||
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
|
||||
@@ -44,7 +44,8 @@ public enum LibraryInsertKind
|
||||
/// Decides how one library drawing revision enters a nest, before anything changes:
|
||||
/// a new exact copy carrying its library link, more quantity on the copy already there,
|
||||
/// or a refusal. Material, grade or thickness that differ from the nest still allow the
|
||||
/// add, but the drawing's cut pins are dropped (D3). Different units, a different revision
|
||||
/// add, but the drawing's cut pins are dropped (D3); a blank material or grade, or a zero
|
||||
/// thickness, on the record suits any nest and is never a difference. Different units, a different revision
|
||||
/// of the same drawing, or another drawing with the same name refuse it.
|
||||
/// </summary>
|
||||
public sealed class LibraryDrawingInsert
|
||||
@@ -75,7 +76,7 @@ public sealed class LibraryDrawingInsert
|
||||
|
||||
/// <summary>
|
||||
/// For a new copy, true when the library drawing's cut pins come with it: the library
|
||||
/// material, grade and thickness match the nest's. Otherwise the copy has no pins. An
|
||||
/// material, grade and thickness match the nest's or are unset. Otherwise the copy has no pins. An
|
||||
/// added quantity leaves the existing copy as it is.
|
||||
/// </summary>
|
||||
public bool KeepPins { get; }
|
||||
@@ -140,7 +141,12 @@ public sealed class LibraryDrawingInsert
|
||||
var drawing = package.Drawing;
|
||||
drawing.Name = record.Name;
|
||||
drawing.Customer = record.Customer;
|
||||
drawing.Material = new Material(record.MaterialName, record.MaterialGrade, drawing.Material?.Density ?? 0);
|
||||
// A field the record leaves unset means any material, so the copy takes the nest's.
|
||||
var nestMaterial = nest.Material ?? new Material();
|
||||
drawing.Material = new Material(
|
||||
IsSet(record.MaterialName) ? record.MaterialName : nestMaterial.Name,
|
||||
IsSet(record.MaterialGrade) ? record.MaterialGrade : nestMaterial.Grade,
|
||||
drawing.Material?.Density ?? 0);
|
||||
(record.Settings ?? new DrawingNestingSettings()).ApplyTo(drawing);
|
||||
drawing.Quantity.Required = quantity;
|
||||
drawing.Library = link;
|
||||
@@ -177,21 +183,24 @@ public sealed class LibraryDrawingInsert
|
||||
private static LibraryDrawingInsert Refuse(Nest nest, string reason) =>
|
||||
new(nest, LibraryInsertKind.Refused, null, 0, keepPins: false, Array.Empty<string>(), reason);
|
||||
|
||||
// A blank material or grade, or a zero thickness, on the record suits any nest.
|
||||
private static List<string> Differences(Nest nest, DrawingLibraryRecord record)
|
||||
{
|
||||
var differences = new List<string>();
|
||||
var material = nest.Material ?? new Material();
|
||||
if (SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
|
||||
if (IsSet(record.MaterialName) && SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
|
||||
differences.Add($"material {Text(record.MaterialName)} (nest {Text(material.Name)})");
|
||||
if (SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
|
||||
if (IsSet(record.MaterialGrade) && SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
|
||||
differences.Add($"grade {Text(record.MaterialGrade)} (nest {Text(material.Grade)})");
|
||||
var thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness);
|
||||
var nestThickness = DrawingLibraryRules.NormalizeThickness(nest.Thickness);
|
||||
if (thickness != nestThickness)
|
||||
if (thickness != 0 && thickness != nestThickness)
|
||||
differences.Add($"thickness {Number(thickness)} (nest {Number(nestThickness)})");
|
||||
return differences;
|
||||
}
|
||||
|
||||
private static bool IsSet(string? value) => SharedListNames.Key(value).Length > 0;
|
||||
|
||||
private static string Describe(DrawingLibraryRecord record) =>
|
||||
string.IsNullOrWhiteSpace(record.Customer)
|
||||
? $"library drawing '{record.Name}' revision {record.Revision}"
|
||||
|
||||
@@ -42,7 +42,7 @@ public class LocalJsonProvider : IDataProvider
|
||||
{
|
||||
var json = JsonSerializer.Serialize(machine, JsonOptions);
|
||||
var path = GetPath(machine.Id);
|
||||
WriteWithRetry(path, json);
|
||||
SettingsFile.Write(path, json);
|
||||
}
|
||||
|
||||
public void DeleteMachine(Guid id)
|
||||
@@ -79,7 +79,7 @@ public class LocalJsonProvider : IDataProvider
|
||||
var assembly = typeof(LocalJsonProvider).Assembly;
|
||||
var resourceName = assembly
|
||||
.GetManifestResourceNames()
|
||||
.FirstOrDefault(n => n.EndsWith("CL-980.json"));
|
||||
.FirstOrDefault(n => n.EndsWith("Generic-Laser.json"));
|
||||
|
||||
if (resourceName is null)
|
||||
return;
|
||||
@@ -97,20 +97,4 @@ public class LocalJsonProvider : IDataProvider
|
||||
|
||||
SaveMachine(config);
|
||||
}
|
||||
|
||||
private static void WriteWithRetry(string path, string json, int maxRetries = 3)
|
||||
{
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < maxRetries - 1)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ public sealed class MachineClamps
|
||||
/// <summary>Minutes to remove one clamp and put it back after cutting; never scaled.</summary>
|
||||
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
|
||||
|
||||
/// <summary>The gap parts keep from every clamp; null uses each sheet's bottom edge spacing.</summary>
|
||||
public double? Clearance { get; set; }
|
||||
|
||||
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
|
||||
public List<double> Centers { get; set; } = new();
|
||||
|
||||
@@ -49,6 +52,7 @@ public sealed class MachineClamps
|
||||
Width = converted.Width,
|
||||
Depth = converted.Depth,
|
||||
RemovalMinutes = converted.RemovalMinutes,
|
||||
Clearance = converted.Clearance,
|
||||
Centers = converted.Centers.ToList(),
|
||||
FarSideCenters = converted.FarSideCenters.ToList(),
|
||||
OriginEndCenters = converted.OriginEndCenters.ToList(),
|
||||
@@ -58,7 +62,8 @@ public sealed class MachineClamps
|
||||
|
||||
/// <summary>
|
||||
/// The rail as clamp settings in the machine's <paramref name="units"/>. An invalid size or
|
||||
/// removal time falls back to the default, and an invalid center is dropped, as when
|
||||
/// removal time falls back to the default, an invalid clearance to the bottom edge spacing,
|
||||
/// and an invalid center is dropped, as when
|
||||
/// <see cref="ClampSettings.Parse"/> reads a file.
|
||||
/// </summary>
|
||||
public ClampSettings ToSettings(UnitSystem units)
|
||||
@@ -70,6 +75,8 @@ public sealed class MachineClamps
|
||||
defaults.Depth = Depth;
|
||||
if (IsLength(RemovalMinutes) && RemovalMinutes >= 0)
|
||||
defaults.RemovalMinutes = RemovalMinutes;
|
||||
if (Clearance is double clearance && IsLength(clearance) && clearance >= 0)
|
||||
defaults.Clearance = clearance;
|
||||
defaults.Centers = Valid(Centers);
|
||||
defaults.FarSideCenters = Valid(FarSideCenters);
|
||||
defaults.OriginEndCenters = Valid(OriginEndCenters);
|
||||
|
||||
@@ -3,12 +3,12 @@ using OpenNest.CNC.CuttingStrategy;
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// The lead-in, lead-out, kerf and cut-off a machine configuration has on file for a nest's
|
||||
/// material and thickness, converted to the nest's units. A machine has one lead-in and one
|
||||
/// lead-out per thickness, so they apply to external, internal and arc/circle contours alike;
|
||||
/// a Clean Hole lead-in is for holes only and leaves the external lead-in as it was, and a
|
||||
/// Line + Line lead-in, whose second segment the configuration does not hold, leaves every
|
||||
/// lead-in as it was.
|
||||
/// The lead-ins, lead-outs, kerf and cut-off a machine configuration has on file for a nest's
|
||||
/// material and thickness, converted to the nest's units. Outside cuts take the thickness's
|
||||
/// lead-in and lead-out; inside cuts (circles included) take its inside ones, or the outside
|
||||
/// ones when it has none. A Clean Hole lead-in is for holes only, so on outside cuts it leaves
|
||||
/// the external lead-in as it was, and a Line + Line lead-in, whose second segment the
|
||||
/// configuration does not hold, leaves the lead-in it would set as it was.
|
||||
/// </summary>
|
||||
public static class MachineCuttingDefaults
|
||||
{
|
||||
@@ -55,17 +55,8 @@ public static class MachineCuttingDefaults
|
||||
return false;
|
||||
|
||||
var scale = Scale(machine!.Units, nest.Units);
|
||||
var kerf = settings.Kerf * scale;
|
||||
// Each lead set gets its own instance, as the cutting panel builds them.
|
||||
if (ToLeadIn(settings.LeadIn, scale, kerf) is { } leadIn)
|
||||
{
|
||||
if (leadIn is not CleanHoleLeadIn)
|
||||
parameters.ExternalLeadIn = leadIn;
|
||||
parameters.InternalLeadIn = ToLeadIn(settings.LeadIn, scale, kerf);
|
||||
}
|
||||
parameters.ExternalLeadOut = ToLeadOut(settings.LeadOut, scale);
|
||||
parameters.InternalLeadOut = ToLeadOut(settings.LeadOut, scale);
|
||||
parameters.Kerf = kerf;
|
||||
ApplyLeads(parameters, settings, scale);
|
||||
parameters.Kerf = settings.Kerf * scale;
|
||||
parameters.MachineName = machine.Name;
|
||||
parameters.MaterialName = nest.Material.Name;
|
||||
parameters.Grade = nest.Material.Grade;
|
||||
@@ -73,6 +64,50 @@ public static class MachineCuttingDefaults
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The leads <paramref name="settings"/> give, in the machine's units, for previewing them:
|
||||
/// a lead the machine leaves as it was (Line + Line, or Clean Hole on outside cuts) is none.
|
||||
/// </summary>
|
||||
public static CuttingParameters LeadsOf(ThicknessConfig settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
var parameters = new CuttingParameters { ExternalLeadIn = new NoLeadIn(), InternalLeadIn = new NoLeadIn() };
|
||||
ApplyLeads(parameters, settings, 1);
|
||||
parameters.Kerf = settings.Kerf;
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private static void ApplyLeads(CuttingParameters parameters, ThicknessConfig settings, double scale)
|
||||
{
|
||||
var kerf = settings.Kerf * scale;
|
||||
// Each lead set gets its own instance, as the cutting panel builds them.
|
||||
if (ToLeadIn(settings.LeadIn, scale, kerf) is { } leadIn and not CleanHoleLeadIn)
|
||||
parameters.ExternalLeadIn = leadIn;
|
||||
if (ToLeadIn(settings.InsideLeadIn ?? settings.LeadIn, scale, kerf) is { } insideLeadIn)
|
||||
parameters.InternalLeadIn = insideLeadIn;
|
||||
parameters.ExternalLeadOut = ToLeadOut(settings.LeadOut, scale);
|
||||
parameters.InternalLeadOut = ToLeadOut(settings.InsideLeadOut ?? settings.LeadOut, scale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="parameters"/> are stamped with the machine, material, grade and
|
||||
/// thickness <see cref="ApplyTo(CuttingParameters, MachineConfig?, Nest)"/> would record for
|
||||
/// the nest: they started from the machine's settings for it, and any lead edits since are
|
||||
/// the user's choice for that same job, which the machine's settings must not replace.
|
||||
/// </summary>
|
||||
public static bool IsRecordedFor(CuttingParameters parameters, MachineConfig? machine, Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parameters);
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
if (Find(machine, nest) == null)
|
||||
return false;
|
||||
var tolerance = ThicknessToleranceInches * Scale(Units.Inches, nest.Units);
|
||||
return string.Equals(parameters.MachineName, machine!.Name, StringComparison.Ordinal)
|
||||
&& string.Equals(parameters.MaterialName, nest.Material.Name, StringComparison.Ordinal)
|
||||
&& string.Equals(parameters.Grade ?? "", nest.Material.Grade ?? "", StringComparison.Ordinal)
|
||||
&& System.Math.Abs(parameters.Thickness - nest.Thickness) <= tolerance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the part clearance, overtravel, minimum segment and direction of
|
||||
/// <paramref name="settings"/> with the machine's for the nest; the head-lock choice is kept.
|
||||
@@ -93,6 +128,71 @@ public static class MachineCuttingDefaults
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The lead-in <paramref name="lead"/> describes, in the machine's units, with
|
||||
/// <paramref name="kerf"/> for a clean hole; null for a type it cannot build (Line + Line).
|
||||
/// </summary>
|
||||
public static LeadIn? LeadInOf(LeadConfig lead, double kerf) => ToLeadIn(lead, 1, kerf);
|
||||
|
||||
/// <summary>The lead-out <paramref name="lead"/> describes, in the machine's units.</summary>
|
||||
public static LeadOut LeadOutOf(LeadConfig lead) => ToLeadOut(lead, 1);
|
||||
|
||||
/// <summary>
|
||||
/// The machine configuration's form of <paramref name="leadIn"/>; what it cannot hold (an
|
||||
/// arc's sweep, a clean hole's kerf, a second line) is left out. Unset fields keep
|
||||
/// <paramref name="previous"/>'s values.
|
||||
/// </summary>
|
||||
public static LeadConfig ToConfig(LeadIn leadIn, LeadConfig? previous = null)
|
||||
{
|
||||
var config = Copy(previous);
|
||||
switch (leadIn)
|
||||
{
|
||||
case LineLeadIn line:
|
||||
(config.Type, config.Length, config.Angle) = ("Line", line.Length, line.ApproachAngle);
|
||||
break;
|
||||
case ArcLeadIn arc:
|
||||
(config.Type, config.Radius) = ("Arc", arc.Radius);
|
||||
break;
|
||||
case LineArcLeadIn lineArc:
|
||||
(config.Type, config.Length, config.Radius, config.Angle) =
|
||||
("Line + Arc", lineArc.LineLength, lineArc.ArcRadius, lineArc.ApproachAngle);
|
||||
break;
|
||||
case CleanHoleLeadIn cleanHole:
|
||||
(config.Type, config.Length, config.Radius) = ("Clean Hole", cleanHole.LineLength, cleanHole.ArcRadius);
|
||||
break;
|
||||
case LineLineLeadIn lineLine:
|
||||
(config.Type, config.Length, config.Angle) = ("Line + Line", lineLine.Length1, lineLine.ApproachAngle1);
|
||||
break;
|
||||
default:
|
||||
config.Type = "None";
|
||||
break;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>The machine configuration's form of <paramref name="leadOut"/>.</summary>
|
||||
public static LeadConfig ToConfig(LeadOut leadOut, LeadConfig? previous = null)
|
||||
{
|
||||
var config = Copy(previous);
|
||||
switch (leadOut)
|
||||
{
|
||||
case LineLeadOut line:
|
||||
(config.Type, config.Length, config.Angle) = ("Line", line.Length, line.ApproachAngle);
|
||||
break;
|
||||
case ArcLeadOut arc:
|
||||
(config.Type, config.Radius) = ("Arc", arc.Radius);
|
||||
break;
|
||||
default:
|
||||
config.Type = "None";
|
||||
break;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
private static LeadConfig Copy(LeadConfig? lead) => lead == null
|
||||
? new LeadConfig()
|
||||
: new LeadConfig { Type = lead.Type, Length = lead.Length, Angle = lead.Angle, Radius = lead.Radius };
|
||||
|
||||
// Lead types are the names Tools > Machine Configuration offers; null when one cannot be built.
|
||||
private static LeadIn? ToLeadIn(LeadConfig? lead, double scale, double kerf) => lead?.Type switch
|
||||
{
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace OpenNest.Data.MaterialLedger;
|
||||
|
||||
/// <summary>A plate's item code that is no longer the nest's to use, and why.</summary>
|
||||
/// <param name="PlateNumber">The plate's 1-based position in the nest.</param>
|
||||
public sealed record LedgerCodeConflict(int PlateNumber, string ItemCode, string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// The save and post re-check of a nest's <see cref="Plate.StockItemCodes"/>: finds codes the
|
||||
/// ledger no longer has free for this nest or that other open nests are coded with, and takes
|
||||
/// them off the plates when the programmer chooses to. Pure; the caller fetches the inputs.
|
||||
/// </summary>
|
||||
public static class LedgerCodeCheck
|
||||
{
|
||||
/// <summary>True when any plate has a coded sheet.</summary>
|
||||
public static bool HasCodes(IEnumerable<Plate> plates) =>
|
||||
(plates ?? throw new ArgumentNullException(nameof(plates))).Any(plate => plate.StockItemCodes.Count > 0);
|
||||
|
||||
/// <param name="plates">The nest's plates in order.</param>
|
||||
/// <param name="freeItems">The ledger's free stock for the nest's material, after every hold.</param>
|
||||
/// <param name="ownHolds">
|
||||
/// The ledger's holds for this nest: an open one keeps its sheet for this nest, so the
|
||||
/// sheet counts as available here although the ledger no longer lists it as free.
|
||||
/// </param>
|
||||
/// <param name="inUse">Other open nests' codes; an unknown answer checks the ledger alone.</param>
|
||||
/// <returns>One conflict per plate and code, in plate order then the plate's code order.</returns>
|
||||
public static IReadOnlyList<LedgerCodeConflict> Find(IList<Plate> plates, IEnumerable<LedgerStockItem> freeItems,
|
||||
IEnumerable<LedgerHold>? ownHolds, LedgerCodesInUse inUse)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
ArgumentNullException.ThrowIfNull(freeItems);
|
||||
ArgumentNullException.ThrowIfNull(inUse);
|
||||
|
||||
var available = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var item in freeItems.Where(item => item != null && item.Quantity > 0))
|
||||
available[item.ItemCode] = available.GetValueOrDefault(item.ItemCode) + item.Quantity;
|
||||
foreach (var hold in (ownHolds ?? Array.Empty<LedgerHold>()).Where(hold => hold != null && LedgerHoldStatuses.IsOpen(hold.Status)))
|
||||
available[hold.ItemCode] = available.GetValueOrDefault(hold.ItemCode) + 1;
|
||||
|
||||
// A bundle's code repeats once per sheet across every plate it is used on.
|
||||
var needed = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var code in plates.SelectMany(plate => plate.StockItemCodes))
|
||||
needed[code] = needed.GetValueOrDefault(code) + 1;
|
||||
|
||||
var reasons = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (code, count) in needed)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var free = available.GetValueOrDefault(code);
|
||||
if (free == 0)
|
||||
parts.Add("not free in MaterialLedger");
|
||||
else if (free < count)
|
||||
parts.Add($"only {free} of the {count} sheets needed free in MaterialLedger");
|
||||
var nests = inUse.NestsUsing(code);
|
||||
if (nests.Count > 0)
|
||||
parts.Add((nests.Count == 1 ? "on nest " : "on nests ") + string.Join(", ", nests));
|
||||
if (parts.Count > 0)
|
||||
reasons[code] = Capitalize(string.Join("; ", parts));
|
||||
}
|
||||
|
||||
var conflicts = new List<LedgerCodeConflict>();
|
||||
for (var i = 0; i < plates.Count; i++)
|
||||
{
|
||||
foreach (var code in plates[i].StockItemCodes.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (reasons.TryGetValue(code, out var reason))
|
||||
conflicts.Add(new LedgerCodeConflict(i + 1, code, reason));
|
||||
}
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes every copy of <paramref name="codes"/> off the plates, keeping the layout and the
|
||||
/// other codes in order; the sheets left uncoded are matched by size in ML. Returns how
|
||||
/// many sheets lost their code.
|
||||
/// </summary>
|
||||
public static int Remove(IEnumerable<Plate> plates, IEnumerable<string> codes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
ArgumentNullException.ThrowIfNull(codes);
|
||||
var remove = codes.ToHashSet(StringComparer.Ordinal);
|
||||
var removed = 0;
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
var kept = plate.StockItemCodes.Where(code => !remove.Contains(code)).ToList();
|
||||
if (kept.Count == plate.StockItemCodes.Count)
|
||||
continue;
|
||||
removed += plate.StockItemCodes.Count - kept.Count;
|
||||
plate.StockItemCodes = kept;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private static string Capitalize(string text) => text.Length == 0 ? text : char.ToUpperInvariant(text[0]) + text[1..];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace OpenNest.Data.MaterialLedger;
|
||||
|
||||
/// <summary>
|
||||
/// Takes item codes already on other open nests out of ledger stock rows, so two nests are
|
||||
/// not planned on the same sheet before ML holds exist for either. Pure: rows are rebuilt,
|
||||
/// never changed.
|
||||
/// </summary>
|
||||
public static class LedgerCodeFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// <paramref name="rows"/> without <paramref name="codesInUse"/>: every copy of a code
|
||||
/// leaves the row's <see cref="LedgerStockRow.ItemCodes"/> and
|
||||
/// <see cref="LedgerStockRow.ItemLocations"/>, its quantity drops by the copies removed, and
|
||||
/// a row left with none is dropped. Row ids and order are kept. <paramref name="removed"/>
|
||||
/// counts the sheets and drops taken out.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LedgerStockRow> Exclude(IEnumerable<LedgerStockRow> rows,
|
||||
IEnumerable<string> codesInUse, out int removed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
ArgumentNullException.ThrowIfNull(codesInUse);
|
||||
var exclude = codesInUse as IReadOnlySet<string> ?? codesInUse.ToHashSet(StringComparer.Ordinal);
|
||||
removed = 0;
|
||||
var kept = new List<LedgerStockRow>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row == null)
|
||||
continue;
|
||||
var codes = row.ItemCodes.Where(code => !exclude.Contains(code)).ToList();
|
||||
var dropped = row.ItemCodes.Count - codes.Count;
|
||||
if (dropped == 0)
|
||||
{
|
||||
kept.Add(row);
|
||||
continue;
|
||||
}
|
||||
removed += dropped;
|
||||
if (codes.Count == 0)
|
||||
continue;
|
||||
|
||||
var itemLocations = row.ItemLocations
|
||||
.Where(pair => !exclude.Contains(pair.Key))
|
||||
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
// A location stays listed while an item left in the row is there, or when the row
|
||||
// never said which item it held.
|
||||
var locations = row.Locations
|
||||
.Where(location => itemLocations.ContainsValue(location)
|
||||
|| !row.ItemLocations.Values.Contains(location, StringComparer.Ordinal))
|
||||
.ToList();
|
||||
kept.Add(row with
|
||||
{
|
||||
Quantity = codes.Count,
|
||||
ItemCodes = codes,
|
||||
Locations = locations,
|
||||
ItemLocations = itemLocations,
|
||||
});
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="mapping"/> with <paramref name="codesInUse"/> taken out of its rows and
|
||||
/// the sheets removed counted in <see cref="LedgerStockMapping.OnOtherNests"/>.
|
||||
/// </summary>
|
||||
public static LedgerStockMapping Exclude(LedgerStockMapping mapping, IEnumerable<string> codesInUse)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mapping);
|
||||
var rows = Exclude(mapping.Rows, codesInUse, out var removed);
|
||||
return mapping with { Rows = rows, OnOtherNests = mapping.OnOtherNests + removed };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Net.Http;
|
||||
|
||||
namespace OpenNest.Data.MaterialLedger;
|
||||
|
||||
/// <summary>
|
||||
/// The ledger item codes other open server nests are coded with, or why they could not be
|
||||
/// read. ML's free counts drop only once a hold exists, so these codes are what another
|
||||
/// Auto Nest run must leave out until then. Reading them never blocks: a failure is a result.
|
||||
/// </summary>
|
||||
public sealed record LedgerCodesInUse
|
||||
{
|
||||
/// <summary>The nest server gets this long to answer, so a stock load or save never waits on it.</summary>
|
||||
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private LedgerCodesInUse(IReadOnlyList<NestStockCodeUse> uses, string? unavailable)
|
||||
{
|
||||
Uses = uses;
|
||||
Unavailable = unavailable;
|
||||
Codes = uses.Select(use => use.ItemCode).ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>One entry per code and nest; empty when <see cref="Unavailable"/>.</summary>
|
||||
public IReadOnlyList<NestStockCodeUse> Uses { get; }
|
||||
|
||||
/// <summary>The distinct codes of <see cref="Uses"/>.</summary>
|
||||
public IReadOnlySet<string> Codes { get; }
|
||||
|
||||
/// <summary>Why the codes could not be read; null when they were.</summary>
|
||||
public string? Unavailable { get; }
|
||||
|
||||
public bool IsKnown => Unavailable == null;
|
||||
|
||||
public static LedgerCodesInUse Known(IEnumerable<NestStockCodeUse> uses) =>
|
||||
new((uses ?? throw new ArgumentNullException(nameof(uses))).Where(use => use != null).ToList(), null);
|
||||
|
||||
public static LedgerCodesInUse Unknown(string reason) =>
|
||||
new(Array.Empty<NestStockCodeUse>(), string.IsNullOrWhiteSpace(reason) ? "not available" : reason);
|
||||
|
||||
/// <summary>The names of the other nests using <paramref name="code"/>, in name order.</summary>
|
||||
public IReadOnlyList<string> NestsUsing(string code) =>
|
||||
Uses.Where(use => string.Equals(use.ItemCode, code, StringComparison.Ordinal))
|
||||
.Select(use => use.NestName)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Reads the codes from <paramref name="repository"/>, leaving out the nest
|
||||
/// <paramref name="excludeNestId"/> (the one being edited). A null repository is File
|
||||
/// mode. Only the caller's own cancellation throws.
|
||||
/// </summary>
|
||||
public static async Task<LedgerCodesInUse> ReadAsync(INestRepository? repository, Guid excludeNestId,
|
||||
CancellationToken cancellationToken, TimeSpan? timeout = null)
|
||||
{
|
||||
if (repository == null)
|
||||
return Unknown("nests are saved as files");
|
||||
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout ?? RequestTimeout);
|
||||
try
|
||||
{
|
||||
return Known(await repository.GetStockCodesInUseAsync(excludeNestId, timeoutSource.Token));
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Unknown("the nest server did not answer");
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return Unknown("the nest server needs an update");
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or IOException or System.Text.Json.JsonException
|
||||
or ArgumentException or InvalidOperationException)
|
||||
{
|
||||
return Unknown("the nest server could not be reached");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ public sealed class LedgerHoldSummary
|
||||
|
||||
public int OpenHolds { get; }
|
||||
|
||||
/// <summary>Holds in any other status: released, closed, cancelled, exception or unknown.</summary>
|
||||
/// <summary>Holds in any other status: released, closed, canceled, exception or unknown.</summary>
|
||||
public int ClosedHolds { get; }
|
||||
|
||||
/// <summary>Sheets without an open hold.</summary>
|
||||
|
||||
@@ -26,13 +26,13 @@ public static class LedgerHoldStatuses
|
||||
public const string AwaitingCloseout = "AwaitingCloseout";
|
||||
public const string ClosedConsumed = "ClosedConsumed";
|
||||
public const string ClosedReturned = "ClosedReturned";
|
||||
public const string Cancelled = "Cancelled";
|
||||
public const string Canceled = "Cancelled";
|
||||
public const string Exception = "Exception";
|
||||
|
||||
/// <summary>
|
||||
/// True for holds that still keep a sheet for the nest and need closing out: the statuses ML
|
||||
/// counts against free stock. Released means the hold was let go, not sent to cutting, so it
|
||||
/// is not open; nor are closed, cancelled, exception and unknown statuses.
|
||||
/// is not open; nor are closed, canceled, exception and unknown statuses.
|
||||
/// </summary>
|
||||
public static bool IsOpen(string? status) =>
|
||||
string.Equals(status, Proposed, StringComparison.OrdinalIgnoreCase)
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace OpenNest.Data.MaterialLedger;
|
||||
|
||||
/// <summary>
|
||||
/// Gives the plates Auto Nest committed on ledger stock their item codes. Runs only after the
|
||||
/// proposal was validated, kept and committed, so a rejected or cancelled one never changes
|
||||
/// proposal was validated, kept and committed, so a rejected or canceled one never changes
|
||||
/// codes. Codes are hand-off data for MaterialLedger holds, not inventory accounting.
|
||||
/// </summary>
|
||||
public static class LedgerStockAssignment
|
||||
|
||||
@@ -51,7 +51,12 @@ public sealed record LedgerStockMapping(
|
||||
public int Drops => Rows.Where(r => r.IsDrop).Sum(r => r.Quantity);
|
||||
public int SubstituteRows => Rows.Count(r => r.IsSubstitute);
|
||||
|
||||
/// <summary>E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (2 skipped: 2 unknown shape)".</summary>
|
||||
/// <summary>Sheets and drops left out because other open nests are coded with them (<see cref="LedgerCodeFilter"/>).</summary>
|
||||
public int OnOtherNests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (3 on other nests) (2 skipped: 2 unknown shape)".
|
||||
/// </summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
@@ -64,6 +69,8 @@ public sealed record LedgerStockMapping(
|
||||
var text = $"Ledger: {Plural(SheetRows, "sheet size")}, {Plural(Drops, "drop")}";
|
||||
if (SubstituteRows > 0)
|
||||
text += $", {Plural(SubstituteRows, "substitute row")}";
|
||||
if (OnOtherNests > 0)
|
||||
text += $" ({OnOtherNests} on other nests)";
|
||||
return skipped.Count == 0
|
||||
? text
|
||||
: $"{text} ({SkippedUnknownShape + SkippedTooSmall} skipped: {string.Join(", ", skipped)})";
|
||||
|
||||
@@ -106,7 +106,7 @@ public sealed class NestDefaults
|
||||
{
|
||||
json = File.ReadAllText(path);
|
||||
}
|
||||
catch (Exception ex) when (IsUnreadableFile(ex))
|
||||
catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
|
||||
{
|
||||
// A file that exists but cannot be read (locked, access-denied)
|
||||
// must never block creating a nest.
|
||||
@@ -129,7 +129,7 @@ public sealed class NestDefaults
|
||||
{
|
||||
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json ?? "", JsonOptions);
|
||||
}
|
||||
catch (Exception ex) when (IsUnreadableFile(ex))
|
||||
catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
|
||||
{
|
||||
// Defaults that cannot be parsed must never block creating a nest.
|
||||
status = NestDefaultsStatus.Invalid;
|
||||
@@ -144,7 +144,7 @@ public sealed class NestDefaults
|
||||
|
||||
status = NestDefaultsStatus.Ok;
|
||||
|
||||
if (TryParseUnits(dto.Units, out var units))
|
||||
if (SettingsFile.TryParseUnits(dto.Units, out var units))
|
||||
defaults.Units = units;
|
||||
|
||||
if (
|
||||
@@ -261,24 +261,7 @@ public sealed class NestDefaults
|
||||
/// </summary>
|
||||
public void Save(string path, int maxRetries = 3)
|
||||
{
|
||||
var json = ToJson();
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < maxRetries - 1)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
SettingsFile.Write(path, ToJson(), maxRetries);
|
||||
}
|
||||
|
||||
/// <summary>The defaults as <see cref="Save"/> writes them, for the shared copy.</summary>
|
||||
@@ -304,38 +287,6 @@ public sealed class NestDefaults
|
||||
return JsonSerializer.Serialize(dto, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts only a defined unit name (case-insensitive). Enum.TryParse
|
||||
/// would also accept numeric strings ("7") and comma-joined names,
|
||||
/// producing undefined or unintended values.
|
||||
/// </summary>
|
||||
private static bool TryParseUnits(JsonElement? element, out Units units)
|
||||
{
|
||||
units = default;
|
||||
if (element is not { ValueKind: JsonValueKind.String } value)
|
||||
return false;
|
||||
|
||||
var text = value.GetString();
|
||||
foreach (var candidate in Enum.GetValues<Units>())
|
||||
{
|
||||
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
units = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsUnreadableFile(Exception ex) =>
|
||||
ex
|
||||
is JsonException
|
||||
or IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or System.Security.SecurityException;
|
||||
|
||||
private static bool IsValidSize(double width, double length) =>
|
||||
!double.IsNaN(width)
|
||||
&& !double.IsNaN(length)
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class NestDetailsSession : IDisposable
|
||||
public async Task<bool> LoadAsync(Guid id)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
// Supersede before cancelling: a load that completes synchronously when cancelled
|
||||
// Supersede before canceling: a load that completes synchronously when canceled
|
||||
// must already see itself as superseded. Each source is released by its own load.
|
||||
var cancellation = Supersede();
|
||||
var generation = _generation;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// A ledger item code an open server nest's plates are coded with, returned by
|
||||
/// <c>GET /api/nests/stock-item-codes</c>. One entry per code and nest; a bundle code used
|
||||
/// for several sheets appears once.
|
||||
/// </summary>
|
||||
public sealed class NestStockCodeUse
|
||||
{
|
||||
public string ItemCode { get; init; } = "";
|
||||
|
||||
public Guid NestId { get; init; }
|
||||
|
||||
public string NestName { get; init; } = "";
|
||||
|
||||
/// <summary>Draft, Quote or To Be Cut: the statuses whose sheets are still to be cut.</summary>
|
||||
public NestStatus Status { get; init; }
|
||||
}
|
||||
@@ -51,20 +51,9 @@ public sealed class NestStorageSettings
|
||||
|
||||
public static NestStorageSettings Load(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return new();
|
||||
|
||||
try
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<NestStorageSettings>(
|
||||
File.ReadAllText(path), JsonOptions) ?? new();
|
||||
settings.ServerUrl = NormalizeUrl(settings.ServerUrl);
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
var settings = SettingsFile.Read<NestStorageSettings>(path, JsonOptions) ?? new();
|
||||
settings.ServerUrl = NormalizeUrl(settings.ServerUrl);
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>Writes camelCase JSON, creating the parent directory and retrying IO collisions.</summary>
|
||||
@@ -73,22 +62,7 @@ public sealed class NestStorageSettings
|
||||
var json = JsonSerializer.Serialize(
|
||||
new NestStorageSettings { Mode = Mode, ServerUrl = NormalizeUrl(ServerUrl) },
|
||||
JsonOptions);
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
|
||||
for (var attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < 2)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
SettingsFile.Write(path, json);
|
||||
}
|
||||
|
||||
private static string NormalizeUrl(string? url) => (url ?? "").Trim().TrimEnd('/');
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Defaults\CL-980.json" />
|
||||
<EmbeddedResource Include="Defaults\Generic-Laser.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -151,6 +151,19 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
return allocation.Number;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = "api/nests/stock-item-codes" + (excludeNestId == Guid.Empty ? "" : "?exclude=" + excludeNestId.ToString("D"));
|
||||
using var response = await _httpClient.GetAsync(Url(query), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
throw new NotSupportedException("This nest server does not list stock item codes (HTTP 404). Update the nest server.");
|
||||
await NestServerHttp.EnsureSuccessAsync(response, "list stock item codes", cancellationToken);
|
||||
var uses = await response.Content.ReadFromJsonAsync<List<NestStockCodeUse>>(NestServerHttp.JsonOptions, cancellationToken);
|
||||
return uses?.Where(use => use != null && !string.IsNullOrWhiteSpace(use.ItemCode)).ToList()
|
||||
?? new List<NestStockCodeUse>();
|
||||
}
|
||||
|
||||
public async Task<NestRecord> UpdateFileAsync(
|
||||
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -148,7 +148,7 @@ public sealed class SetReleaseResult
|
||||
/// matches its record, units the new nest does not use, an insert refusal) creates
|
||||
/// nothing, and so does a server too old to record drawings released on their own, which is
|
||||
/// checked first. Uploads then run in order; the first failure stops them, and cancellation is
|
||||
/// honoured between uploads, never during one. Nests already uploaded stay as ordinary
|
||||
/// honored between uploads, never during one. Nests already uploaded stay as ordinary
|
||||
/// nests and are listed in the result; the release is posted only when every chosen group
|
||||
/// was created. Nothing is rolled back.
|
||||
/// </para>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>File handling shared by the JSON settings files under %APPDATA%\OpenNest.</summary>
|
||||
internal static class SettingsFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes the file, creating the parent directory, and retries briefly on IO collisions.
|
||||
/// The last failure propagates; the host handles a persistent write failure.
|
||||
/// </summary>
|
||||
public static void Write(string path, string json, int maxRetries = 3)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < maxRetries - 1)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the file, or returns null when it is missing, unreadable or corrupt.
|
||||
/// </summary>
|
||||
public static T? Read<T>(string path, JsonSerializerOptions options)
|
||||
where T : class
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), options);
|
||||
}
|
||||
catch (Exception ex) when (IsUnreadableFile(ex))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsUnreadableFile(Exception ex) =>
|
||||
ex
|
||||
is JsonException
|
||||
or IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or System.Security.SecurityException;
|
||||
|
||||
/// <summary>
|
||||
/// Accepts only a defined unit name (case-insensitive). Enum.TryParse would also accept
|
||||
/// numeric strings ("7") and comma-joined names, producing undefined or unintended values.
|
||||
/// </summary>
|
||||
public static bool TryParseUnits(JsonElement? element, out Units units)
|
||||
{
|
||||
units = default;
|
||||
if (element is not { ValueKind: JsonValueKind.String } value)
|
||||
return false;
|
||||
|
||||
var text = value.GetString();
|
||||
foreach (var candidate in Enum.GetValues<Units>())
|
||||
{
|
||||
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
units = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,25 @@ namespace OpenNest.Data;
|
||||
public class ThicknessConfig
|
||||
{
|
||||
public double Value { get; set; }
|
||||
|
||||
/// <summary>Optional display name for the thickness, such as a gauge ("16 ga").</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public double Kerf { get; set; }
|
||||
public string AssistGas { get; set; } = "";
|
||||
|
||||
/// <summary>The lead-in of outside cuts (part perimeters), and of inside cuts while <see cref="InsideLeadIn"/> is null.</summary>
|
||||
public LeadConfig LeadIn { get; set; } = new();
|
||||
|
||||
/// <summary>The lead-out of outside cuts, and of inside cuts while <see cref="InsideLeadOut"/> is null.</summary>
|
||||
public LeadConfig LeadOut { get; set; } = new();
|
||||
|
||||
/// <summary>The lead-in of inside cuts (cutouts and holes); null uses <see cref="LeadIn"/>.</summary>
|
||||
public LeadConfig? InsideLeadIn { get; set; }
|
||||
|
||||
/// <summary>The lead-out of inside cuts; null uses <see cref="LeadOut"/>.</summary>
|
||||
public LeadConfig? InsideLeadOut { get; set; }
|
||||
|
||||
public CutOffConfig CutOff { get; set; } = new();
|
||||
public List<string> PlateSizes { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Desktop.Tests;
|
||||
|
||||
public class AutoNestJobTests
|
||||
{
|
||||
private static Drawing Square(string name, double side, int required)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
program.Codes.Add(new LinearMove(new Vector(side, 0)));
|
||||
program.Codes.Add(new LinearMove(new Vector(side, side)));
|
||||
program.Codes.Add(new LinearMove(new Vector(0, side)));
|
||||
program.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
var drawing = new Drawing(name, program);
|
||||
drawing.Quantity.Required = required;
|
||||
return drawing;
|
||||
}
|
||||
|
||||
private static Nest NestWith(params Drawing[] drawings)
|
||||
{
|
||||
var nest = new Nest { Name = "job" };
|
||||
foreach (var drawing in drawings)
|
||||
nest.Drawings.Add(drawing);
|
||||
nest.Plates.Add(new Plate(new Size(48, 96)) { Quantity = 1 });
|
||||
return nest;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForRemaining_AsksForWhatIsNotPlacedYet_OnTheTemplateStock()
|
||||
{
|
||||
var square = Square("square", 10, 5);
|
||||
var nest = NestWith(square);
|
||||
nest.Plates[0].Parts.Add(new Part(square));
|
||||
|
||||
var request = AutoNestJob.ForRemaining(nest, nest.Plates[0], "Default");
|
||||
|
||||
var item = Assert.Single(request.Items);
|
||||
Assert.Same(square, item.Drawing);
|
||||
Assert.Equal(4, item.Quantity);
|
||||
Assert.Equal("Default", request.EngineName);
|
||||
Assert.Equal(nest.Plates[0].Size, Assert.Single(request.Stock).Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForRemaining_SkipsCutOffsAndPlacedDrawings_AndIsNullWhenNothingRemains()
|
||||
{
|
||||
var placed = Square("placed", 10, 1);
|
||||
var cutOff = Square("cut-off", 1, 3);
|
||||
cutOff.IsCutOff = true;
|
||||
var nest = NestWith(placed, cutOff);
|
||||
nest.Plates[0].Parts.Add(new Part(placed));
|
||||
|
||||
Assert.Null(AutoNestJob.ForRemaining(nest, nest.Plates[0], "Default"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidResult_IsCommittedOntoTheEmptyPlate()
|
||||
{
|
||||
var square = Square("square", 10, 6);
|
||||
var nest = NestWith(square);
|
||||
var request = AutoNestJob.ForRemaining(nest, nest.Plates[0], "Default");
|
||||
|
||||
var result = NestPipeline.Run(request);
|
||||
var kept = AutoNestJob.Keep(nest, result);
|
||||
|
||||
Assert.True(kept.Nested, kept.Note);
|
||||
Assert.Null(AutoNestJob.RefusalReason(result));
|
||||
var plate = Assert.Single(nest.Plates);
|
||||
Assert.Equal(6, plate.Parts.Count);
|
||||
Assert.Equal(0, square.Quantity.Remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResultPlacingNothing_LeavesTheNestUnchanged()
|
||||
{
|
||||
var tooBig = Square("too big", 200, 1);
|
||||
var nest = NestWith(tooBig);
|
||||
var request = AutoNestJob.ForRemaining(nest, nest.Plates[0], "Default");
|
||||
|
||||
var result = NestPipeline.Run(request);
|
||||
var kept = AutoNestJob.Keep(nest, result);
|
||||
|
||||
Assert.False(kept.Nested);
|
||||
var reason = AutoNestJob.RefusalReason(result);
|
||||
Assert.StartsWith("Auto Nest placed nothing; nothing was changed. Not placed: ", reason);
|
||||
Assert.Contains("too big", reason);
|
||||
Assert.Empty(Assert.Single(nest.Plates).Parts);
|
||||
Assert.Equal(1, tooBig.Quantity.Remaining);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace OpenNest.Desktop.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// OpenNest.Desktop is shared by the WinForms and Avalonia front ends, so it must stay free of
|
||||
/// any UI toolkit: no WinForms/WindowsDesktop framework, System.Drawing.Common or Avalonia.
|
||||
/// </summary>
|
||||
public class DesktopDependencyTests
|
||||
{
|
||||
private static readonly string[] ForbiddenPrefixes =
|
||||
[
|
||||
"System.Windows.Forms",
|
||||
"Microsoft.WindowsDesktop",
|
||||
"WindowsBase",
|
||||
"PresentationCore",
|
||||
"PresentationFramework",
|
||||
"System.Drawing.Common",
|
||||
"Avalonia",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void DesktopAssembly_ReferencesNoUiToolkit()
|
||||
{
|
||||
var assembly = Assembly.Load(new AssemblyName("OpenNest.Desktop"));
|
||||
var references = assembly.GetReferencedAssemblies().Select(a => a.Name!);
|
||||
|
||||
Assert.Empty(references.Where(IsForbidden));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesktopProject_TargetsPlainNet8WithoutUiFrameworks()
|
||||
{
|
||||
var project = XDocument.Load(Path.Combine(DesktopProjectDirectory(), "OpenNest.Desktop.csproj"));
|
||||
var elements = project.Descendants().ToList();
|
||||
|
||||
var targets = elements
|
||||
.Where(e => e.Name.LocalName is "TargetFramework" or "TargetFrameworks")
|
||||
.SelectMany(e => e.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.ToList();
|
||||
Assert.Equal(["net8.0"], targets);
|
||||
|
||||
Assert.DoesNotContain(elements, e =>
|
||||
e.Name.LocalName is "UseWindowsForms" or "UseWPF" or "UseWindowsDesktop"
|
||||
&& e.Value.Trim().Equals("true", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(elements, e => e.Name.LocalName == "FrameworkReference");
|
||||
|
||||
var packages = elements
|
||||
.Where(e => e.Name.LocalName == "PackageReference")
|
||||
.Select(e => (string?)e.Attribute("Include") ?? string.Empty);
|
||||
Assert.Empty(packages.Where(IsForbidden));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesktopProject_RestoresNoUiToolkitPackagesTransitively()
|
||||
{
|
||||
var assetsPath = Path.Combine(DesktopProjectDirectory(), "obj", "project.assets.json");
|
||||
Assert.True(File.Exists(assetsPath), $"Restore output not found: {assetsPath}");
|
||||
|
||||
using var assets = JsonDocument.Parse(File.ReadAllText(assetsPath));
|
||||
var libraries = assets.RootElement.GetProperty("libraries").EnumerateObject()
|
||||
.Select(library => library.Name.Split('/')[0]);
|
||||
|
||||
Assert.Empty(libraries.Where(IsForbidden));
|
||||
}
|
||||
|
||||
private static bool IsForbidden(string name) =>
|
||||
ForbiddenPrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static string DesktopProjectDirectory()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "OpenNest.sln")))
|
||||
directory = directory.Parent;
|
||||
|
||||
Assert.NotNull(directory);
|
||||
return Path.Combine(directory.FullName, "OpenNest.Desktop");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Undo;
|
||||
|
||||
namespace OpenNest.Desktop.Tests;
|
||||
|
||||
public sealed class DocumentTests : IDisposable
|
||||
{
|
||||
private readonly string directory =
|
||||
Directory.CreateTempSubdirectory("opennest-desktop-document-").FullName;
|
||||
|
||||
public void Dispose() => Directory.Delete(directory, recursive: true);
|
||||
|
||||
[Fact]
|
||||
public void SaveAs_WritesAReadableNestNamedAfterTheFile()
|
||||
{
|
||||
var document = new Document { Nest = TestNests.OneSquare() };
|
||||
var path = Path.Combine(directory, "Renamed Job.nest");
|
||||
|
||||
document.SaveAs(path);
|
||||
|
||||
Assert.Equal("Renamed Job", document.Name);
|
||||
Assert.Equal(path, document.LastSavePath);
|
||||
Assert.True(document.HasSavePath);
|
||||
var read = new NestReader(path).Read();
|
||||
Assert.Equal("Renamed Job", read.Name);
|
||||
Assert.Single(read.Plates);
|
||||
Assert.Single(read.Plates[0].Parts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveAs_MarksTheCurrentHistoryStateSaved()
|
||||
{
|
||||
var document = new Document { Nest = TestNests.OneSquare() };
|
||||
document.History.Reset(new NestUndoState(NestSnapshot.Capture(document.Nest)));
|
||||
document.Nest.Plates[0].Parts[0].Offset(1, 0);
|
||||
document.History.Commit("Move", new NestUndoState(NestSnapshot.Capture(document.Nest)));
|
||||
Assert.False(document.History.IsAtSavePoint);
|
||||
|
||||
document.SaveAs(Path.Combine(directory, "saved.nest"));
|
||||
|
||||
Assert.True(document.History.IsAtSavePoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_RoundTripsThroughTheNestReader()
|
||||
{
|
||||
var document = new Document { Nest = TestNests.OneSquare() };
|
||||
|
||||
using var stream = new MemoryStream(document.Serialize());
|
||||
var read = new NestReader(stream).Read();
|
||||
|
||||
Assert.Equal(document.Nest.Plates[0].Parts.Count, read.Plates[0].Parts.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using OpenNest.Forms;
|
||||
|
||||
namespace OpenNest.Desktop.Tests.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Only the pure strategy mapping is covered here: setting EngineName or loading the saved
|
||||
/// selection reads and writes the real per-user engine-selection.json.
|
||||
/// </summary>
|
||||
public class EngineSelectionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Fill")]
|
||||
[InlineData("Strip")]
|
||||
[InlineData("vertical remnant")]
|
||||
[InlineData(" Horizontal Remnant ")]
|
||||
public void BuiltInPlacementStrategies_AreFillStrategies(string name) =>
|
||||
Assert.True(EngineSelection.IsFillStrategy(name));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Default")]
|
||||
[InlineData("Irregular")]
|
||||
[InlineData("Rectangles")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void JobsOnlyEnginesAndBlanks_AreNotFillStrategies(string? name) =>
|
||||
Assert.False(EngineSelection.IsFillStrategy(name!));
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Forms;
|
||||
|
||||
namespace OpenNest.Desktop.Tests.Forms;
|
||||
|
||||
public class JobEngineNestTests
|
||||
{
|
||||
private sealed class Sink<T> : IProgress<T>
|
||||
{
|
||||
public List<T> Reports { get; } = [];
|
||||
|
||||
public void Report(T value) => Reports.Add(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatingReport_BecomesAOneBasedPlateDescription()
|
||||
{
|
||||
var progress = new Sink<NestProgress>();
|
||||
|
||||
JobEngineNest.CreateProgress("Irregular", progress)
|
||||
.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, "48x96", 2, 2, 30));
|
||||
|
||||
var report = Assert.Single(progress.Reports);
|
||||
Assert.Equal(NestPhase.Custom, report.Phase);
|
||||
Assert.Equal(3, report.PlateNumber);
|
||||
Assert.Equal("Irregular: evaluating plate 3 on stock 48x96", report.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownPlateIndex_CountsFromTheCommittedPlates()
|
||||
{
|
||||
var progress = new Sink<NestProgress>();
|
||||
|
||||
JobEngineNest.CreateProgress("Fill", progress)
|
||||
.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, "s", -1, 4, 0));
|
||||
|
||||
Assert.Equal(5, Assert.Single(progress.Reports).PlateNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommittedReport_NamesTheCommittedCounts()
|
||||
{
|
||||
var progress = new Sink<NestProgress>();
|
||||
|
||||
JobEngineNest.CreateProgress("Fill", progress)
|
||||
.Report(new NestJobProgress(NestJobStage.PlateCommitted, "s", 0, 2, 17));
|
||||
|
||||
var report = Assert.Single(progress.Reports);
|
||||
Assert.Equal(2, report.PlateNumber);
|
||||
Assert.Equal("Fill: committed plate 2 (17 parts placed)", report.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyProgress_PassesThroughUnchanged()
|
||||
{
|
||||
var progress = new Sink<NestProgress>();
|
||||
var legacy = new NestProgress { Phase = NestPhase.Linear, PlateNumber = 9 };
|
||||
|
||||
JobEngineNest.CreateProgress("Fill", progress)
|
||||
.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, "s", 0, 0, 0, legacy));
|
||||
|
||||
Assert.Same(legacy, Assert.Single(progress.Reports));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImprovedPlan_GoesToThePlanSinkOnly()
|
||||
{
|
||||
var progress = new Sink<NestProgress>();
|
||||
var plans = new Sink<IReadOnlyList<ProposedPlate>>();
|
||||
IReadOnlyList<ProposedPlate> sheets = [];
|
||||
|
||||
JobEngineNest.CreateProgress("Irregular", progress, plans)
|
||||
.Report(new NestJobProgress(NestJobStage.PlanImproved, "s", 0, 0, 0) { PlanSheets = sheets });
|
||||
|
||||
Assert.Empty(progress.Reports);
|
||||
Assert.Same(sheets, Assert.Single(plans.Reports));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImprovedPlan_WithoutAPlanSink_IsDescribed()
|
||||
{
|
||||
var progress = new Sink<NestProgress>();
|
||||
|
||||
JobEngineNest.CreateProgress("Irregular", progress)
|
||||
.Report(new NestJobProgress(NestJobStage.PlanImproved, "s", 0, 0, 0));
|
||||
|
||||
Assert.Equal("Irregular: found a better plan (0 sheets)", Assert.Single(progress.Reports).Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullReport_IsIgnored()
|
||||
{
|
||||
var progress = new Sink<NestProgress>();
|
||||
|
||||
JobEngineNest.CreateProgress("Fill", progress).Report(null!);
|
||||
|
||||
Assert.Empty(progress.Reports);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
<Using Include="Xunit" />
|
||||
<ProjectReference Include="../OpenNest.Desktop/OpenNest.Desktop.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,29 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Desktop.Tests;
|
||||
|
||||
internal static class TestNests
|
||||
{
|
||||
/// <summary>A nest with one 48x96 plate holding one 10x10 square part.</summary>
|
||||
public static Nest OneSquare()
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
program.Codes.Add(new LinearMove(new Vector(10, 0)));
|
||||
program.Codes.Add(new LinearMove(new Vector(10, 10)));
|
||||
program.Codes.Add(new LinearMove(new Vector(0, 10)));
|
||||
program.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
|
||||
var drawing = new Drawing("square", program);
|
||||
drawing.Quantity.Required = 1;
|
||||
|
||||
var plate = new Plate(new Size(48, 96));
|
||||
plate.Parts.Add(new Part(drawing));
|
||||
|
||||
var nest = new Nest { Name = "test" };
|
||||
nest.Drawings.Add(drawing);
|
||||
nest.Plates.Add(plate);
|
||||
return nest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using OpenNest.Undo;
|
||||
|
||||
namespace OpenNest.Desktop.Tests.Undo;
|
||||
|
||||
public class ChangeScopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void RootScope_EndsOnceAndCarriesChangesMergedFromNestedScopes()
|
||||
{
|
||||
var ends = new List<ChangeScope>();
|
||||
var root = ChangeScope.Root("Rotate", "key", ends.Add);
|
||||
|
||||
using (var nested = ChangeScope.Nested(root))
|
||||
{
|
||||
nested.MarkChanged();
|
||||
nested.Complete();
|
||||
Assert.Equal("Rotate", nested.Label);
|
||||
Assert.Null(nested.CoalesceKey);
|
||||
}
|
||||
|
||||
Assert.Empty(ends);
|
||||
root.Complete();
|
||||
root.Dispose();
|
||||
root.Dispose();
|
||||
|
||||
var ended = Assert.Single(ends);
|
||||
Assert.Same(root, ended);
|
||||
Assert.True(ended.IsChanged);
|
||||
Assert.True(ended.Succeeded);
|
||||
Assert.Equal("key", ended.CoalesceKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncompleteNestedScope_FailsTheWholeEntry()
|
||||
{
|
||||
ChangeScope ended = null!;
|
||||
var root = ChangeScope.Root("Fill", null, scope => ended = scope);
|
||||
|
||||
using (ChangeScope.Nested(root))
|
||||
{
|
||||
// Left incomplete, as when the nested edit throws or is canceled.
|
||||
}
|
||||
|
||||
root.MarkChanged();
|
||||
root.Complete();
|
||||
root.Dispose();
|
||||
|
||||
Assert.True(ended.IsChanged);
|
||||
Assert.False(ended.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RootScope_DisposedWithoutComplete_DoesNotSucceed()
|
||||
{
|
||||
ChangeScope ended = null!;
|
||||
using (var root = ChangeScope.Root("Move", null, scope => ended = scope))
|
||||
root.MarkChanged();
|
||||
|
||||
Assert.False(ended.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetachedScope_RecordsNothingAndToleratesDispose()
|
||||
{
|
||||
var scope = ChangeScope.Detached();
|
||||
Assert.False(scope.IsChanged);
|
||||
|
||||
scope.MarkChanged();
|
||||
scope.Complete();
|
||||
scope.Dispose();
|
||||
|
||||
Assert.True(scope.IsChanged);
|
||||
Assert.Null(scope.Label);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using OpenNest.Undo;
|
||||
|
||||
namespace OpenNest.Desktop.Tests.Undo;
|
||||
|
||||
public class NestFingerprintTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnchangedNest_KeepsItsFingerprint()
|
||||
{
|
||||
var nest = TestNests.OneSquare();
|
||||
|
||||
Assert.Equal(NestFingerprint.Compute(nest), NestFingerprint.Compute(nest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MovingAPart_ChangesTheFingerprint()
|
||||
{
|
||||
var nest = TestNests.OneSquare();
|
||||
var before = NestFingerprint.Compute(nest);
|
||||
|
||||
nest.Plates[0].Parts[0].Offset(1, 0);
|
||||
|
||||
Assert.NotEqual(before, NestFingerprint.Compute(nest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangingARequiredQuantity_ChangesTheFingerprint()
|
||||
{
|
||||
var nest = TestNests.OneSquare();
|
||||
var before = NestFingerprint.Compute(nest);
|
||||
|
||||
nest.Drawings.First().Quantity.Required = 5;
|
||||
|
||||
Assert.NotEqual(before, NestFingerprint.Compute(nest));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Desktop
|
||||
{
|
||||
/// <summary>
|
||||
/// Auto Nest with the desktop dialog's defaults and no dialog: every drawing's remaining
|
||||
/// quantity, with its priority and rotation constraints, on sheets like a template plate,
|
||||
/// through <see cref="NestPipeline"/>. Only a valid, keepable result that places something
|
||||
/// is committed, onto the nest's empty plates first and then new plates from its defaults.
|
||||
/// </summary>
|
||||
public static class AutoNestJob
|
||||
{
|
||||
/// <summary>
|
||||
/// The job for <paramref name="nest"/>'s remaining quantities on stock like
|
||||
/// <paramref name="template"/>; null when nothing remains. The nest is not changed, so
|
||||
/// the caller can solve it off its thread.
|
||||
/// </summary>
|
||||
public static NestPipelineRequest ForRemaining(Nest nest, Plate template, string engineName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
var items = nest.Drawings
|
||||
.Where(d => !d.IsCutOff && d.Quantity.Remaining > 0)
|
||||
.Select(d => new NestItem
|
||||
{
|
||||
Drawing = d,
|
||||
Quantity = d.Quantity.Remaining,
|
||||
Priority = d.Priority,
|
||||
RotationStart = d.Constraints.StartAngle,
|
||||
RotationEnd = d.Constraints.EndAngle,
|
||||
StepAngle = d.Constraints.StepAngle,
|
||||
})
|
||||
.ToList();
|
||||
if (items.Count == 0)
|
||||
return null;
|
||||
|
||||
return new NestPipelineRequest(engineName, items,
|
||||
NestStockBuilder.FromTemplate(template, null),
|
||||
new NestJobOptions(maxPlates: NestRenest.MaxPlates, salvageRate: 0,
|
||||
minimumSalvageDimension: nest.MinimumRemnantDimension));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits <paramref name="result"/> to <paramref name="nest"/> on the nest's own thread
|
||||
/// when it is valid, keepable and places something; otherwise leaves the nest unchanged
|
||||
/// and says why in the note.
|
||||
/// </summary>
|
||||
public static NestRenestResult Keep(Nest nest, NestPipelineResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
return NestRenest.Keep(result,
|
||||
valid => NestPipelineCommit.ApplyToPlates(valid, () => EmptyOrNewPlate(nest)));
|
||||
}
|
||||
|
||||
private static Plate EmptyOrNewPlate(Nest nest) =>
|
||||
nest.Plates.FirstOrDefault(p => p.Parts.Count == 0) ?? nest.CreatePlate();
|
||||
|
||||
/// <summary>
|
||||
/// Why <see cref="Keep"/> would commit nothing for <paramref name="result"/>, naming the
|
||||
/// first problem; null when it would commit.
|
||||
/// </summary>
|
||||
public static string RefusalReason(NestPipelineResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
if (!result.IsValid)
|
||||
return $"Auto Nest found {result.Violations.Count} layout problem"
|
||||
+ $"{(result.Violations.Count == 1 ? "" : "s")} (first: {result.Violations[0]}); nothing was changed.";
|
||||
if (!result.CanKeep)
|
||||
return "Auto Nest's layout cannot be kept faithfully; nothing was changed.";
|
||||
if (result.Plates.All(p => p.Parts.Count == 0))
|
||||
return "Auto Nest placed nothing; nothing was changed."
|
||||
+ (result.Unplaced.Count > 0
|
||||
? " Not placed: " + string.Join("; ", result.Unplaced.Select(u => u.Describe())) + "."
|
||||
: "");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Toolkit-neutral desktop logic shared by the WinForms and Avalonia front ends.
|
||||
No UI toolkit: OpenNest.Desktop.Tests guards against WinForms, WindowsDesktop,
|
||||
System.Drawing.Common and Avalonia references. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Desktop</RootNamespace>
|
||||
<AssemblyName>OpenNest.Desktop</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Reporting\OpenNest.Reporting.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
// The WinForms app drives undo recording through ChangeScope's internal root/nested factories
|
||||
// and NestFingerprint; both desktop front ends report Auto Nest progress through the internal
|
||||
// JobEngineNest.
|
||||
[assembly: InternalsVisibleTo("OpenNest")]
|
||||
[assembly: InternalsVisibleTo("OpenNest.Avalonia")]
|
||||
[assembly: InternalsVisibleTo("OpenNest.Desktop.Tests")]
|
||||
File renamed without changes.
@@ -8,7 +8,7 @@ namespace OpenNest.Undo
|
||||
/// cut-offs, and the drawings with their required quantities. Two equal fingerprints do not
|
||||
/// prove two nests equal; a differing one means the nest changed since it was taken. Objects
|
||||
/// count by reference, so compare fingerprints of one live nest, never across a restore.
|
||||
/// Derived values (nested counts, colours, the modified date) are left out.
|
||||
/// Derived values (nested counts, colors, the modified date) are left out.
|
||||
/// </summary>
|
||||
internal static class NestFingerprint
|
||||
{
|
||||
Loaded 100 of 482 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user