Compare commits
199
Commits
9b69c67572
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38324628b0 | ||
|
|
132bb07bc9 | ||
|
|
50884daca1 | ||
|
|
fc53c7732e | ||
|
|
ab1f3a28d1 | ||
|
|
43ebd61fdf | ||
|
|
bf608fa271 | ||
|
|
fa5e5a8e29 | ||
|
|
388816b015 | ||
|
|
3bdefb1d1c | ||
|
|
7f648e6400 | ||
|
|
b688ce896f | ||
|
|
1eb509740e | ||
|
|
ad36101b7f | ||
|
|
00ede7c5d0 | ||
|
|
314ca2f2a6 | ||
|
|
64045be968 | ||
|
|
437d659c9d | ||
|
|
10ed3d3a30 | ||
|
|
ef2f9aa714 | ||
|
|
b15c4cf6c6 | ||
|
|
e4d64a88eb | ||
|
|
74d2b6aa68 | ||
|
|
64d0e50df3 | ||
|
|
7d179c696a | ||
|
|
662bc133b1 | ||
|
|
b3a4390169 | ||
|
|
b8bded1b09 | ||
|
|
ac901f7ee9 | ||
|
|
43253cd9ab | ||
|
|
7aeb636ca2 | ||
|
|
c914d2bbd9 | ||
|
|
481c3e5128 | ||
|
|
29953ea601 | ||
|
|
99c31748ba | ||
|
|
2b8a3ce7db | ||
|
|
36de55f10f | ||
|
|
55fe0ef228 | ||
|
|
8720580004 | ||
|
|
c3dd346b7a | ||
|
|
4afab63046 | ||
|
|
2cc06d1dc7 | ||
|
|
45d2dfced4 | ||
|
|
a5fddec0e9 | ||
|
|
a8204bb51f | ||
|
|
e4d07121c8 | ||
|
|
76044f6bd9 | ||
|
|
1581260e31 | ||
|
|
19c3a89c32 | ||
|
|
4dbdc2678d | ||
|
|
fb32d508f8 | ||
|
|
1f637ca43c | ||
|
|
648b0eaca5 | ||
|
|
b254a0bd3e | ||
|
|
74302761ce | ||
|
|
4da48eed68 | ||
|
|
6803d7519f | ||
|
|
86dbd8819b | ||
|
|
20395eed9b | ||
|
|
a886735040 | ||
|
|
75d41f3bb7 | ||
|
|
26252b333a | ||
|
|
c219ec4e66 | ||
|
|
32586149c6 | ||
|
|
9ec28add80 | ||
|
|
35aa64fe4e | ||
|
|
c33337cea2 | ||
|
|
b0997f614b | ||
|
|
6d7c98ef4a | ||
|
|
f44c1d465f | ||
|
|
bdb78a689d | ||
|
|
3f89f8f5dd | ||
|
|
3cab90747a | ||
|
|
fff3bef4e4 | ||
|
|
2b78fb3a75 | ||
|
|
a04460c57b | ||
|
|
27948ba8cf | ||
|
|
1fdede396b | ||
|
|
1b9988a1ba | ||
|
|
ca1902e075 | ||
|
|
bc5fd86996 | ||
|
|
1b23ad79f2 | ||
|
|
a27290a29c | ||
|
|
f44a1a3d2c | ||
|
|
2b5485f6cf | ||
|
|
a98a49c00a | ||
|
|
fdbc1fb99f | ||
|
|
82feb78b0f | ||
|
|
c825f40213 | ||
|
|
0df2587cf2 | ||
|
|
094c4c196b | ||
|
|
77b729e58c | ||
|
|
1d1c60daa7 | ||
|
|
1e8e532063 | ||
|
|
d70505b7c0 | ||
|
|
8188533d72 | ||
|
|
6863c8bdb1 | ||
|
|
4553f8afad | ||
|
|
4ec92c95ec | ||
|
|
b2c864a328 | ||
|
|
6efa6b1117 | ||
|
|
cec7396da6 | ||
|
|
b318950a54 | ||
|
|
1b862dc1a8 | ||
|
|
d1f3907505 | ||
|
|
4fb924b03a | ||
|
|
39db4dc10f | ||
|
|
23dd99fa2f | ||
|
|
695ccc0a3b | ||
|
|
98d0124172 | ||
|
|
4161e6d1c7 | ||
|
|
896ed2026a | ||
|
|
7f63c725e6 | ||
|
|
ec5f57171f | ||
|
|
dd1a958f5c | ||
|
|
ae2b0beb45 | ||
|
|
6ab45e6de7 | ||
|
|
029299ccf8 | ||
|
|
4cdb39870b | ||
|
|
95833236cf | ||
|
|
1b5e1b14a6 | ||
|
|
e3d10e95ae | ||
|
|
22d21924b8 | ||
|
|
87f7ddad1a | ||
|
|
23fcec4b6c | ||
|
|
d6d819bfed | ||
|
|
c45909e397 | ||
|
|
1c363504f5 | ||
|
|
dc9e83ef17 | ||
|
|
1a7ed3c759 | ||
|
|
b122298f57 | ||
|
|
2ecd94c705 | ||
|
|
26c17a386b | ||
|
|
4dd00c14c4 | ||
|
|
5064340eb3 | ||
|
|
062c7fa08f | ||
|
|
9af97c70b0 | ||
|
|
01789c5929 | ||
|
|
dceb5f7d18 | ||
|
|
10fe00d8ab | ||
|
|
9b9386b029 | ||
|
|
12f97474b7 | ||
|
|
a6bc9d8be6 | ||
|
|
7964c87eb9 | ||
|
|
45a59c6b02 | ||
|
|
1c8305e8a1 | ||
|
|
828500f984 | ||
|
|
8e4735d25d | ||
|
|
5bbb7b7461 | ||
|
|
57e9f625b6 | ||
|
|
e65f849c7a | ||
|
|
1be0904506 | ||
|
|
98c4929d14 | ||
|
|
014c071716 | ||
|
|
da1f8120f1 | ||
|
|
0512f3f84a | ||
|
|
b14cb9d10a | ||
|
|
a0417a6077 | ||
|
|
56f39556d1 | ||
|
|
86f6c9efa1 | ||
|
|
6f250b5730 | ||
|
|
c28bc0da21 | ||
|
|
88966d118c | ||
|
|
f36e124039 | ||
|
|
856dbfd8af | ||
|
|
eb8fbec1aa | ||
|
|
073ead9b79 | ||
|
|
bb671cb176 | ||
|
|
6bcea7919a | ||
|
|
ed908b9706 | ||
|
|
e69ec07830 | ||
|
|
eafa0fab01 | ||
|
|
9b9e510510 | ||
|
|
451841401a | ||
|
|
fd3375cde6 | ||
|
|
42bbde7433 | ||
|
|
64d38c452c | ||
|
|
630d514b0e | ||
|
|
a764a70e52 | ||
|
|
2a855139d2 | ||
|
|
f23f0fa566 | ||
|
|
6f38c11383 | ||
|
|
f34b3c4449 | ||
|
|
365825bc5a | ||
|
|
02fc0ea3db | ||
|
|
451876c02f | ||
|
|
5061b41a5d | ||
|
|
e7cbd99db6 | ||
|
|
27684c3782 | ||
|
|
54694f9b17 | ||
|
|
de1248589a | ||
|
|
a9ebd8bb55 | ||
|
|
aec0523062 | ||
|
|
8e6fa677fb | ||
|
|
589d341455 | ||
|
|
1a05391d94 | ||
|
|
f5d27652f4 | ||
|
|
ea4bd836cd | ||
|
|
a085339ba9 |
@@ -0,0 +1,68 @@
|
|||||||
|
# Unified code style for OpenNest.
|
||||||
|
# Canonical formatter: dotnet format.
|
||||||
|
# Format only the files or directories being changed:
|
||||||
|
# dotnet format OpenNest.sln --include path/to/changed-file.cs
|
||||||
|
# dotnet format OpenNest.sln --include path/to/changed-file.cs --verify-no-changes
|
||||||
|
# These settings are used by dotnet format and IDE auto-formatting
|
||||||
|
# (VS / Rider / VS Code).
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{csproj,props,targets,xml,config,manifest}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{json,yml,yaml}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{cs,csx}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
# dotnet format cannot re-wrap source to this limit, but IDEs can surface it
|
||||||
|
# as a visual guide and analyzers can flag hard violations.
|
||||||
|
max_line_length = 100
|
||||||
|
|
||||||
|
# --- Using directives (System first, outside the namespace) ---
|
||||||
|
dotnet_sort_system_directives_first = true
|
||||||
|
csharp_using_directive_placement = outside_namespace:warning
|
||||||
|
|
||||||
|
# --- Brace placement: Allman (opening brace on its own line) ---
|
||||||
|
csharp_new_line_before_open_brace = all
|
||||||
|
csharp_new_line_before_else = true
|
||||||
|
csharp_new_line_before_catch = true
|
||||||
|
csharp_new_line_before_finally = true
|
||||||
|
csharp_new_line_before_members_in_object_initializers = true
|
||||||
|
csharp_new_line_before_members_in_anonymous_types = true
|
||||||
|
csharp_new_line_between_query_expression_clauses = true
|
||||||
|
|
||||||
|
# --- Spacing ---
|
||||||
|
csharp_space_after_keywords_in_control_flow_statements = true
|
||||||
|
csharp_space_between_method_call_parameter_list_parentheses = false
|
||||||
|
csharp_space_between_method_declaration_parameter_list_parentheses = false
|
||||||
|
csharp_space_between_parentheses = false
|
||||||
|
csharp_space_before_colon_in_inheritance_clause = true
|
||||||
|
csharp_space_after_colon_in_inheritance_clause = true
|
||||||
|
csharp_space_around_binary_operators = before_and_after
|
||||||
|
csharp_space_after_cast = false
|
||||||
|
csharp_space_after_comma = true
|
||||||
|
csharp_space_before_comma = false
|
||||||
|
|
||||||
|
# --- Code style preferences ---
|
||||||
|
# Project rule: always use var for locals (see CLAUDE.md).
|
||||||
|
csharp_style_var_for_built_in_types = true:suggestion
|
||||||
|
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||||
|
csharp_style_var_elsewhere = true:suggestion
|
||||||
|
csharp_prefer_braces = true:suggestion
|
||||||
|
csharp_prefer_simple_using_statement = true:suggestion
|
||||||
|
csharp_style_namespace_declarations = file_scoped:silent
|
||||||
|
dotnet_style_prefer_auto_properties = true:suggestion
|
||||||
|
dotnet_style_object_initializer = true:suggestion
|
||||||
|
dotnet_style_collection_initializer = true:suggestion
|
||||||
|
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Commits whose changes git blame should skip (whitespace-only sweeps).
|
||||||
|
# Enable locally: git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||||
|
aec052306234e3c4313c0ee8905e2557d3c3671b
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
name: Windows release build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['release/**']
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Release version (for example 0.3.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
windows:
|
||||||
|
runs-on: windows-2022
|
||||||
|
timeout-minutes: 30
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
|
||||||
|
DOTNET_NOLOGO: '1'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: Resolve version
|
||||||
|
env:
|
||||||
|
INPUT_VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
$version = $env:INPUT_VERSION
|
||||||
|
if ($env:GITHUB_EVENT_NAME -eq 'push') {
|
||||||
|
$version = $env:GITHUB_REF_NAME -replace '^release/', '' -replace '^v', ''
|
||||||
|
}
|
||||||
|
if ($version -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$') {
|
||||||
|
throw "Expected an X.Y.Z version, not '$version'."
|
||||||
|
}
|
||||||
|
"RELEASE_VERSION=$version" >> $env:GITHUB_ENV
|
||||||
|
- name: Build solution
|
||||||
|
run: |
|
||||||
|
dotnet build OpenNest.sln -c Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||||
|
- name: Run all test projects on Windows
|
||||||
|
run: |
|
||||||
|
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||||
|
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
||||||
|
}
|
||||||
|
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --logger 'trx;LogFileName=OpenNest.Tests.Debug.trx' --results-directory TestResults
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Debug tests failed.' }
|
||||||
|
- name: Publish and verify Windows package
|
||||||
|
run: ./scripts/Publish-Windows.ps1 -Version $env:RELEASE_VERSION
|
||||||
|
- name: Verify overwrite protection
|
||||||
|
run: |
|
||||||
|
$zip = Get-ChildItem artifacts/*.zip
|
||||||
|
$before = (Get-FileHash $zip.FullName).Hash
|
||||||
|
$rejected = $false
|
||||||
|
try { ./scripts/Publish-Windows.ps1 -Version $env:RELEASE_VERSION }
|
||||||
|
catch {
|
||||||
|
if ($_.Exception.Message -notlike 'Refusing to overwrite existing output:*') { throw }
|
||||||
|
$rejected = $true
|
||||||
|
}
|
||||||
|
if (-not $rejected) { throw 'Existing output was not rejected.' }
|
||||||
|
if ((Get-FileHash $zip.FullName).Hash -ne $before) { throw 'Existing ZIP changed.' }
|
||||||
|
Write-Host 'PASS: existing release package preserved.'
|
||||||
|
- name: Upload verified release candidate
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: OpenNest-${{ env.RELEASE_VERSION }}-win-x64
|
||||||
|
path: |
|
||||||
|
artifacts/*.zip
|
||||||
|
artifacts/*.sha256
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
compression-level: 0
|
||||||
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: windows-test-results
|
||||||
|
path: TestResults/*.trx
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 14
|
||||||
+10
-1
@@ -11,6 +11,7 @@
|
|||||||
*.userprefs
|
*.userprefs
|
||||||
|
|
||||||
# Build results
|
# Build results
|
||||||
|
/artifacts/
|
||||||
[Dd]ebug/
|
[Dd]ebug/
|
||||||
[Dd]ebugPublic/
|
[Dd]ebugPublic/
|
||||||
[Rr]elease/
|
[Rr]elease/
|
||||||
@@ -206,13 +207,21 @@ FakesAssemblies/
|
|||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|
||||||
# Claude Code
|
# Local agent state and temporary planning/progress documents
|
||||||
.claude/
|
.claude/
|
||||||
|
/.hermes/plans/
|
||||||
|
/.hermes/progress/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
/docs/*-plan.md
|
||||||
|
/docs/*-progress.md
|
||||||
|
|
||||||
# Launch settings
|
# Launch settings
|
||||||
**/Properties/launchSettings.json
|
**/Properties/launchSettings.json
|
||||||
|
|
||||||
# Local test config (contains user-specific paths to proprietary test assets)
|
# Local test config (contains user-specific paths to proprietary test assets)
|
||||||
OpenNest.Tests/test-config.json
|
OpenNest.Tests/test-config.json
|
||||||
|
|
||||||
|
# Vendor programming manuals: keep reference copies outside source control.
|
||||||
|
CINCINNATI LASER PROGRAMMING MANUAL.pdf
|
||||||
|
TF5200_programming_manual_en.pdf
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"opennest": {
|
"opennest": {
|
||||||
"command": "cmd",
|
"command": "${USERPROFILE}/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe",
|
||||||
"args": ["/c", "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/run.cmd"]
|
"args": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# OpenNest agent instructions
|
||||||
|
|
||||||
|
Shared instructions; keep `CLAUDE.md` as the thin `@AGENTS.md` import.
|
||||||
|
OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform libraries.
|
||||||
|
|
||||||
|
## Working rules
|
||||||
|
|
||||||
|
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
|
||||||
|
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
|
||||||
|
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`.
|
||||||
|
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
|
||||||
|
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
|
||||||
|
|
||||||
|
## Build and test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Full solution: Windows
|
||||||
|
dotnet build OpenNest.sln
|
||||||
|
|
||||||
|
# Cross-platform suites: run independently on Linux/macOS/Windows
|
||||||
|
dotnet test OpenNest.Tests/OpenNest.Tests.csproj
|
||||||
|
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
|
||||||
|
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
|
||||||
|
|
||||||
|
# Windows runtime tests
|
||||||
|
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
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. Gitea is authoritative for Git refs.
|
||||||
|
|
||||||
|
## Project map and boundaries
|
||||||
|
|
||||||
|
- `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter.
|
||||||
|
- `OpenNest.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. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
|
||||||
|
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory.
|
||||||
|
- `OpenNest.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.
|
||||||
|
|
||||||
|
## Geometry and ownership safeguards
|
||||||
|
|
||||||
|
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
|
||||||
|
- Clipper is for cached CPU region preparation, never per-pair hot loops. Preserve the hand-written `Collision` kernel's GPU-port contract. Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding. Do not loosen tolerances to hide failures.
|
||||||
|
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
|
||||||
|
- `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.
|
||||||
|
- 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.
|
||||||
|
- 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)
|
||||||
|
- [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)
|
||||||
|
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
|
||||||
|
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
|
||||||
|
- [Pre-post verification](docs/post-verification.md)
|
||||||
|
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
|
||||||
|
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
|
||||||
@@ -1,136 +1 @@
|
|||||||
# CLAUDE.md
|
@AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts on material plates to minimize waste. It imports DXF drawings, places parts onto plates using NFP-based (No Fit Polygon) and rectangle-packing algorithms, and can export nest layouts as DXF or post-process them to G-code for CNC cutting machines.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-windows`. Build with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet build OpenNest.sln
|
|
||||||
```
|
|
||||||
|
|
||||||
Cross-platform whole-job engine tests (net8.0, runs on Linux/macOS/Windows without the desktop project or DXF fixtures): `dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj`. The existing `OpenNest.Tests` suite targets `net8.0-windows` and requires a Windows runner; cross-compiling on Linux is not Windows runtime verification.
|
|
||||||
|
|
||||||
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training).
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Nine projects form a layered architecture:
|
|
||||||
|
|
||||||
### OpenNest.Core (class library)
|
|
||||||
Domain model, geometry, and CNC primitives organized into namespaces:
|
|
||||||
|
|
||||||
- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
|
|
||||||
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output.
|
|
||||||
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction).
|
|
||||||
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
|
|
||||||
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
|
|
||||||
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
|
|
||||||
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
|
|
||||||
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
|
|
||||||
- **Splitting** (`Splitting/`, `namespace OpenNest`): `DrawingSplitter` splits a Drawing into multiple pieces along split lines. `ISplitFeature` strategy pattern with implementations: `StraightSplit` (clean edge), `WeldGapTabSplit` (rectangular tab spacers on one side), `SpikeGrooveSplit` (interlocking spike/V-groove pairs). `AutoSplitCalculator` computes split lines for fit-to-plate and split-by-count modes. Supporting types: `SplitLine`, `SplitParameters`, `SplitFeatureResult`.
|
|
||||||
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
|
|
||||||
|
|
||||||
### OpenNest.Engine (class library, depends on Core)
|
|
||||||
Nesting algorithms provide both a legacy single-plate API and a whole-job API. The legacy path centers on `NestEngineBase`, `DefaultNestEngine` (formerly `NestEngine`), and the global `NestEngineRegistry`. New job callers use immutable, ID-based contracts in `Jobs/`: `INestingEngine.Solve(NestJob)` returns `NestJobResult`; `NestJobRunner` alone commits demand and finite/unlimited stock accounting; `IPlateNester` only proposes a one-sheet candidate; and `PlateNesterFactory` resolves a named strategy without reading or changing the process-global registry.
|
|
||||||
|
|
||||||
- **Whole-job API (`Jobs/`)**: `NestJob` owns part requirements, physical stock, and options for one material/thickness/unit system. `PartGeometrySnapshot` contains owned flat rapid/line/arc geometry; results contain stock IDs and placement poses (radians), not mutable desktop models. `NestJobPlacementValidator` validates contours, rotation, usable work area, overlap, and spacing before accounting commits. The runner selects valid trial candidates greedily by priority vector, sheet area, envelope, and input order; an incomplete result reports why but does not prove geometric impossibility. `DrawingJobMapper` and `NestResultMaterializer` are the domain-boundary adapters.
|
|
||||||
- **Placement boundary (`Jobs/Placement/`, `Jobs/Adapters/`)**: `DefaultPlateNester` and `StripPlateNester` are migrated built-ins with run-scoped private geometry; `LegacyPlateNesterAdapter` remains for remnant strategies and legacy plugins/callers during rollout. Job-path identity is reference-based rather than drawing name; `PlateOptimizer` and NFP/`AutoNester` retain legacy name-based helpers and are deliberately outside the runner path.
|
|
||||||
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases) → `VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`. Existing desktop, CLI, and MCP callers remain on this compatibility path until separate migrations preserve their existing-plate, preview, and accept/cancel semantics.
|
|
||||||
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`.
|
|
||||||
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
|
|
||||||
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
|
|
||||||
- **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`.
|
|
||||||
- **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
|
|
||||||
- **RectanglePacking/** (`namespace OpenNest.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
|
|
||||||
- **CirclePacking/** (`namespace OpenNest.CirclePacking`): Alternative packing for circular parts.
|
|
||||||
- **Nfp/** (`namespace OpenNest.Engine.Nfp`): Internal NFP-based single-part placement utilities — `AutoNester` (NFP placement with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`OptimizationResult`. Not exposed as a nest engine; used internally for individual part placement.
|
|
||||||
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data).
|
|
||||||
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
|
|
||||||
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
|
|
||||||
|
|
||||||
### OpenNest.IO (class library, depends on Core)
|
|
||||||
File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
|
|
||||||
|
|
||||||
- `DxfImporter`/`DxfExporter` — DXF file import/export via ACadSharp.
|
|
||||||
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
|
|
||||||
- `ProgramReader` — G-code text parser.
|
|
||||||
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
|
|
||||||
- `CadImporter` — shared "DXF → Drawing" service used by the UI, console, MCP, API, and training projects. Two-stage API: `Import(path, options)` loads raw entities, runs bend detection, and returns a mutable `CadImportResult`; `BuildDrawing(result, visible, bends, quantity, customer, editedProgram)` produces a fully-populated `Drawing` with `Source.Offset`, `SourceEntities`, `SuppressedEntityIds`, and bends. `ImportDrawing(path, options)` composes both stages for headless callers.
|
|
||||||
- `CadImportOptions`, `CadImportResult` — inputs and intermediate state for `CadImporter`.
|
|
||||||
|
|
||||||
### OpenNest.Console (console app, depends on Core + Engine + IO)
|
|
||||||
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
|
|
||||||
|
|
||||||
### OpenNest.Gpu (class library, depends on Core + Engine)
|
|
||||||
GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods.
|
|
||||||
|
|
||||||
### OpenNest.Training (console app, depends on Core + Engine)
|
|
||||||
Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training.
|
|
||||||
|
|
||||||
### OpenNest.Benchmark (console app, depends on Core + Engine + IO)
|
|
||||||
Compares registered `INestingEngine` implementations against each other on real `.nest` files. Each engine solves the whole job — it owns its own multi-plate/size strategy rather than being handed one already-sized plate at a time. Fully generic — it never hardcodes drawing geometry, just reads whatever drawings/quantities/plate settings each input file already has.
|
|
||||||
|
|
||||||
- `JobLoader` builds `BenchmarkJob`s from a `.nest` file or a folder of them via `NestReader`, using every drawing with `Quantity.Required > 0`. `--sheet-sizes` can sweep a fixed list of plate sizes instead of each file's own.
|
|
||||||
- `BenchmarkJob.BuildNestJob(maxPlates)` converts the job into a `NestJob`: one `NestJobPart` per requested drawing (via `DrawingJobMapper.FromDrawing`) and one `NestPlateStock` per candidate sheet size (unlimited quantity — the engine decides how many of each size it uses).
|
|
||||||
- `BenchmarkRunner` calls each engine's `INestingEngine.Solve(NestJob)` once per job, under a wall-clock timeout so a runaway or hanging engine can't stall the whole benchmark run, then materializes the result back into legacy `Plate`/`Part` objects via `NestResultMaterializer` for scoring.
|
|
||||||
- `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically via each part's own world-space polygon, inflated by the spacing — works on arbitrary concave/holed shapes, not just bounding boxes), and no drawing over its requested quantity. An invalid, throwing, or timed-out run scores zero for that job.
|
|
||||||
- Scoring matches `Plate.Utilization()` (placed drawing area / full sheet area, `Plate.Area()`). If an engine placed every requested part, ties are broken by fewer plates used (`Report`'s ranking rule) — using fewer sheets to do the same job wastes less material.
|
|
||||||
- `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv <path>` writes a flat per-job CSV alongside the console report.
|
|
||||||
|
|
||||||
### OpenNest.Mcp (console app, depends on Core + Engine + IO)
|
|
||||||
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
|
|
||||||
|
|
||||||
- **Tools/InputTools**: `load_nest`, `import_dxf`, `create_drawing` (built-in shapes or G-code).
|
|
||||||
- **Tools/SetupTools**: `create_plate`, `clear_plate`.
|
|
||||||
- **Tools/NestingTools**: `fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`.
|
|
||||||
- **Tools/InspectionTools**: `get_plate_info`, `get_parts`, `check_overlaps`.
|
|
||||||
- `NestSession` — in-memory state across tool calls (current Nest, standalone plates/drawings).
|
|
||||||
|
|
||||||
### OpenNest (WinForms WinExe, depends on Core + Engine + IO)
|
|
||||||
The UI application with MDI interface.
|
|
||||||
|
|
||||||
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
|
|
||||||
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
|
|
||||||
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`, `ActionCutOff`.
|
|
||||||
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime.
|
|
||||||
|
|
||||||
## File Format
|
|
||||||
|
|
||||||
Nest files (`.nest`, ZIP-based) use v2 JSON format:
|
|
||||||
- `nest.json` — single JSON file containing all nest metadata: nest info (name, units, customer, dates, notes), plate defaults (size, thickness, quadrant, spacing, material, edge spacing), drawings array (id, name, color, quantity, priority, rotation constraints, material, source), and plates array (id, size, material, edge spacing, parts with drawingId/x/y/rotation, cutoffs with x/y/axis/startLimit/endLimit)
|
|
||||||
- `programs/program-N` — G-code text for each drawing's cut program (N = drawing id)
|
|
||||||
- `bestfits/bestfit-N` — JSON array of best-fit pair evaluation results per drawing, keyed by plate size/spacing (optional, only present if best-fit data was computed)
|
|
||||||
|
|
||||||
## Tool Preferences
|
|
||||||
|
|
||||||
Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary method for exploring and analyzing this codebase. It is faster and more efficient than file-based searches. Use it for finding symbols, references, diagnostics, type hierarchies, and code navigation. Only fall back to Glob/Grep when Roslyn Bridge cannot fulfill the query.
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
- Always use `var` instead of explicit types (e.g., `var parts = new List<Part>();` not `List<Part> parts = new List<Part>();`).
|
|
||||||
|
|
||||||
## Documentation Maintenance
|
|
||||||
|
|
||||||
Always keep `README.md` and `CLAUDE.md` up to date when making changes that affect project structure, architecture, build instructions, dependencies, or key patterns. If you add a new project, change a namespace, modify the build process, or alter significant behavior, update both files as part of the same change.
|
|
||||||
|
|
||||||
**Do not commit** design specs, implementation plans, or other temporary planning documents (`docs/superpowers/` etc.) to the repository. These are working documents only — keep them local and untracked.
|
|
||||||
|
|
||||||
## Key Patterns
|
|
||||||
|
|
||||||
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
|
|
||||||
- OpenNest.Engine uses sub-namespaces: `OpenNest.Engine.Fill` (fill algorithms), `OpenNest.Engine.Strategies` (pluggable strategy layer), `OpenNest.Engine.BestFit`, `OpenNest.Engine.Nfp` (NFP-based nesting, not yet integrated), `OpenNest.Engine.ML`, `OpenNest.Engine.RapidPlanning`, `OpenNest.Engine.Sequencing`.
|
|
||||||
- `ObservableList<T>` provides ItemAdded/ItemRemoved/ItemChanged events used for automatic quantity tracking between plates and drawings.
|
|
||||||
- Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion).
|
|
||||||
- `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations.
|
|
||||||
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
|
|
||||||
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
|
|
||||||
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
|
|
||||||
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
|
|
||||||
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
|
|
||||||
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
|
|
||||||
|
|||||||
@@ -6,17 +6,23 @@ namespace OpenNest.Api;
|
|||||||
public class NestRequest
|
public class NestRequest
|
||||||
{
|
{
|
||||||
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
|
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Explicit available physical stock. Null keeps the legacy unlimited SheetSize fallback;
|
/// Explicit available physical stock. Null keeps the legacy unlimited SheetSize fallback;
|
||||||
/// an empty list deliberately means no stock is available.
|
/// an empty list deliberately means no stock is available.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<NestRequestPlate> Plates { get; init; }
|
public IReadOnlyList<NestRequestPlate> Plates { get; init; }
|
||||||
public Size SheetSize { get; init; } = new(60, 120);
|
public Size SheetSize { get; init; } = new(60, 120);
|
||||||
|
|
||||||
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
|
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
|
||||||
public string PlacementStrategy { get; init; } = "Default";
|
public string PlacementStrategy { get; init; } = "Default";
|
||||||
|
|
||||||
|
/// <summary>Registered whole-job engine. Null uses PlacementStrategy (or legacy Strategy).</summary>
|
||||||
|
public string Engine { get; init; }
|
||||||
public string Material { get; init; } = "Steel, A1011 HR";
|
public string Material { get; init; } = "Steel, A1011 HR";
|
||||||
public double Thickness { get; init; } = 0.06;
|
public double Thickness { get; init; } = 0.06;
|
||||||
public double Spacing { get; init; } = 0.1;
|
public double Spacing { get; init; } = 0.1;
|
||||||
|
|
||||||
/// <summary>Legacy compatibility setting; Auto maps to the Default whole-job strategy.</summary>
|
/// <summary>Legacy compatibility setting; Auto maps to the Default whole-job strategy.</summary>
|
||||||
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
|
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
|
||||||
public CutParameters Cutting { get; init; } = CutParameters.Default;
|
public CutParameters Cutting { get; init; } = CutParameters.Default;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public class NestRequestPlate
|
|||||||
{
|
{
|
||||||
public string Id { get; init; }
|
public string Id { get; init; }
|
||||||
public Size Size { get; init; }
|
public Size Size { get; init; }
|
||||||
|
|
||||||
/// <summary>Available physical sheets; null means unlimited.</summary>
|
/// <summary>Available physical sheets; null means unlimited.</summary>
|
||||||
public int? Quantity { get; init; }
|
public int? Quantity { get; init; }
|
||||||
public double PartSpacing { get; init; }
|
public double PartSpacing { get; init; }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.IO.Compression;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Api;
|
namespace OpenNest.Api;
|
||||||
@@ -18,20 +19,28 @@ public sealed record NestStockUsage(string StockId, int Used, int? Remaining);
|
|||||||
/// <summary>Maps each materialized physical sheet to its source stock identity.</summary>
|
/// <summary>Maps each materialized physical sheet to its source stock identity.</summary>
|
||||||
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
|
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
|
||||||
|
|
||||||
|
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
|
||||||
|
public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
|
||||||
|
|
||||||
public class NestResponse
|
public class NestResponse
|
||||||
{
|
{
|
||||||
public const int CurrentSchemaVersion = 2;
|
public const int CurrentSchemaVersion = 3;
|
||||||
|
|
||||||
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
||||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
public int SheetCount { get; init; }
|
public int SheetCount { get; init; }
|
||||||
|
|
||||||
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
|
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
|
||||||
public double Utilization { get; init; }
|
public double Utilization { get; init; }
|
||||||
public TimeSpan CutTime { get; init; }
|
public TimeSpan CutTime { get; init; }
|
||||||
public TimeSpan Elapsed { get; init; }
|
public TimeSpan Elapsed { get; init; }
|
||||||
|
|
||||||
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
|
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
|
||||||
public NestJobStatus? Status { get; init; }
|
public NestJobStatus? Status { get; init; }
|
||||||
public NestJobStopReason? StopReason { get; init; }
|
public NestJobStopReason? StopReason { get; init; }
|
||||||
|
/// <summary>Fulfillment status is not geometry acceptance. Review this status before using Nest.</summary>
|
||||||
|
public NestValidationStatus? ValidationStatus { get; init; }
|
||||||
|
public IReadOnlyList<string> Violations { get; init; } = [];
|
||||||
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
|
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||||
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
|
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
|
||||||
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||||
@@ -43,7 +52,7 @@ public class NestResponse
|
|||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
IncludeFields = true, // Required for OpenNest.Geometry.Size and Spacing public fields.
|
IncludeFields = true, // Required for OpenNest.Geometry.Size and Spacing public fields.
|
||||||
Converters = { new JsonStringEnumConverter() }
|
Converters = { new JsonStringEnumConverter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
public async Task SaveAsync(string path)
|
public async Task SaveAsync(string path)
|
||||||
@@ -61,7 +70,9 @@ public class NestResponse
|
|||||||
var responseEntry = zip.CreateEntry("response.json");
|
var responseEntry = zip.CreateEntry("response.json");
|
||||||
await using (var stream = responseEntry.Open())
|
await using (var stream = responseEntry.Open())
|
||||||
{
|
{
|
||||||
await JsonSerializer.SerializeAsync(stream, new NestResponseArchiveDto
|
await JsonSerializer.SerializeAsync(
|
||||||
|
stream,
|
||||||
|
new NestResponseArchiveDto
|
||||||
{
|
{
|
||||||
SchemaVersion = CurrentSchemaVersion,
|
SchemaVersion = CurrentSchemaVersion,
|
||||||
SheetCount = SheetCount,
|
SheetCount = SheetCount,
|
||||||
@@ -70,10 +81,18 @@ public class NestResponse
|
|||||||
ElapsedTicks = Elapsed.Ticks,
|
ElapsedTicks = Elapsed.Ticks,
|
||||||
Status = Status,
|
Status = Status,
|
||||||
StopReason = StopReason,
|
StopReason = StopReason,
|
||||||
Fulfillment = Fulfillment is null ? [] : new List<NestPartFulfillment>(Fulfillment),
|
ValidationStatus = ValidationStatus,
|
||||||
|
Violations = Violations is null ? [] : new List<string>(Violations),
|
||||||
|
Fulfillment = Fulfillment is null
|
||||||
|
? []
|
||||||
|
: new List<NestPartFulfillment>(Fulfillment),
|
||||||
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
||||||
PlateStockMappings = PlateStockMappings is null ? [] : new List<NestPlateStockMapping>(PlateStockMappings)
|
PlateStockMappings = PlateStockMappings is null
|
||||||
}, JsonOptions);
|
? []
|
||||||
|
: new List<NestPlateStockMapping>(PlateStockMappings),
|
||||||
|
},
|
||||||
|
JsonOptions
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var nestEntry = zip.CreateEntry("nest.nest");
|
var nestEntry = zip.CreateEntry("nest.nest");
|
||||||
@@ -91,16 +110,19 @@ public class NestResponse
|
|||||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
|
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
|
||||||
using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
|
using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
|
||||||
|
|
||||||
var requestEntry = zip.GetEntry("request.json")
|
var requestEntry =
|
||||||
|
zip.GetEntry("request.json")
|
||||||
?? throw new InvalidOperationException("Missing request.json in .nestquote file");
|
?? throw new InvalidOperationException("Missing request.json in .nestquote file");
|
||||||
NestRequest request;
|
NestRequest request;
|
||||||
await using (var stream = requestEntry.Open())
|
await using (var stream = requestEntry.Open())
|
||||||
{
|
{
|
||||||
request = await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions)
|
request =
|
||||||
|
await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions)
|
||||||
?? throw new InvalidOperationException("Invalid request.json in .nestquote file");
|
?? throw new InvalidOperationException("Invalid request.json in .nestquote file");
|
||||||
}
|
}
|
||||||
|
|
||||||
var responseEntry = zip.GetEntry("response.json")
|
var responseEntry =
|
||||||
|
zip.GetEntry("response.json")
|
||||||
?? throw new InvalidOperationException("Missing response.json in .nestquote file");
|
?? throw new InvalidOperationException("Missing response.json in .nestquote file");
|
||||||
NestResponseArchiveDto archive;
|
NestResponseArchiveDto archive;
|
||||||
var hasSchemaVersion = false;
|
var hasSchemaVersion = false;
|
||||||
@@ -110,16 +132,19 @@ public class NestResponse
|
|||||||
{
|
{
|
||||||
var root = document.RootElement;
|
var root = document.RootElement;
|
||||||
hasSchemaVersion = root.TryGetProperty("schemaVersion", out _);
|
hasSchemaVersion = root.TryGetProperty("schemaVersion", out _);
|
||||||
hasStatusMetadata = root.TryGetProperty("status", out _) ||
|
hasStatusMetadata =
|
||||||
root.TryGetProperty("stopReason", out _) ||
|
root.TryGetProperty("status", out _)
|
||||||
root.TryGetProperty("fulfillment", out _) ||
|
|| root.TryGetProperty("stopReason", out _)
|
||||||
root.TryGetProperty("stockUsage", out _) ||
|
|| root.TryGetProperty("fulfillment", out _)
|
||||||
root.TryGetProperty("plateStockMappings", out _);
|
|| root.TryGetProperty("stockUsage", out _)
|
||||||
archive = root.Deserialize<NestResponseArchiveDto>(JsonOptions)
|
|| root.TryGetProperty("plateStockMappings", out _);
|
||||||
|
archive =
|
||||||
|
root.Deserialize<NestResponseArchiveDto>(JsonOptions)
|
||||||
?? throw new InvalidOperationException("Invalid response.json in .nestquote file");
|
?? throw new InvalidOperationException("Invalid response.json in .nestquote file");
|
||||||
}
|
}
|
||||||
|
|
||||||
var nestEntry = zip.GetEntry("nest.nest")
|
var nestEntry =
|
||||||
|
zip.GetEntry("nest.nest")
|
||||||
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
|
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
|
||||||
Nest nest;
|
Nest nest;
|
||||||
using (var nestMs = new MemoryStream())
|
using (var nestMs = new MemoryStream())
|
||||||
@@ -141,11 +166,13 @@ public class NestResponse
|
|||||||
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
|
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
|
||||||
Status = hasStatusMetadata ? archive.Status : null,
|
Status = hasStatusMetadata ? archive.Status : null,
|
||||||
StopReason = hasStatusMetadata ? archive.StopReason : null,
|
StopReason = hasStatusMetadata ? archive.StopReason : null,
|
||||||
|
ValidationStatus = archive.ValidationStatus,
|
||||||
|
Violations = archive.Violations ?? [],
|
||||||
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
||||||
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
||||||
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
||||||
Nest = nest,
|
Nest = nest,
|
||||||
Request = request
|
Request = request,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +185,8 @@ public class NestResponse
|
|||||||
public long ElapsedTicks { get; init; }
|
public long ElapsedTicks { get; init; }
|
||||||
public NestJobStatus? Status { get; init; }
|
public NestJobStatus? Status { get; init; }
|
||||||
public NestJobStopReason? StopReason { get; init; }
|
public NestJobStopReason? StopReason { get; init; }
|
||||||
|
public NestValidationStatus? ValidationStatus { get; init; }
|
||||||
|
public List<string> Violations { get; init; } = [];
|
||||||
public List<NestPartFulfillment> Fulfillment { get; init; } = [];
|
public List<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||||
public List<NestStockUsage> StockUsage { get; init; } = [];
|
public List<NestStockUsage> StockUsage { get; init; } = [];
|
||||||
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||||
|
|||||||
+135
-46
@@ -5,6 +5,8 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Api;
|
namespace OpenNest.Api;
|
||||||
@@ -16,90 +18,156 @@ public static class NestRunner
|
|||||||
public static Task<NestResponse> RunAsync(
|
public static Task<NestResponse> RunAsync(
|
||||||
NestRequest request,
|
NestRequest request,
|
||||||
IProgress<NestProgress> progress = null,
|
IProgress<NestProgress> progress = null,
|
||||||
CancellationToken token = default)
|
CancellationToken token = default
|
||||||
|
)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
var requestParts = request.Parts ?? throw new ArgumentException("Request parts must not be null.", nameof(request));
|
var requestParts =
|
||||||
|
request.Parts
|
||||||
|
?? throw new ArgumentException("Request parts must not be null.", nameof(request));
|
||||||
if (requestParts.Count == 0)
|
if (requestParts.Count == 0)
|
||||||
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
||||||
|
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
var parts = IdentifyParts(requestParts);
|
var parts = IdentifyParts(requestParts);
|
||||||
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||||
var jobParts = new List<NestJobPart>(parts.Count);
|
var items = new List<NestItem>(parts.Count);
|
||||||
|
|
||||||
foreach (var part in parts)
|
foreach (var part in parts)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (!File.Exists(part.Request.DxfPath))
|
if (!File.Exists(part.Request.DxfPath))
|
||||||
throw new FileNotFoundException($"DXF file not found: {part.Request.DxfPath}", part.Request.DxfPath);
|
throw new FileNotFoundException(
|
||||||
|
$"DXF file not found: {part.Request.DxfPath}",
|
||||||
|
part.Request.DxfPath
|
||||||
|
);
|
||||||
|
|
||||||
if (!importedByPath.TryGetValue(part.Request.DxfPath, out var drawing))
|
if (!importedByPath.TryGetValue(part.Request.DxfPath, out var drawing))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
drawing = CadImporter.ImportDrawing(part.Request.DxfPath,
|
drawing = CadImporter.ImportDrawing(
|
||||||
new CadImportOptions { Quantity = part.Request.Quantity });
|
part.Request.DxfPath,
|
||||||
|
new CadImportOptions { Quantity = part.Request.Quantity }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"Failed to import DXF: {part.Request.DxfPath}", exception);
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to import DXF: {part.Request.DxfPath}",
|
||||||
|
exception
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
||||||
throw new InvalidOperationException($"Failed to import DXF: {part.Request.DxfPath}");
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to import DXF: {part.Request.DxfPath}"
|
||||||
|
);
|
||||||
|
|
||||||
importedByPath.Add(part.Request.DxfPath, drawing);
|
importedByPath.Add(part.Request.DxfPath, drawing);
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfigureDrawingForRequirement(drawing, part.Request);
|
// Each requirement keeps its own identity/constraints even when paths are shared.
|
||||||
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity));
|
var requirementDrawing = new Drawing(part.Id, drawing.Program);
|
||||||
|
ConfigureDrawingForRequirement(requirementDrawing, part.Request);
|
||||||
|
requirementDrawing.Quantity.Required = part.Request.Quantity;
|
||||||
|
items.Add(new NestItem
|
||||||
|
{
|
||||||
|
Drawing = requirementDrawing,
|
||||||
|
Quantity = part.Request.Quantity,
|
||||||
|
Priority = part.Request.Priority,
|
||||||
|
StepAngle = requirementDrawing.Constraints.StepAngle,
|
||||||
|
RotationStart = requirementDrawing.Constraints.StartAngle,
|
||||||
|
RotationEnd = requirementDrawing.Constraints.EndAngle,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var job = new NestJob(jobParts, CreateStock(request),
|
var stock = CreateStock(request);
|
||||||
new NestJobOptions(ResolvePlacementStrategy(request)));
|
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
|
||||||
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
||||||
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job, jobProgress, token);
|
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||||
|
engineName, items, stock, new NestJobOptions(engineName)), jobProgress, token);
|
||||||
|
|
||||||
// This is the sole translation from immutable result poses to mutable legacy output objects.
|
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
|
||||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
// Invalid but representable proposals retain every pose and carry explicit validation status.
|
||||||
var nest = materialized.Nest;
|
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
|
||||||
nest.Thickness = request.Thickness;
|
foreach (var item in items)
|
||||||
nest.Material = new Material(request.Material);
|
nest.Drawings.Add(item.Drawing);
|
||||||
|
foreach (var proposed in result.Plates)
|
||||||
|
{
|
||||||
|
var plate = new Plate(proposed.Stock.Size)
|
||||||
|
{
|
||||||
|
PartSpacing = proposed.Stock.PartSpacing,
|
||||||
|
EdgeSpacing = proposed.Stock.EdgeSpacing,
|
||||||
|
Quadrant = proposed.Stock.Quadrant,
|
||||||
|
Quantity = 1,
|
||||||
|
};
|
||||||
|
plate.Parts.AddRange(proposed.Parts);
|
||||||
|
nest.Plates.Add(plate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline IDs are internal part-i values. Exposed counts/IDs come from the
|
||||||
|
// returned, bound placements and the original request, never plug-in summaries.
|
||||||
|
var counts = result.Plates.SelectMany(p => p.Parts)
|
||||||
|
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
var fulfillment = parts.Select((part, i) =>
|
||||||
|
{
|
||||||
|
var placed = counts.GetValueOrDefault(items[i].Drawing);
|
||||||
|
return new NestPartFulfillment(part.Id, part.Request.Quantity, placed,
|
||||||
|
System.Math.Max(0, part.Request.Quantity - placed));
|
||||||
|
}).ToArray();
|
||||||
|
var usage = stock.Select(s =>
|
||||||
|
{
|
||||||
|
var used = result.Plates.Count(p => p.Stock.Id == s.Id);
|
||||||
|
return new NestStockUsage(s.Id, used, s.Quantity.HasValue ? System.Math.Max(0, s.Quantity.Value - used) : null);
|
||||||
|
}).ToArray();
|
||||||
|
var complete = fulfillment.All(f => f.Placed == f.Requested);
|
||||||
|
var stopReason = complete ? NestJobStopReason.Completed
|
||||||
|
: usage.All(u => u.Remaining == 0) ? NestJobStopReason.StockExhausted
|
||||||
|
: NestJobStopReason.NoPlacementFound;
|
||||||
|
|
||||||
var timingInfo = Timing.GetTimingInfo(nest);
|
var timingInfo = Timing.GetTimingInfo(nest);
|
||||||
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
|
|
||||||
return Task.FromResult(new NestResponse
|
return Task.FromResult(
|
||||||
|
new NestResponse
|
||||||
{
|
{
|
||||||
SheetCount = nest.Plates.Count,
|
SheetCount = nest.Plates.Count,
|
||||||
Utilization = CalculateUtilization(nest),
|
Utilization = CalculateUtilization(nest),
|
||||||
CutTime = cutTime,
|
CutTime = cutTime,
|
||||||
Elapsed = sw.Elapsed,
|
Elapsed = sw.Elapsed,
|
||||||
Status = result.Status,
|
Status = complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||||
StopReason = result.StopReason,
|
StopReason = stopReason,
|
||||||
Fulfillment = result.Fulfillment
|
ValidationStatus = !result.CanKeep ? NestValidationStatus.Unrepresentable
|
||||||
.Select(value => new NestPartFulfillment(value.PartId, value.Requested, value.Placed, value.Unplaced))
|
: result.IsValid ? NestValidationStatus.Valid : NestValidationStatus.Invalid,
|
||||||
.ToArray(),
|
Violations = result.Violations,
|
||||||
StockUsage = result.StockUsage
|
Fulfillment = fulfillment,
|
||||||
.Select(value => new NestStockUsage(value.StockId, value.Used, value.Remaining))
|
StockUsage = usage,
|
||||||
.ToArray(),
|
PlateStockMappings = result.Plates.Select((value, index) =>
|
||||||
PlateStockMappings = result.Plates
|
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
|
||||||
.Select(value => new NestPlateStockMapping(value.PlateIndex, value.StockId))
|
|
||||||
.ToArray(),
|
|
||||||
Nest = nest,
|
Nest = nest,
|
||||||
Request = request
|
Request = request,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<IdentifiedRequestPart> IdentifyParts(IReadOnlyList<NestRequestPart> requestParts)
|
private static IReadOnlyList<IdentifiedRequestPart> IdentifyParts(
|
||||||
|
IReadOnlyList<NestRequestPart> requestParts
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var identified = new List<IdentifiedRequestPart>(requestParts.Count);
|
var identified = new List<IdentifiedRequestPart>(requestParts.Count);
|
||||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||||
for (var index = 0; index < requestParts.Count; index++)
|
for (var index = 0; index < requestParts.Count; index++)
|
||||||
{
|
{
|
||||||
var part = requestParts[index] ?? throw new ArgumentException("Request parts must not contain null entries.", nameof(requestParts));
|
var part =
|
||||||
|
requestParts[index]
|
||||||
|
?? throw new ArgumentException(
|
||||||
|
"Request parts must not contain null entries.",
|
||||||
|
nameof(requestParts)
|
||||||
|
);
|
||||||
|
if (part.Quantity < 0)
|
||||||
|
throw new ArgumentException("Part quantities must be nonnegative.", nameof(requestParts));
|
||||||
var id = part.Id ?? $"part-{index}";
|
var id = part.Id ?? $"part-{index}";
|
||||||
if (string.IsNullOrWhiteSpace(id))
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
|
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
|
||||||
@@ -117,8 +185,12 @@ public static class NestRunner
|
|||||||
{
|
{
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
new NestPlateStock(LegacyStockId, request.SheetSize, quantity: null,
|
new NestPlateStock(
|
||||||
partSpacing: request.Spacing)
|
LegacyStockId,
|
||||||
|
request.SheetSize,
|
||||||
|
quantity: null,
|
||||||
|
partSpacing: request.Spacing
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,9 +198,20 @@ public static class NestRunner
|
|||||||
foreach (var plate in request.Plates)
|
foreach (var plate in request.Plates)
|
||||||
{
|
{
|
||||||
if (plate is null)
|
if (plate is null)
|
||||||
throw new ArgumentException("Request plates must not contain null entries.", nameof(request));
|
throw new ArgumentException(
|
||||||
stock.Add(new NestPlateStock(plate.Id, plate.Size, plate.Quantity, plate.PartSpacing,
|
"Request plates must not contain null entries.",
|
||||||
plate.EdgeSpacing, plate.Quadrant));
|
nameof(request)
|
||||||
|
);
|
||||||
|
stock.Add(
|
||||||
|
new NestPlateStock(
|
||||||
|
plate.Id,
|
||||||
|
plate.Size,
|
||||||
|
plate.Quantity,
|
||||||
|
plate.PartSpacing,
|
||||||
|
plate.EdgeSpacing,
|
||||||
|
plate.Quadrant
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return stock;
|
return stock;
|
||||||
@@ -147,25 +230,31 @@ public static class NestRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolvePlacementStrategy(NestRequest request) => request.PlacementStrategy ?? request.Strategy switch
|
private static string ResolvePlacementStrategy(NestRequest request) =>
|
||||||
|
request.PlacementStrategy
|
||||||
|
?? request.Strategy switch
|
||||||
{
|
{
|
||||||
NestStrategy.Auto => "Default",
|
NestStrategy.Auto => "Default",
|
||||||
_ => throw new NotSupportedException($"Unknown legacy nesting strategy: {request.Strategy}.")
|
_ => throw new NotSupportedException(
|
||||||
|
$"Unknown legacy nesting strategy: {request.Strategy}."
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static double CalculateUtilization(Nest nest)
|
private static double CalculateUtilization(Nest nest)
|
||||||
{
|
{
|
||||||
var sheetArea = nest.Plates.Sum(plate => plate.Area());
|
var sheetArea = nest.Plates.Sum(plate => plate.Area());
|
||||||
if (sheetArea == 0) return 0;
|
if (sheetArea == 0)
|
||||||
var placedArea = nest.Plates.Sum(plate => plate.Parts
|
return 0;
|
||||||
.Where(part => !part.BaseDrawing.IsCutOff)
|
var placedArea = nest.Plates.Sum(plate =>
|
||||||
.Sum(part => part.BaseDrawing.Area));
|
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area)
|
||||||
|
);
|
||||||
return placedArea / sheetArea;
|
return placedArea / sheetArea;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record IdentifiedRequestPart(string Id, NestRequestPart Request);
|
private sealed record IdentifiedRequestPart(string Id, NestRequestPart Request);
|
||||||
|
|
||||||
private sealed class JobProgressBridge(IProgress<NestProgress> progress) : IProgress<NestJobProgress>
|
private sealed class JobProgressBridge(IProgress<NestProgress> progress)
|
||||||
|
: IProgress<NestJobProgress>
|
||||||
{
|
{
|
||||||
public void Report(NestJobProgress value)
|
public void Report(NestJobProgress value)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
namespace OpenNest.Api;
|
namespace OpenNest.Api;
|
||||||
|
|
||||||
public enum NestStrategy { Auto }
|
public enum NestStrategy
|
||||||
|
{
|
||||||
|
Auto,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RootNamespace>OpenNest.Api</RootNamespace>
|
<RootNamespace>OpenNest.Api</RootNamespace>
|
||||||
<AssemblyName>OpenNest.Api</AssemblyName>
|
<AssemblyName>OpenNest.Api</AssemblyName>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Benchmark
|
namespace OpenNest.Benchmark
|
||||||
{
|
{
|
||||||
@@ -34,12 +36,24 @@ namespace OpenNest.Benchmark
|
|||||||
public Spacing EdgeSpacing { get; init; }
|
public Spacing EdgeSpacing { get; init; }
|
||||||
public double PartSpacing { get; init; }
|
public double PartSpacing { get; init; }
|
||||||
public int Quadrant { get; init; }
|
public int Quadrant { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
|
||||||
|
public double SalvageRate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
|
||||||
|
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
|
||||||
public List<DrawingRequest> Requests { get; init; }
|
public List<DrawingRequest> Requests { get; init; }
|
||||||
|
|
||||||
public string Name => Path.GetFileNameWithoutExtension(SourceFile);
|
public string Name => Path.GetFileNameWithoutExtension(SourceFile);
|
||||||
|
|
||||||
public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity);
|
public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity);
|
||||||
|
|
||||||
|
/// <summary>Sheet area charged per unplaced part: the largest candidate
|
||||||
|
/// sheet. Any single part that fits the stock at all fits on one such
|
||||||
|
/// sheet, so placing a part is never scored worse than leaving it out.</summary>
|
||||||
|
public double UnplacedPartPenalty =>
|
||||||
|
CandidateSizes.Count == 0 ? 0 : CandidateSizes.Max(s => s.Width * s.Length);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the whole-job request this job represents: one NestJobPart per
|
/// Builds the whole-job request this job represents: one NestJobPart per
|
||||||
/// requested drawing, and one NestPlateStock per candidate sheet size
|
/// requested drawing, and one NestPlateStock per candidate sheet size
|
||||||
@@ -48,13 +62,33 @@ namespace OpenNest.Benchmark
|
|||||||
/// engine owns its own multi-plate/size strategy; this harness no
|
/// engine owns its own multi-plate/size strategy; this harness no
|
||||||
/// longer picks plate sizes on the engine's behalf.
|
/// longer picks plate sizes on the engine's behalf.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public NestJob BuildNestJob(int maxPlates)
|
public NestJob BuildNestJob(
|
||||||
|
int maxPlates,
|
||||||
|
double? salvageRate = null,
|
||||||
|
double? minimumSalvageDimension = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var parts = Requests.Select(r =>
|
var parts = Requests.Select(r =>
|
||||||
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity));
|
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
|
||||||
var stock = CandidateSizes.Select(size =>
|
);
|
||||||
new NestPlateStock(size.ToString(1), size, null, PartSpacing, EdgeSpacing, Quadrant));
|
var stock = CandidateSizes.Select(size => new NestPlateStock(
|
||||||
return new NestJob(parts, stock, new NestJobOptions("Default", maxPlates));
|
size.ToString(1),
|
||||||
|
size,
|
||||||
|
null,
|
||||||
|
PartSpacing,
|
||||||
|
EdgeSpacing,
|
||||||
|
Quadrant
|
||||||
|
));
|
||||||
|
return new NestJob(
|
||||||
|
parts,
|
||||||
|
stock,
|
||||||
|
new NestJobOptions(
|
||||||
|
"Default",
|
||||||
|
maxPlates,
|
||||||
|
salvageRate ?? SalvageRate,
|
||||||
|
minimumSalvageDimension ?? 0
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
|
||||||
namespace OpenNest.Benchmark
|
namespace OpenNest.Benchmark
|
||||||
{
|
{
|
||||||
@@ -23,53 +27,322 @@ namespace OpenNest.Benchmark
|
|||||||
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
||||||
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestingEngineInfo> engines)
|
public static List<JobResult> Run(
|
||||||
|
List<BenchmarkJob> jobs,
|
||||||
|
IReadOnlyList<NestingEngineInfo> engines,
|
||||||
|
double? salvageRate = null,
|
||||||
|
double? minimumSalvageDimension = null,
|
||||||
|
string outputDirectory = null,
|
||||||
|
int maxParallelism = 1,
|
||||||
|
System.IO.TextWriter progressLog = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var results = new List<JobResult>(jobs.Count * engines.Count);
|
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
|
||||||
|
.ToList();
|
||||||
|
var results = new JobResult[pairs.Count];
|
||||||
|
var options = new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = System.Math.Max(1, maxParallelism),
|
||||||
|
};
|
||||||
|
|
||||||
foreach (var job in jobs)
|
var baselineResults = new JobResult[jobs.Count];
|
||||||
|
Parallel.ForEach(
|
||||||
|
Partitioner.Create(
|
||||||
|
Enumerable.Range(0, jobs.Count),
|
||||||
|
EnumerablePartitionerOptions.NoBuffering
|
||||||
|
),
|
||||||
|
options,
|
||||||
|
i => baselineResults[i] = RunBaseline(jobs[i], salvageRate, minimumSalvageDimension)
|
||||||
|
);
|
||||||
|
|
||||||
|
// NoBuffering hands out one pair at a time: solves run for seconds to minutes,
|
||||||
|
// so chunked partitioning would leave workers idle behind a slow engine.
|
||||||
|
Parallel.ForEach(
|
||||||
|
Partitioner.Create(
|
||||||
|
Enumerable.Range(0, pairs.Count),
|
||||||
|
EnumerablePartitionerOptions.NoBuffering
|
||||||
|
),
|
||||||
|
options,
|
||||||
|
i =>
|
||||||
|
results[i] = RunOne(
|
||||||
|
pairs[i].Job,
|
||||||
|
pairs[i].Engine,
|
||||||
|
salvageRate,
|
||||||
|
minimumSalvageDimension,
|
||||||
|
outputDirectory,
|
||||||
|
progressLog
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Indexed writes keep the report in job-then-engine order whatever finishes first.
|
||||||
|
var ordered = new List<JobResult>(
|
||||||
|
results.Length + baselineResults.Count(result => result != null)
|
||||||
|
);
|
||||||
|
for (var jobIndex = 0; jobIndex < jobs.Count; jobIndex++)
|
||||||
{
|
{
|
||||||
foreach (var engineInfo in engines)
|
if (baselineResults[jobIndex] != null)
|
||||||
|
ordered.Add(baselineResults[jobIndex]);
|
||||||
|
var firstResult = jobIndex * engines.Count;
|
||||||
|
for (var engineIndex = 0; engineIndex < engines.Count; engineIndex++)
|
||||||
|
ordered.Add(results[firstResult + engineIndex]);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JobResult RunBaseline(
|
||||||
|
BenchmarkJob job,
|
||||||
|
double? salvageRate,
|
||||||
|
double? minimumSalvageDimension
|
||||||
|
)
|
||||||
{
|
{
|
||||||
results.Add(RunOne(job, engineInfo));
|
if (job.BaselinePlateRuns == null)
|
||||||
|
return null;
|
||||||
|
var requested = job.TotalRequestedQuantity;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requirements = job.Requests.ToDictionary<
|
||||||
|
DrawingRequest,
|
||||||
|
Drawing,
|
||||||
|
(string Name, int Quantity)
|
||||||
|
>(
|
||||||
|
request => request.Drawing,
|
||||||
|
request => (request.Drawing.Name, request.Quantity),
|
||||||
|
ReferenceEqualityComparer.Instance
|
||||||
|
);
|
||||||
|
var partIds = job.Requests.ToDictionary<DrawingRequest, Drawing, string>(
|
||||||
|
request => request.Drawing,
|
||||||
|
request => request.Drawing.Id.ToString(),
|
||||||
|
ReferenceEqualityComparer.Instance
|
||||||
|
);
|
||||||
|
var validation = NestValidator.Validate(job.BaselinePlateRuns, requirements);
|
||||||
|
var benchmarkJob = job.BuildNestJob(
|
||||||
|
MaxPlates,
|
||||||
|
salvageRate,
|
||||||
|
minimumSalvageDimension
|
||||||
|
);
|
||||||
|
var instanceIndices = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
var plateResults = job
|
||||||
|
.BaselinePlateRuns.Select(
|
||||||
|
(run, index) =>
|
||||||
|
{
|
||||||
|
var stock = new NestPlateStock(
|
||||||
|
$"baseline-{index}",
|
||||||
|
run.Plate.Size,
|
||||||
|
1,
|
||||||
|
run.Plate.PartSpacing,
|
||||||
|
run.Plate.EdgeSpacing,
|
||||||
|
run.Plate.Quadrant
|
||||||
|
);
|
||||||
|
var placements = run
|
||||||
|
.Parts.Select(part =>
|
||||||
|
{
|
||||||
|
var partId = partIds[part.BaseDrawing];
|
||||||
|
instanceIndices.TryGetValue(partId, out var instanceIndex);
|
||||||
|
instanceIndices[partId] = instanceIndex + 1;
|
||||||
|
return new NestJobPlacement(
|
||||||
|
partId,
|
||||||
|
instanceIndex,
|
||||||
|
part.Location.X,
|
||||||
|
part.Location.Y,
|
||||||
|
part.Rotation
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
return new NestJobPlateResult(index, stock, placements);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.ToList();
|
||||||
|
var baselineJob = new NestJob(
|
||||||
|
benchmarkJob.Parts,
|
||||||
|
plateResults.Select(result => result.Stock),
|
||||||
|
benchmarkJob.Options
|
||||||
|
);
|
||||||
|
var baselineJobResult = new NestJobResult(
|
||||||
|
NestJobStatus.Complete,
|
||||||
|
NestJobStopReason.Completed,
|
||||||
|
plateResults,
|
||||||
|
Array.Empty<PartFulfillment>(),
|
||||||
|
Array.Empty<StockUsage>()
|
||||||
|
);
|
||||||
|
NestValidator.ValidateAgainstJob(
|
||||||
|
baselineJob,
|
||||||
|
baselineJobResult,
|
||||||
|
job.Requests.ToDictionary(
|
||||||
|
request => request.Drawing.Id.ToString(),
|
||||||
|
request => request.Drawing.Name
|
||||||
|
),
|
||||||
|
validation
|
||||||
|
);
|
||||||
|
|
||||||
|
var plateRuns = job.BaselinePlateRuns;
|
||||||
|
var placedArea = validation.Valid
|
||||||
|
? plateRuns.Sum(run => run.Parts.Sum(part => part.BaseDrawing.Area))
|
||||||
|
: 0;
|
||||||
|
var plateArea = plateRuns.Sum(run => run.Plate.Area());
|
||||||
|
var netSheetArea = validation.Valid
|
||||||
|
? plateResults.Sum(result =>
|
||||||
|
NestJobCost.NetSheetArea(baselineJob, result)
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
var sizeBreakdown = plateRuns
|
||||||
|
.GroupBy(run => run.Plate.Size.ToString(1))
|
||||||
|
.OrderByDescending(group => group.Count())
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count());
|
||||||
|
|
||||||
|
return new JobResult
|
||||||
|
{
|
||||||
|
EngineName = "Baseline",
|
||||||
|
JobName = job.Name,
|
||||||
|
Valid = validation.Valid,
|
||||||
|
Violations = validation.Violations,
|
||||||
|
PartsPlaced = plateRuns.Sum(run => run.Parts.Count),
|
||||||
|
PartsRequested = requested,
|
||||||
|
PlacedArea = placedArea,
|
||||||
|
PlateArea = plateArea,
|
||||||
|
NetSheetArea = netSheetArea,
|
||||||
|
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||||
|
PlatesUsed = plateRuns.Count,
|
||||||
|
SizeBreakdown = sizeBreakdown,
|
||||||
|
ElapsedMs = 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JobResult
|
||||||
|
{
|
||||||
|
EngineName = "Baseline",
|
||||||
|
JobName = job.Name,
|
||||||
|
Valid = false,
|
||||||
|
PartsRequested = requested,
|
||||||
|
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||||
|
Error = $"{ex.GetType().Name}: {ex.Message}",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
private static JobResult RunOne(
|
||||||
}
|
BenchmarkJob job,
|
||||||
|
NestingEngineInfo engineInfo,
|
||||||
private static JobResult RunOne(BenchmarkJob job, NestingEngineInfo engineInfo)
|
double? salvageRate,
|
||||||
|
double? minimumSalvageDimension,
|
||||||
|
string outputDirectory,
|
||||||
|
System.IO.TextWriter progressLog
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var requested = job.TotalRequestedQuantity;
|
var requested = job.TotalRequestedQuantity;
|
||||||
|
var log = progressLog == null
|
||||||
|
? null
|
||||||
|
: new JobProgressLog(progressLog, $"{job.Name}/{engineInfo.Name}");
|
||||||
|
log?.Started();
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var nestJob = job.BuildNestJob(MaxPlates);
|
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
|
||||||
var engine = engineInfo.Factory();
|
var engine = engineInfo.Factory();
|
||||||
using var cts = new CancellationTokenSource(SolveTimeout);
|
using var cts = new CancellationTokenSource(SolveTimeout);
|
||||||
var jobResult = engine.Solve(nestJob, null, cts.Token);
|
var jobResult = engine.Solve(nestJob, log, cts.Token);
|
||||||
|
log?.Finished(jobResult, sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
|
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
|
||||||
var plateRuns = materialized.Nest.Plates
|
var plateRuns = materialized
|
||||||
.Select(plate => (Plate: plate, Parts: plate.Parts.ToList()))
|
.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var requirements = job.Requests.ToDictionary<DrawingRequest, Drawing, (string Name, int Quantity)>(
|
var requirements = job.Requests.ToDictionary<
|
||||||
|
DrawingRequest,
|
||||||
|
Drawing,
|
||||||
|
(string Name, int Quantity)
|
||||||
|
>(
|
||||||
r => materialized.DrawingsByPartId[r.Drawing.Id.ToString()],
|
r => materialized.DrawingsByPartId[r.Drawing.Id.ToString()],
|
||||||
r => (r.Drawing.Name, r.Quantity),
|
r => (r.Drawing.Name, r.Quantity),
|
||||||
ReferenceEqualityComparer.Instance);
|
ReferenceEqualityComparer.Instance
|
||||||
|
);
|
||||||
|
|
||||||
var validation = NestValidator.Validate(plateRuns, requirements);
|
var validation = NestValidator.Validate(plateRuns, requirements);
|
||||||
|
NestValidator.ValidateAgainstJob(
|
||||||
|
nestJob,
|
||||||
|
jobResult,
|
||||||
|
job.Requests.ToDictionary(r => r.Drawing.Id.ToString(), r => r.Drawing.Name),
|
||||||
|
validation
|
||||||
|
);
|
||||||
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
|
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
|
||||||
var placedArea = validation.Valid ? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area)) : 0;
|
var placedArea = validation.Valid
|
||||||
|
? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area))
|
||||||
|
: 0;
|
||||||
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
|
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
|
||||||
|
// Salvage credit is recomputed from the job's own geometry, never taken from the engine.
|
||||||
|
var netSheetArea = validation.Valid
|
||||||
|
? jobResult.Plates.Sum(p =>
|
||||||
|
NestJobCost.NetSheetArea(nestJob, p)
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
var sizeBreakdown = plateRuns
|
var sizeBreakdown = plateRuns
|
||||||
.GroupBy(pr => pr.Plate.Size.ToString(1))
|
.GroupBy(pr => pr.Plate.Size.ToString(1))
|
||||||
.OrderByDescending(g => g.Count())
|
.OrderByDescending(g => g.Count())
|
||||||
.ToDictionary(g => g.Key, g => g.Count());
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
|
||||||
|
if (validation.Valid && outputDirectory != null)
|
||||||
|
{
|
||||||
|
System.IO.Directory.CreateDirectory(outputDirectory);
|
||||||
|
// Keep names and job metadata for a useful inspectable output; never modify source.
|
||||||
|
// Manifest jobs have no source nest to copy from, so they keep the job's name.
|
||||||
|
if (job.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var source = new OpenNest.IO.NestReader(job.SourceFile).Read();
|
||||||
|
materialized.Nest.Name = source.Name;
|
||||||
|
materialized.Nest.Units = source.Units;
|
||||||
|
materialized.Nest.Material = source.Material;
|
||||||
|
materialized.Nest.Thickness = source.Thickness;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
materialized.Nest.Name = job.Name;
|
||||||
|
}
|
||||||
|
materialized.Nest.SalvageRate = nestJob.Options.SalvageRate;
|
||||||
|
foreach (var request in job.Requests)
|
||||||
|
materialized.DrawingsByPartId[request.Drawing.Id.ToString()].Name = request
|
||||||
|
.Drawing
|
||||||
|
.Name;
|
||||||
|
var path = System.IO.Path.Combine(
|
||||||
|
outputDirectory,
|
||||||
|
$"{job.Name}-{engineInfo.Name}.nest"
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
System.IO.Path.GetFullPath(path)
|
||||||
|
== System.IO.Path.GetFullPath(job.SourceFile)
|
||||||
|
)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Output must not overwrite the source nest."
|
||||||
|
);
|
||||||
|
new OpenNest.IO.NestWriter(materialized.Nest).Write(path);
|
||||||
|
var report = new
|
||||||
|
{
|
||||||
|
Source = job.SourceFile,
|
||||||
|
Engine = engineInfo.Name,
|
||||||
|
jobResult.Status,
|
||||||
|
jobResult.StopReason,
|
||||||
|
Requested = requested,
|
||||||
|
Placed = totalPlaced,
|
||||||
|
SheetArea = plateArea,
|
||||||
|
PlacedArea = placedArea,
|
||||||
|
SalvageRate = nestJob.Options.SalvageRate,
|
||||||
|
MinimumSalvageDimension = nestJob.Options.MinimumSalvageDimension,
|
||||||
|
EstimatedNetArea = netSheetArea,
|
||||||
|
Fulfillment = jobResult.Fulfillment,
|
||||||
|
StockUsage = jobResult.StockUsage,
|
||||||
|
Plates = jobResult.Plates,
|
||||||
|
validation.Violations,
|
||||||
|
};
|
||||||
|
System.IO.File.WriteAllText(
|
||||||
|
System.IO.Path.ChangeExtension(path, ".json"),
|
||||||
|
System.Text.Json.JsonSerializer.Serialize(
|
||||||
|
report,
|
||||||
|
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
|
|
||||||
return new JobResult
|
return new JobResult
|
||||||
@@ -82,6 +355,8 @@ namespace OpenNest.Benchmark
|
|||||||
PartsRequested = requested,
|
PartsRequested = requested,
|
||||||
PlacedArea = placedArea,
|
PlacedArea = placedArea,
|
||||||
PlateArea = plateArea,
|
PlateArea = plateArea,
|
||||||
|
NetSheetArea = netSheetArea,
|
||||||
|
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||||
PlatesUsed = plateRuns.Count,
|
PlatesUsed = plateRuns.Count,
|
||||||
SizeBreakdown = sizeBreakdown,
|
SizeBreakdown = sizeBreakdown,
|
||||||
ElapsedMs = sw.ElapsedMilliseconds,
|
ElapsedMs = sw.ElapsedMilliseconds,
|
||||||
@@ -90,12 +365,14 @@ namespace OpenNest.Benchmark
|
|||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
|
log?.Failed("timed out", sw.ElapsedMilliseconds);
|
||||||
return new JobResult
|
return new JobResult
|
||||||
{
|
{
|
||||||
EngineName = engineInfo.Name,
|
EngineName = engineInfo.Name,
|
||||||
JobName = job.Name,
|
JobName = job.Name,
|
||||||
Valid = false,
|
Valid = false,
|
||||||
PartsRequested = requested,
|
PartsRequested = requested,
|
||||||
|
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||||
ElapsedMs = sw.ElapsedMilliseconds,
|
ElapsedMs = sw.ElapsedMilliseconds,
|
||||||
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
|
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
|
||||||
};
|
};
|
||||||
@@ -103,12 +380,14 @@ namespace OpenNest.Benchmark
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
|
log?.Failed($"{ex.GetType().Name}: {ex.Message}", sw.ElapsedMilliseconds);
|
||||||
return new JobResult
|
return new JobResult
|
||||||
{
|
{
|
||||||
EngineName = engineInfo.Name,
|
EngineName = engineInfo.Name,
|
||||||
JobName = job.Name,
|
JobName = job.Name,
|
||||||
Valid = false,
|
Valid = false,
|
||||||
PartsRequested = requested,
|
PartsRequested = requested,
|
||||||
|
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||||
ElapsedMs = sw.ElapsedMilliseconds,
|
ElapsedMs = sw.ElapsedMilliseconds,
|
||||||
Error = $"{ex.GetType().Name}: {ex.Message}",
|
Error = $"{ex.GetType().Name}: {ex.Message}",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Benchmark
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a BenchmarkJob from a JSON manifest that lists DXF files and the
|
||||||
|
/// quantity of each to nest. DXF paths resolve relative to the manifest.
|
||||||
|
/// Sheet sizes come from the manifest or the caller's override; unlike a
|
||||||
|
/// .nest file there is no plate to inherit them from, so a job with none is
|
||||||
|
/// an error. Sheet sizes must use the same units as the DXFs.
|
||||||
|
/// </summary>
|
||||||
|
public static class DxfManifestLoader
|
||||||
|
{
|
||||||
|
/// <summary>Suffix a manifest needs to be picked up when scanning a folder.</summary>
|
||||||
|
public const string FolderSuffix = ".manifest.json";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static BenchmarkJob Load(
|
||||||
|
string manifestPath,
|
||||||
|
IReadOnlyList<Size> sheetSizeOverrides = null,
|
||||||
|
double? partSpacingOverride = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var manifest = ReadManifest(manifestPath);
|
||||||
|
var baseDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath));
|
||||||
|
|
||||||
|
if (manifest.Parts == null || manifest.Parts.Count == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Manifest '{manifestPath}' has no parts. Add entries to \"parts\"."
|
||||||
|
);
|
||||||
|
|
||||||
|
var sizes = ResolveSheetSizes(manifest, sheetSizeOverrides, manifestPath);
|
||||||
|
var requests = manifest.Parts.Select(p => BuildRequest(p, baseDir)).ToList();
|
||||||
|
|
||||||
|
return new BenchmarkJob
|
||||||
|
{
|
||||||
|
SourceFile = manifestPath,
|
||||||
|
CandidateSizes = sizes,
|
||||||
|
EdgeSpacing = new Spacing(manifest.EdgeSpacing, manifest.EdgeSpacing),
|
||||||
|
PartSpacing = partSpacingOverride ?? manifest.Spacing,
|
||||||
|
Quadrant = manifest.Quadrant,
|
||||||
|
Requests = requests,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Manifest ReadManifest(string manifestPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<Manifest>(
|
||||||
|
File.ReadAllText(manifestPath),
|
||||||
|
JsonOptions
|
||||||
|
) ?? throw new InvalidOperationException("The manifest is empty.");
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Manifest '{manifestPath}' is not valid JSON: {ex.Message}",
|
||||||
|
ex
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Size> ResolveSheetSizes(
|
||||||
|
Manifest manifest,
|
||||||
|
IReadOnlyList<Size> overrides,
|
||||||
|
string manifestPath
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (overrides != null && overrides.Count > 0)
|
||||||
|
return overrides.ToList();
|
||||||
|
|
||||||
|
var sizes = new List<Size>();
|
||||||
|
|
||||||
|
foreach (var text in manifest.SheetSizes ?? new List<string>())
|
||||||
|
{
|
||||||
|
if (!JobLoader.TryParseSheetSize(text, out var size))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Manifest '{manifestPath}': could not parse sheet size '{text}' (expected e.g. \"48x96\")."
|
||||||
|
);
|
||||||
|
|
||||||
|
sizes.Add(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sizes.Count == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Manifest '{manifestPath}' has no sheet sizes. Set \"sheetSizes\" or pass --sheet-sizes."
|
||||||
|
);
|
||||||
|
|
||||||
|
return sizes.Distinct().ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DrawingRequest BuildRequest(ManifestPart part, string baseDir)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(part.Dxf))
|
||||||
|
throw new InvalidOperationException("A manifest part is missing \"dxf\".");
|
||||||
|
|
||||||
|
if (part.Quantity <= 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Manifest part '{part.Dxf}': quantity must be greater than 0 (was {part.Quantity})."
|
||||||
|
);
|
||||||
|
|
||||||
|
var dxfPath = Path.GetFullPath(Path.Combine(baseDir, part.Dxf));
|
||||||
|
|
||||||
|
if (!File.Exists(dxfPath))
|
||||||
|
throw new FileNotFoundException($"DXF file not found: {dxfPath}", dxfPath);
|
||||||
|
|
||||||
|
Drawing drawing;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
drawing = CadImporter.ImportDrawing(
|
||||||
|
dxfPath,
|
||||||
|
new CadImportOptions { Quantity = part.Quantity }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
||||||
|
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}");
|
||||||
|
|
||||||
|
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
|
||||||
|
if (!part.AllowRotation)
|
||||||
|
{
|
||||||
|
drawing.Constraints ??= new NestConstraints();
|
||||||
|
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI;
|
||||||
|
drawing.Constraints.StartAngle = 0;
|
||||||
|
drawing.Constraints.EndAngle = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var constraints = drawing.Constraints;
|
||||||
|
|
||||||
|
return new DrawingRequest
|
||||||
|
{
|
||||||
|
Drawing = drawing,
|
||||||
|
Quantity = part.Quantity,
|
||||||
|
Priority = drawing.Priority,
|
||||||
|
StepAngle = constraints?.StepAngle ?? 0,
|
||||||
|
RotationStart = constraints?.StartAngle ?? 0,
|
||||||
|
RotationEnd = constraints?.EndAngle ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Manifest
|
||||||
|
{
|
||||||
|
public List<string> SheetSizes { get; set; }
|
||||||
|
public double Spacing { get; set; }
|
||||||
|
public double EdgeSpacing { get; set; }
|
||||||
|
public int Quadrant { get; set; } = 1;
|
||||||
|
public List<ManifestPart> Parts { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ManifestPart
|
||||||
|
{
|
||||||
|
public string Dxf { get; set; }
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
public bool AllowRotation { get; set; } = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.IO;
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Benchmark
|
namespace OpenNest.Benchmark
|
||||||
{
|
{
|
||||||
@@ -17,14 +17,24 @@ namespace OpenNest.Benchmark
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class JobLoader
|
public static class JobLoader
|
||||||
{
|
{
|
||||||
public static List<BenchmarkJob> Load(string inputPath, IReadOnlyList<Size> sheetSizeOverrides = null,
|
public static List<BenchmarkJob> Load(
|
||||||
double? partSpacingOverride = null)
|
string inputPath,
|
||||||
|
IReadOnlyList<Size> sheetSizeOverrides = null,
|
||||||
|
double? partSpacingOverride = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var files = ResolveFiles(inputPath);
|
var files = ResolveFiles(inputPath);
|
||||||
var jobs = new List<BenchmarkJob>();
|
var jobs = new List<BenchmarkJob>();
|
||||||
|
|
||||||
foreach (var file in files)
|
foreach (var file in files)
|
||||||
{
|
{
|
||||||
|
if (file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Hand-written manifests fail loudly rather than being skipped like unreadable .nest files.
|
||||||
|
jobs.Add(DxfManifestLoader.Load(file, sheetSizeOverrides, partSpacingOverride));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
Nest nest;
|
Nest nest;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -33,7 +43,9 @@ namespace OpenNest.Benchmark
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[JobLoader] Skipping '{file}': failed to read ({ex.Message})");
|
Console.Error.WriteLine(
|
||||||
|
$"[JobLoader] Skipping '{file}': failed to read ({ex.Message})"
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,34 +53,72 @@ namespace OpenNest.Benchmark
|
|||||||
|
|
||||||
if (requests.Count == 0)
|
if (requests.Count == 0)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[JobLoader] Skipping '{file}': no drawings with quantity > 0");
|
Console.Error.WriteLine(
|
||||||
|
$"[JobLoader] Skipping '{file}': no drawings with quantity > 0"
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var template = ResolvePlateTemplate(nest);
|
var template = ResolvePlateTemplate(nest);
|
||||||
var sizes = sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
|
var sizes =
|
||||||
|
sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
|
||||||
? sheetSizeOverrides.ToList()
|
? sheetSizeOverrides.ToList()
|
||||||
: ResolveSheetSizes(nest);
|
: ResolveSheetSizes(nest);
|
||||||
|
|
||||||
jobs.Add(new BenchmarkJob
|
jobs.Add(
|
||||||
|
new BenchmarkJob
|
||||||
{
|
{
|
||||||
SourceFile = file,
|
SourceFile = file,
|
||||||
CandidateSizes = sizes,
|
CandidateSizes = sizes,
|
||||||
EdgeSpacing = template.EdgeSpacing,
|
EdgeSpacing = template.EdgeSpacing,
|
||||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||||
Quadrant = template.Quadrant,
|
Quadrant = template.Quadrant,
|
||||||
|
SalvageRate = nest.SalvageRate,
|
||||||
|
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
|
||||||
Requests = requests,
|
Requests = requests,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return jobs;
|
return jobs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses "WxL" with invariant-culture numbers, so "48.5x96" means the
|
||||||
|
/// same thing on every machine (Size.Parse follows the current culture).</summary>
|
||||||
|
public static bool TryParseSheetSize(string text, out Size size)
|
||||||
|
{
|
||||||
|
size = default;
|
||||||
|
var dims = text?.Split('x', 'X');
|
||||||
|
|
||||||
|
if (dims == null || dims.Length != 2)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var style = System.Globalization.NumberStyles.Float;
|
||||||
|
var culture = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!double.TryParse(dims[0].Trim(), style, culture, out var width)
|
||||||
|
|| !double.TryParse(dims[1].Trim(), style, culture, out var length)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
size = new Size(width, length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static List<string> ResolveFiles(string inputPath)
|
private static List<string> ResolveFiles(string inputPath)
|
||||||
{
|
{
|
||||||
if (Directory.Exists(inputPath))
|
if (Directory.Exists(inputPath))
|
||||||
{
|
{
|
||||||
return Directory.GetFiles(inputPath, "*.nest", SearchOption.AllDirectories)
|
return Directory
|
||||||
|
.EnumerateFiles(inputPath, "*", SearchOption.AllDirectories)
|
||||||
|
.Where(f =>
|
||||||
|
f.EndsWith(".nest", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| f.EndsWith(
|
||||||
|
DxfManifestLoader.FolderSuffix,
|
||||||
|
StringComparison.OrdinalIgnoreCase
|
||||||
|
)
|
||||||
|
)
|
||||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
@@ -92,7 +142,8 @@ namespace OpenNest.Benchmark
|
|||||||
|
|
||||||
var constraints = drawing.Constraints;
|
var constraints = drawing.Constraints;
|
||||||
|
|
||||||
requests.Add(new DrawingRequest
|
requests.Add(
|
||||||
|
new DrawingRequest
|
||||||
{
|
{
|
||||||
Drawing = drawing,
|
Drawing = drawing,
|
||||||
Quantity = qty,
|
Quantity = qty,
|
||||||
@@ -100,13 +151,45 @@ namespace OpenNest.Benchmark
|
|||||||
StepAngle = constraints?.StepAngle ?? 0,
|
StepAngle = constraints?.StepAngle ?? 0,
|
||||||
RotationStart = constraints?.StartAngle ?? 0,
|
RotationStart = constraints?.StartAngle ?? 0,
|
||||||
RotationEnd = constraints?.EndAngle ?? 0,
|
RotationEnd = constraints?.EndAngle ?? 0,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return requests;
|
return requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(Nest nest)
|
private static List<(Plate Plate, List<Part> Parts)> BuildBaselinePlateRuns(
|
||||||
|
Nest nest,
|
||||||
|
double? partSpacingOverride
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var runs = new List<(Plate Plate, List<Part> Parts)>();
|
||||||
|
foreach (var plate in nest.Plates ?? Enumerable.Empty<Plate>())
|
||||||
|
{
|
||||||
|
var parts = plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).ToList();
|
||||||
|
if (parts.Count == 0)
|
||||||
|
continue;
|
||||||
|
var validationPlate = new Plate(new Size(plate.Size.Width, plate.Size.Length))
|
||||||
|
{
|
||||||
|
Quantity = 1,
|
||||||
|
Quadrant = plate.Quadrant,
|
||||||
|
PartSpacing = partSpacingOverride ?? plate.PartSpacing,
|
||||||
|
EdgeSpacing = new Spacing(
|
||||||
|
plate.EdgeSpacing.Left,
|
||||||
|
plate.EdgeSpacing.Bottom,
|
||||||
|
plate.EdgeSpacing.Right,
|
||||||
|
plate.EdgeSpacing.Top
|
||||||
|
),
|
||||||
|
};
|
||||||
|
for (var copy = 0; copy < plate.Quantity; copy++)
|
||||||
|
runs.Add((validationPlate, parts));
|
||||||
|
}
|
||||||
|
return runs.Count > 0 ? runs : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
|
||||||
|
Nest nest
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var source = nest.Plates?.FirstOrDefault();
|
var source = nest.Plates?.FirstOrDefault();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
|
namespace OpenNest.Benchmark
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Writes one solve's NestJobProgress as log lines prefixed with "[job/engine]". Every
|
||||||
|
/// PlateCommitted is written; EvaluatingCandidate is throttled to one line per interval so a
|
||||||
|
/// chatty engine cannot flood the console. One instance per solve; Report is thread-safe.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class JobProgressLog : IProgress<NestJobProgress>
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan DefaultInterval = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
private readonly TextWriter writer;
|
||||||
|
private readonly string label;
|
||||||
|
private readonly TimeSpan interval;
|
||||||
|
private readonly Func<TimeSpan> clock;
|
||||||
|
private readonly object sync = new();
|
||||||
|
private TimeSpan? lastEvaluating;
|
||||||
|
|
||||||
|
public JobProgressLog(
|
||||||
|
TextWriter writer,
|
||||||
|
string label,
|
||||||
|
TimeSpan? interval = null,
|
||||||
|
Func<TimeSpan> clock = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
this.writer = writer ?? throw new ArgumentNullException(nameof(writer));
|
||||||
|
this.label = label;
|
||||||
|
this.interval = interval ?? DefaultInterval;
|
||||||
|
if (clock == null)
|
||||||
|
{
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
clock = () => stopwatch.Elapsed;
|
||||||
|
}
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Started() => Write("started");
|
||||||
|
|
||||||
|
public void Finished(NestJobResult result, long elapsedMs) =>
|
||||||
|
Write(
|
||||||
|
$"finished in {elapsedMs} ms: {result.Status} ({result.StopReason}), "
|
||||||
|
+ $"{result.Plates.Count} plate(s)"
|
||||||
|
);
|
||||||
|
|
||||||
|
public void Failed(string error, long elapsedMs) =>
|
||||||
|
Write($"failed after {elapsedMs} ms: {error}");
|
||||||
|
|
||||||
|
public void Report(NestJobProgress value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (value.Stage == NestJobStage.PlateCommitted)
|
||||||
|
{
|
||||||
|
Write(
|
||||||
|
$"committed plate {value.CommittedPlates} on stock {value.StockId} "
|
||||||
|
+ $"({value.CommittedParts} parts placed)"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (sync)
|
||||||
|
{
|
||||||
|
var now = clock();
|
||||||
|
if (lastEvaluating.HasValue && now - lastEvaluating.Value < interval)
|
||||||
|
return;
|
||||||
|
lastEvaluating = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
var plate = value.PlateIndex >= 0 ? value.PlateIndex + 1 : value.CommittedPlates + 1;
|
||||||
|
Write(
|
||||||
|
$"evaluating plate {plate} on stock {value.StockId} "
|
||||||
|
+ $"({value.CommittedPlates} plate(s), {value.CommittedParts} parts committed)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Write(string message)
|
||||||
|
{
|
||||||
|
lock (sync)
|
||||||
|
writer.WriteLine($"[{label}] {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@ namespace OpenNest.Benchmark
|
|||||||
/// Outcome of running one engine against one job. A job may span several
|
/// Outcome of running one engine against one job. A job may span several
|
||||||
/// plates (PlatesUsed, SizeBreakdown), since the engine may need more than
|
/// plates (PlatesUsed, SizeBreakdown), since the engine may need more than
|
||||||
/// one sheet - possibly of different sizes - to place everything asked of
|
/// one sheet - possibly of different sizes - to place everything asked of
|
||||||
/// it. An invalid or crashed run always scores zero utilization, per the
|
/// it. An invalid or crashed run places nothing as far as scoring is
|
||||||
/// benchmark rules.
|
/// concerned: it earns no area and pays the unplaced penalty on every
|
||||||
|
/// requested part.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class JobResult
|
public class JobResult
|
||||||
{
|
{
|
||||||
@@ -20,6 +21,17 @@ namespace OpenNest.Benchmark
|
|||||||
public int PartsRequested { get; init; }
|
public int PartsRequested { get; init; }
|
||||||
public double PlacedArea { get; init; }
|
public double PlacedArea { get; init; }
|
||||||
public double PlateArea { get; init; }
|
public double PlateArea { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Sheet area consumed after crediting salvageable offcuts
|
||||||
|
/// (NestJobCost.NetSheetArea summed over every plate).
|
||||||
|
/// Equals PlateArea when salvage credit is disabled.</summary>
|
||||||
|
public double NetSheetArea { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Sheet area charged for each requested part that was not
|
||||||
|
/// placed: the largest candidate sheet's area, so leaving a part out
|
||||||
|
/// always costs at least as much as the extra sheet it would need.</summary>
|
||||||
|
public double UnplacedPartPenalty { get; init; }
|
||||||
|
|
||||||
public int PlatesUsed { get; init; }
|
public int PlatesUsed { get; init; }
|
||||||
public Dictionary<string, int> SizeBreakdown { get; init; } = new();
|
public Dictionary<string, int> SizeBreakdown { get; init; } = new();
|
||||||
public long ElapsedMs { get; init; }
|
public long ElapsedMs { get; init; }
|
||||||
@@ -31,5 +43,21 @@ namespace OpenNest.Benchmark
|
|||||||
/// total placed drawing area over total plate area, matching
|
/// total placed drawing area over total plate area, matching
|
||||||
/// Plate.Utilization()'s per-plate definition summed across the job.</summary>
|
/// Plate.Utilization()'s per-plate definition summed across the job.</summary>
|
||||||
public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0;
|
public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0;
|
||||||
|
|
||||||
|
/// <summary>Placed area over salvage-credited sheet area.</summary>
|
||||||
|
public double NetUtilization =>
|
||||||
|
Valid && NetSheetArea > 0 ? PlacedArea / NetSheetArea : 0;
|
||||||
|
|
||||||
|
public int PartsUnplaced =>
|
||||||
|
Valid ? System.Math.Max(0, PartsRequested - PartsPlaced) : PartsRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ranking score, in sheet area (lower is better): net sheet area
|
||||||
|
/// consumed plus the unplaced penalty. An engine cannot improve it by
|
||||||
|
/// dropping awkward parts, and it sums honestly across jobs of
|
||||||
|
/// different sizes. Invalid runs consume no sheet but pay the penalty
|
||||||
|
/// on every requested part.
|
||||||
|
/// </summary>
|
||||||
|
public double Cost => (Valid ? NetSheetArea : 0) + PartsUnplaced * UnplacedPartPenalty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,198 +1,30 @@
|
|||||||
using OpenNest.Converters;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.Math;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
namespace OpenNest.Benchmark
|
namespace OpenNest.Benchmark;
|
||||||
|
|
||||||
|
/// <summary>Benchmark validation outcome.</summary>
|
||||||
|
public class ValidationResult
|
||||||
{
|
{
|
||||||
public class ValidationResult
|
|
||||||
{
|
|
||||||
public bool Valid => Violations.Count == 0;
|
public bool Valid => Violations.Count == 0;
|
||||||
public List<string> Violations { get; } = new();
|
public List<string> Violations { get; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Compatibility wrapper over the shared layout validation contract.</summary>
|
||||||
/// Validates a (possibly multi-plate) placed layout against the benchmark
|
public static class NestValidator
|
||||||
/// rules: on every plate, every part must lie within that plate's work
|
{
|
||||||
/// area and every pair of parts must be at least PartSpacing apart; across
|
/// <summary>Checks materialized plates using drawing-reference requirement identity.</summary>
|
||||||
/// all plates combined, no drawing may have more parts placed than
|
public static ValidationResult Validate(
|
||||||
/// requested (the quantity limit is a property of the whole order, not of
|
List<(Plate Plate, List<Part> Parts)> plateRuns,
|
||||||
/// any one plate). Geometry checks work on arbitrary (concave, holed)
|
|
||||||
/// polygons by reusing the same world-space extraction Part.Intersects
|
|
||||||
/// uses internally, so no engine gets an advantage or penalty from shape
|
|
||||||
/// complexity.
|
|
||||||
/// </summary>
|
|
||||||
public static class NestValidator
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// requirements maps each materialized part's BaseDrawing (by reference - materialized
|
|
||||||
/// Drawing instances are freshly reconstructed per NestResultMaterializer.Materialize, so
|
|
||||||
/// identity must never be inferred from Name, which is only incidentally seeded from the
|
|
||||||
/// originating NestJobPart id) to its original quantity limit and display name.
|
|
||||||
/// </summary>
|
|
||||||
public static ValidationResult Validate(List<(Plate Plate, List<Part> Parts)> plateRuns,
|
|
||||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements)
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements)
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
var result = new ValidationResult();
|
||||||
var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList();
|
result.Violations.AddRange(NestLayoutCheck.Validate(plateRuns, requirements));
|
||||||
|
|
||||||
if (allParts.Count == 0)
|
|
||||||
return result;
|
|
||||||
|
|
||||||
ValidateQuantities(allParts, requirements, result);
|
|
||||||
|
|
||||||
foreach (var (plate, parts) in plateRuns)
|
|
||||||
{
|
|
||||||
if (parts.Count == 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
ValidateBounds(parts, plate, requirements, result);
|
|
||||||
ValidateAreaBudget(parts, plate, result);
|
|
||||||
ValidateSpacing(parts, plate.PartSpacing, requirements, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateQuantities(List<Part> parts,
|
/// <summary>Appends offered-stock, finite-stock and rotation-policy violations.</summary>
|
||||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, ValidationResult result)
|
public static void ValidateAgainstJob(NestJob job, NestJobResult jobResult,
|
||||||
{
|
IReadOnlyDictionary<string, string> displayNames, ValidationResult result) =>
|
||||||
var placedCounts = parts
|
NestLayoutCheck.ValidateAgainstJob(job, jobResult, displayNames, result.Violations);
|
||||||
.GroupBy<Part, Drawing>(p => p.BaseDrawing, ReferenceEqualityComparer.Instance)
|
|
||||||
.ToDictionary(g => g.Key, g => g.Count());
|
|
||||||
|
|
||||||
foreach (var (drawing, placed) in placedCounts)
|
|
||||||
{
|
|
||||||
if (!requirements.TryGetValue(drawing, out var requirement))
|
|
||||||
{
|
|
||||||
result.Violations.Add($"Placed drawing '{drawing.Name}' which was not requested for this job");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (placed > requirement.Quantity)
|
|
||||||
{
|
|
||||||
result.Violations.Add(
|
|
||||||
$"'{requirement.Name}': placed {placed} across all plates but only {requirement.Quantity} were requested");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidateBounds(List<Part> parts, Plate plate,
|
|
||||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, ValidationResult result)
|
|
||||||
{
|
|
||||||
var workArea = plate.WorkArea();
|
|
||||||
|
|
||||||
foreach (var part in parts)
|
|
||||||
{
|
|
||||||
var bb = part.BoundingBox;
|
|
||||||
|
|
||||||
var outLeft = bb.Left < workArea.X - Tolerance.Epsilon;
|
|
||||||
var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon;
|
|
||||||
var outRight = bb.Right > workArea.Right + Tolerance.Epsilon;
|
|
||||||
var outTop = bb.Top > workArea.Top + Tolerance.Epsilon;
|
|
||||||
|
|
||||||
if (outLeft || outBottom || outRight || outTop)
|
|
||||||
{
|
|
||||||
result.Violations.Add(
|
|
||||||
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area " +
|
|
||||||
$"of a {plate.Size} plate");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hard mathematical backstop: non-overlapping parts confined to the
|
|
||||||
/// work area can never have a combined area greater than the work
|
|
||||||
/// area itself. This catches overlap that the polygon-based
|
|
||||||
/// ValidateSpacing check can miss - Collision.HasOverlap (and
|
|
||||||
/// Part.Intersects, which uses the same algorithm) has been observed
|
|
||||||
/// to return false negatives on real, complex production geometry, so
|
|
||||||
/// this check does not depend on it.
|
|
||||||
/// </summary>
|
|
||||||
private static void ValidateAreaBudget(List<Part> parts, Plate plate, ValidationResult result)
|
|
||||||
{
|
|
||||||
var workArea = plate.WorkArea();
|
|
||||||
var budget = workArea.Width * workArea.Length;
|
|
||||||
var placedArea = parts.Sum(p => p.BaseDrawing.Area);
|
|
||||||
|
|
||||||
if (placedArea > budget + Tolerance.Epsilon)
|
|
||||||
{
|
|
||||||
result.Violations.Add(
|
|
||||||
$"Combined placed area ({placedArea:F2}) on a {plate.Size} plate exceeds its work area ({budget:F2}) - " +
|
|
||||||
"parts must overlap even though the polygon overlap check did not flag a pair");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidateSpacing(List<Part> parts, double spacing,
|
|
||||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, ValidationResult result)
|
|
||||||
{
|
|
||||||
var worldPolygons = new Polygon[parts.Count];
|
|
||||||
var inflatedPolygons = new Polygon[parts.Count];
|
|
||||||
|
|
||||||
for (var i = 0; i < parts.Count; i++)
|
|
||||||
{
|
|
||||||
worldPolygons[i] = WorldPolygon(parts[i], 0);
|
|
||||||
inflatedPolygons[i] = spacing > Tolerance.Epsilon ? WorldPolygon(parts[i], spacing) : worldPolygons[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < parts.Count; i++)
|
|
||||||
{
|
|
||||||
if (worldPolygons[i] == null || inflatedPolygons[i] == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
for (var j = i + 1; j < parts.Count; j++)
|
|
||||||
{
|
|
||||||
if (worldPolygons[j] == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (Collision.HasOverlap(inflatedPolygons[i], worldPolygons[j]))
|
|
||||||
{
|
|
||||||
result.Violations.Add(
|
|
||||||
$"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Friendly name for a violation message, falling back to the materialized
|
|
||||||
/// Drawing's own Name (the raw partId string) if this part wasn't in requirements at all -
|
|
||||||
/// that mismatch is already reported by ValidateQuantities, so this is display-only.</summary>
|
|
||||||
private static string DisplayName(Part part, IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements) =>
|
|
||||||
requirements.TryGetValue(part.BaseDrawing, out var requirement) ? requirement.Name : part.BaseDrawing.Name;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extracts a part's perimeter as a world-space polygon, optionally inflated
|
|
||||||
/// outward by the given spacing, mirroring Part.Intersects' own geometry
|
|
||||||
/// extraction (part.Program is already rotated; only a Location offset is needed).
|
|
||||||
/// </summary>
|
|
||||||
private static Polygon WorldPolygon(Part part, double inflateBy)
|
|
||||||
{
|
|
||||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
|
||||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (entities.Count == 0)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var perimeter = new ShapeProfile(entities).Perimeter;
|
|
||||||
|
|
||||||
if (perimeter == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if (inflateBy > Tolerance.Epsilon)
|
|
||||||
perimeter = perimeter.OffsetOutward(inflateBy) ?? perimeter;
|
|
||||||
|
|
||||||
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000
|
|
||||||
// segments per arc) - arc-heavy real parts otherwise produce thousands
|
|
||||||
// of vertices, which is needlessly slow for a spacing check.
|
|
||||||
var polygon = perimeter.ToPolygonWithTolerance(0.01, circumscribe: true);
|
|
||||||
|
|
||||||
if (polygon == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
polygon.Offset(part.Location);
|
|
||||||
return polygon;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RootNamespace>OpenNest.Benchmark</RootNamespace>
|
<RootNamespace>OpenNest.Benchmark</RootNamespace>
|
||||||
<AssemblyName>OpenNest.Benchmark</AssemblyName>
|
<AssemblyName>OpenNest.Benchmark</AssemblyName>
|
||||||
<Nullable>disable</Nullable>
|
<Nullable>disable</Nullable>
|
||||||
|
|||||||
+201
-28
@@ -1,10 +1,11 @@
|
|||||||
using OpenNest;
|
|
||||||
using OpenNest.Benchmark;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using OpenNest;
|
||||||
|
using OpenNest.Benchmark;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
return BenchmarkConsole.Run(args);
|
return BenchmarkConsole.Run(args);
|
||||||
|
|
||||||
@@ -37,7 +38,9 @@ static class BenchmarkConsole
|
|||||||
|
|
||||||
if (jobs.Count == 0)
|
if (jobs.Count == 0)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("No benchmark jobs found (no .nest files with any drawing quantity > 0).");
|
Console.Error.WriteLine(
|
||||||
|
"No benchmark jobs found (no .nest files with any drawing quantity > 0, or *.manifest.json files)."
|
||||||
|
);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,13 +52,22 @@ static class BenchmarkConsole
|
|||||||
if (options.EngineNames.Count > 0)
|
if (options.EngineNames.Count > 0)
|
||||||
{
|
{
|
||||||
engines = engines
|
engines = engines
|
||||||
.Where(e => options.EngineNames.Any(n => n.Equals(e.Name, StringComparison.OrdinalIgnoreCase)))
|
.Where(e =>
|
||||||
|
options.EngineNames.Any(n =>
|
||||||
|
n.Equals(e.Name, StringComparison.OrdinalIgnoreCase)
|
||||||
|
)
|
||||||
|
)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (engines.Count == 0)
|
if (engines.Count == 0)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("None of the requested engines are registered. Available: " +
|
Console.Error.WriteLine(
|
||||||
string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name)));
|
"None of the requested engines are registered. Available: "
|
||||||
|
+ string.Join(
|
||||||
|
", ",
|
||||||
|
NestingEngineRegistry.AvailableEngines.Select(e => e.Name)
|
||||||
|
)
|
||||||
|
);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,12 +77,55 @@ static class BenchmarkConsole
|
|||||||
foreach (var job in jobs)
|
foreach (var job in jobs)
|
||||||
{
|
{
|
||||||
var sizes = string.Join(", ", job.CandidateSizes.Select(s => s.ToString(1)));
|
var sizes = string.Join(", ", job.CandidateSizes.Select(s => s.ToString(1)));
|
||||||
Console.WriteLine($" {job.Name}: {job.Requests.Count} drawing(s), {job.TotalRequestedQuantity} part(s) requested, candidate sizes: {sizes}");
|
Console.WriteLine(
|
||||||
|
$" {job.Name}: {job.Requests.Count} drawing(s), {job.TotalRequestedQuantity} part(s) requested, candidate sizes: {sizes}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.SheetSizes.Count == 0
|
||||||
|
&& jobs.Any(j => j.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"Warning: no --sheet-sizes given, so each .nest job only offers the sheet sizes its "
|
||||||
|
+ "original layout used - a hint toward that answer. Pass --sheet-sizes with the "
|
||||||
|
+ "sizes you actually stock for an unbiased comparison."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
|
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
|
||||||
|
|
||||||
var results = BenchmarkRunner.Run(jobs, engines);
|
var effectiveSalvageRates = jobs.Select(job => options.SalvageRate ?? job.SalvageRate);
|
||||||
|
if (
|
||||||
|
effectiveSalvageRates.Any(rate => rate > 0)
|
||||||
|
&& (options.MinimumSalvageDimension ?? 0) <= 0
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"Warning: salvage credit is disabled because --min-salvage-dimension was not set to a positive value."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var solves = jobs.Count * engines.Count;
|
||||||
|
|
||||||
|
if (options.Parallel > 1 && solves > 1)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"Running up to {options.Parallel} solves at a time; Time(ms) is measured under that "
|
||||||
|
+ "concurrent load. Use --parallel 1 for strictly isolated timings."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = BenchmarkRunner.Run(
|
||||||
|
jobs,
|
||||||
|
engines,
|
||||||
|
options.SalvageRate,
|
||||||
|
options.MinimumSalvageDimension,
|
||||||
|
options.OutputDirectory,
|
||||||
|
options.Parallel,
|
||||||
|
options.Progress ? Console.Out : null
|
||||||
|
);
|
||||||
|
|
||||||
Report.PrintDetailed(results);
|
Report.PrintDetailed(results);
|
||||||
Report.PrintSummary(results);
|
Report.PrintSummary(results);
|
||||||
@@ -98,12 +153,18 @@ static class BenchmarkConsole
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "--spacing" when i + 1 < args.Length:
|
case "--spacing" when i + 1 < args.Length:
|
||||||
o.PartSpacing = double.Parse(args[++i]);
|
o.PartSpacing = double.Parse(
|
||||||
|
args[++i],
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "--engines" when i + 1 < args.Length:
|
case "--engines" when i + 1 < args.Length:
|
||||||
o.EngineNames = args[++i]
|
o.EngineNames = args[++i]
|
||||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
.Split(
|
||||||
|
',',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
|
||||||
|
)
|
||||||
.ToList();
|
.ToList();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -111,6 +172,35 @@ static class BenchmarkConsole
|
|||||||
o.CsvPath = args[++i];
|
o.CsvPath = args[++i];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "--salvage-rate" when i + 1 < args.Length:
|
||||||
|
o.SalvageRate = double.Parse(
|
||||||
|
args[++i],
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "--min-salvage-dimension" when i + 1 < args.Length:
|
||||||
|
o.MinimumSalvageDimension = double.Parse(
|
||||||
|
args[++i],
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "--output" when i + 1 < args.Length:
|
||||||
|
o.OutputDirectory = args[++i];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "--parallel" when i + 1 < args.Length:
|
||||||
|
if (int.TryParse(args[++i], out var parallel) && parallel >= 1)
|
||||||
|
o.Parallel = parallel;
|
||||||
|
else
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"Warning: --parallel needs a whole number >= 1, using {o.Parallel}"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "--progress":
|
||||||
|
o.Progress = true;
|
||||||
|
break;
|
||||||
|
|
||||||
case "--help":
|
case "--help":
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
return null;
|
return null;
|
||||||
@@ -129,9 +219,14 @@ static class BenchmarkConsole
|
|||||||
{
|
{
|
||||||
var sizes = new List<Size>();
|
var sizes = new List<Size>();
|
||||||
|
|
||||||
foreach (var token in arg.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
foreach (
|
||||||
|
var token in arg.Split(
|
||||||
|
',',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
|
||||||
|
)
|
||||||
|
)
|
||||||
{
|
{
|
||||||
if (Size.TryParse(token, out var size))
|
if (JobLoader.TryParseSheetSize(token, out var size))
|
||||||
sizes.Add(size);
|
sizes.Add(size);
|
||||||
else
|
else
|
||||||
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
|
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
|
||||||
@@ -142,26 +237,99 @@ static class BenchmarkConsole
|
|||||||
|
|
||||||
private static void PrintUsage()
|
private static void PrintUsage()
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("OpenNest.Benchmark - compare registered whole-job nesting engines on a set of .nest files");
|
Console.Error.WriteLine(
|
||||||
|
"OpenNest.Benchmark - compare registered whole-job nesting engines on a set of .nest files"
|
||||||
|
);
|
||||||
Console.Error.WriteLine();
|
Console.Error.WriteLine();
|
||||||
Console.Error.WriteLine("For each .nest file, every drawing with quantity > 0 is nested (mixed together),");
|
Console.Error.WriteLine(
|
||||||
Console.Error.WriteLine("once per registered INestingEngine. Each engine is handed the full job - every");
|
"For each .nest file, every drawing with quantity > 0 is nested (mixed together),"
|
||||||
Console.Error.WriteLine("requested part and the whole pool of candidate sheet sizes - and owns its own");
|
);
|
||||||
Console.Error.WriteLine("multi-plate/size strategy: how many plates it uses, of which sizes, and how");
|
Console.Error.WriteLine(
|
||||||
Console.Error.WriteLine("demand splits across them. Scoring: aggregate material utilization across every");
|
"once per registered INestingEngine. Each engine is handed the full job - every"
|
||||||
Console.Error.WriteLine("plate used, then (if everything requested was placed) fewer plates as the");
|
);
|
||||||
Console.Error.WriteLine("tie-break. An invalid layout (out of bounds, overlapping, or over-quantity), a");
|
Console.Error.WriteLine(
|
||||||
Console.Error.WriteLine("thrown exception, or a run exceeding its time budget all score zero.");
|
"requested part and the whole pool of candidate sheet sizes - and owns its own"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"multi-plate/size strategy: how many plates it uses, of which sizes, and how"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"demand splits across them. Ranking: a run that places every requested part beats"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"one that does not; then lower cost = sheet area consumed (minus salvage credit for a"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"usable offcut) + the largest candidate sheet's area per unplaced part; then fewer"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"plates. An invalid layout (out of bounds, overlapping, over-quantity, off-stock, or"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"breaking a rotation constraint), a thrown exception, or a timeout places nothing."
|
||||||
|
);
|
||||||
Console.Error.WriteLine();
|
Console.Error.WriteLine();
|
||||||
Console.Error.WriteLine("Usage:");
|
Console.Error.WriteLine("Usage:");
|
||||||
Console.Error.WriteLine(" OpenNest.Benchmark <file.nest | folder> [options]");
|
Console.Error.WriteLine(
|
||||||
|
" OpenNest.Benchmark <file.nest | manifest.json | folder> [options]"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine();
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"A manifest.json builds a job straight from DXF files (paths relative to the manifest):"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" { \"sheetSizes\": [\"48x96\"], \"spacing\": 0.25, \"edgeSpacing\": 0.25, \"quadrant\": 1,"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" \"parts\": [ { \"dxf\": \"a.dxf\", \"quantity\": 12 }, { \"dxf\": \"b.dxf\", \"quantity\": 4, \"allowRotation\": false } ] }"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"Sheet sizes must use the same units as the DXFs. A folder is scanned for *.nest and"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"*.manifest.json files. --sheet-sizes and --spacing override the manifest."
|
||||||
|
);
|
||||||
Console.Error.WriteLine();
|
Console.Error.WriteLine();
|
||||||
Console.Error.WriteLine("Options:");
|
Console.Error.WriteLine("Options:");
|
||||||
Console.Error.WriteLine(" --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest");
|
Console.Error.WriteLine(
|
||||||
Console.Error.WriteLine(" (default: the distinct sizes already in each file)");
|
" --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest"
|
||||||
Console.Error.WriteLine(" --spacing <value> Override part spacing for every job");
|
);
|
||||||
Console.Error.WriteLine(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)");
|
Console.Error.WriteLine(
|
||||||
Console.Error.WriteLine(" --csv <path> Write a flat CSV of all results");
|
" (default: the distinct sizes already in each file,"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" which hints engines toward the original layout)"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" --spacing <value> Override part spacing for every job"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" --engines Name1,Name2,... Only benchmark these registered engines (default: all)"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" --csv <path> Write a flat CSV of all results"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" --salvage-rate <0..1> Fraction of eligible offcut area credited (default: saved .nest rate;"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" manifests 0; needs positive --min-salvage-dimension)"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" --min-salvage-dimension <value> Both offcut dimensions must qualify; positive value enables credit (default 0)"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" --output <directory> Save valid layouts as .nest plus detailed JSON reports"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" --parallel <n> Solves to run at once (default 3; 1 = strictly sequential,"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" which gives the cleanest per-engine timings)"
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
" --progress Log each solve's start, engine progress and finish"
|
||||||
|
);
|
||||||
Console.Error.WriteLine(" --help Show this message");
|
Console.Error.WriteLine(" --help Show this message");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,5 +340,10 @@ static class BenchmarkConsole
|
|||||||
public double? PartSpacing;
|
public double? PartSpacing;
|
||||||
public List<string> EngineNames = new();
|
public List<string> EngineNames = new();
|
||||||
public string CsvPath;
|
public string CsvPath;
|
||||||
|
public string OutputDirectory;
|
||||||
|
public double? SalvageRate;
|
||||||
|
public double? MinimumSalvageDimension;
|
||||||
|
public int Parallel = 3;
|
||||||
|
public bool Progress;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ namespace OpenNest.Benchmark
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Console + CSV reporting for benchmark results. Ranking rule per job:
|
/// Console + CSV reporting for benchmark results. Ranking rule per job:
|
||||||
/// valid beats invalid; higher aggregate utilization wins; if utilization
|
/// valid beats invalid; placing every requested part beats not; then lower
|
||||||
/// ties and both engines fully placed every requested part, fewer plates
|
/// JobResult.Cost wins - salvage-credited sheet area consumed plus a
|
||||||
/// used wins (the multi-plate analogue of "smaller remnant" - both are
|
/// largest-sheet penalty per unplaced part, so dropping awkward parts can
|
||||||
/// proxies for wasting less material). Ties beyond that are a shared win.
|
/// never buy a better score; then fewer plates. Ties beyond that are a
|
||||||
|
/// shared win. Across jobs, costs and areas are summed (not averaged), so
|
||||||
|
/// a big job weighs more than a three-part one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Report
|
public static class Report
|
||||||
{
|
{
|
||||||
@@ -28,19 +30,28 @@ namespace OpenNest.Benchmark
|
|||||||
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
|
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
|
||||||
var best = ranked.Count > 0 ? ranked[0] : null;
|
var best = ranked.Count > 0 ? ranked[0] : null;
|
||||||
|
|
||||||
Console.WriteLine($"{"Engine",-16} {"Result",-9} {"Parts",-10} {"Util%",-8} {"Plates",-18} {"Time(ms)",-9} Notes");
|
Console.WriteLine(
|
||||||
|
$"{"Engine", -16} {"Result", -9} {"Parts", -10} {"Util%", -7} {"Net%", -7} {"Cost", -12} {"Plates", -18} {"Time(ms)", -9} Notes"
|
||||||
|
);
|
||||||
|
|
||||||
foreach (var r in ranked)
|
foreach (var r in ranked)
|
||||||
{
|
{
|
||||||
var isWinner = best != null && Compare(r, best) == 0 && r.Valid;
|
var isWinner = best != null && Compare(r, best) == 0 && r.Valid;
|
||||||
var marker = isWinner ? "*" : " ";
|
var marker = isWinner ? "*" : " ";
|
||||||
var status = r.Crashed ? "CRASH" : r.Valid ? "ok" : "INVALID";
|
var status =
|
||||||
|
r.Crashed ? "CRASH"
|
||||||
|
: r.Valid ? "ok"
|
||||||
|
: "INVALID";
|
||||||
var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}";
|
var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}";
|
||||||
var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-";
|
var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-";
|
||||||
var platesCol = r.PlatesUsed > 0 ? $"{r.PlatesUsed} ({SizeSummary(r.SizeBreakdown)})" : "-";
|
var netCol = r.Valid ? $"{r.NetUtilization * 100:F1}" : "-";
|
||||||
|
var platesCol =
|
||||||
|
r.PlatesUsed > 0 ? $"{r.PlatesUsed} ({SizeSummary(r.SizeBreakdown)})" : "-";
|
||||||
var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2));
|
var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2));
|
||||||
|
|
||||||
Console.WriteLine($"{marker}{r.EngineName,-15} {status,-9} {partsCol,-10} {utilCol,-8} {platesCol,-18} {r.ElapsedMs,-9} {notes}");
|
Console.WriteLine(
|
||||||
|
$"{marker}{r.EngineName, -15} {status, -9} {partsCol, -10} {utilCol, -7} {netCol, -7} {r.Cost, -12:F1} {platesCol, -18} {r.ElapsedMs, -9} {notes}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,39 +70,68 @@ namespace OpenNest.Benchmark
|
|||||||
Valid = g.Count(r => r.Valid),
|
Valid = g.Count(r => r.Valid),
|
||||||
Crashed = g.Count(r => r.Crashed),
|
Crashed = g.Count(r => r.Crashed),
|
||||||
FullyPlaced = g.Count(r => r.FullyPlaced),
|
FullyPlaced = g.Count(r => r.FullyPlaced),
|
||||||
TotalUtilization = g.Sum(r => r.Utilization),
|
Unplaced = g.Sum(r => r.PartsUnplaced),
|
||||||
|
PlacedArea = g.Where(r => r.Valid).Sum(r => r.PlacedArea),
|
||||||
|
PlateArea = g.Where(r => r.Valid).Sum(r => r.PlateArea),
|
||||||
|
NetSheetArea = g.Where(r => r.Valid).Sum(r => r.NetSheetArea),
|
||||||
|
TotalCost = g.Sum(r => r.Cost),
|
||||||
TotalPlates = g.Sum(r => r.PlatesUsed),
|
TotalPlates = g.Sum(r => r.PlatesUsed),
|
||||||
TotalTimeMs = g.Sum(r => r.ElapsedMs),
|
TotalTimeMs = g.Sum(r => r.ElapsedMs),
|
||||||
})
|
})
|
||||||
.OrderByDescending(e => e.TotalUtilization)
|
.OrderBy(e => e.TotalCost)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var wins = CountWins(results);
|
var wins = CountWins(results);
|
||||||
|
|
||||||
Console.WriteLine($"{"Engine",-16} {"Jobs",-6} {"Valid",-7} {"Complete",-9} {"Wins",-6} {"AvgUtil%",-10} {"Plates",-8} {"TotalTime(ms)",-14}");
|
// Util% and Net% are area-weighted over valid runs (sum placed / sum
|
||||||
|
// sheet), not a mean of per-job percentages. TotalCost sums across
|
||||||
|
// jobs, so it is only meaningful when every job uses the same units.
|
||||||
|
Console.WriteLine(
|
||||||
|
$"{"Engine", -16} {"Jobs", -6} {"Valid", -7} {"Complete", -9} {"Unplaced", -9} {"Wins", -6} {"Util%", -7} {"Net%", -7} {"TotalCost", -14} {"Plates", -8} {"TotalTime(ms)", -14}"
|
||||||
|
);
|
||||||
|
|
||||||
foreach (var e in byEngine)
|
foreach (var e in byEngine)
|
||||||
{
|
{
|
||||||
var avgUtil = e.Jobs > 0 ? e.TotalUtilization / e.Jobs * 100 : 0;
|
var util = e.PlateArea > 0 ? e.PlacedArea / e.PlateArea * 100 : 0;
|
||||||
|
var netUtil = e.NetSheetArea > 0 ? e.PlacedArea / e.NetSheetArea * 100 : 0;
|
||||||
var winCount = wins.TryGetValue(e.Engine, out var w) ? w : 0;
|
var winCount = wins.TryGetValue(e.Engine, out var w) ? w : 0;
|
||||||
Console.WriteLine($"{e.Engine,-16} {e.Jobs,-6} {e.Valid,-7} {e.FullyPlaced,-9} {winCount,-6} {avgUtil,-10:F1} {e.TotalPlates,-8} {e.TotalTimeMs,-14}");
|
Console.WriteLine(
|
||||||
|
$"{e.Engine, -16} {e.Jobs, -6} {e.Valid, -7} {e.FullyPlaced, -9} {e.Unplaced, -9} {winCount, -6} {util, -7:F1} {netUtil, -7:F1} {e.TotalCost, -14:F1} {e.TotalPlates, -8} {e.TotalTimeMs, -14}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void WriteCsv(string path, List<JobResult> results)
|
public static void WriteCsv(string path, List<JobResult> results)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine("Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,PlatesUsed,SizeBreakdown,ElapsedMs,Notes");
|
sb.AppendLine(
|
||||||
|
"Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes"
|
||||||
|
);
|
||||||
|
|
||||||
foreach (var r in results)
|
foreach (var r in results)
|
||||||
{
|
{
|
||||||
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
|
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
|
||||||
sb.AppendLine(string.Join(",",
|
sb.AppendLine(
|
||||||
Csv(r.JobName), Csv(r.EngineName), r.Valid, r.Crashed, r.FullyPlaced,
|
string.Join(
|
||||||
r.PartsPlaced, r.PartsRequested,
|
",",
|
||||||
|
Csv(r.JobName),
|
||||||
|
Csv(r.EngineName),
|
||||||
|
r.Valid,
|
||||||
|
r.Crashed,
|
||||||
|
r.FullyPlaced,
|
||||||
|
r.PartsPlaced,
|
||||||
|
r.PartsRequested,
|
||||||
r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
|
r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
|
||||||
r.PlatesUsed, Csv(SizeSummary(r.SizeBreakdown)),
|
r.NetUtilization.ToString("F4", CultureInfo.InvariantCulture),
|
||||||
r.ElapsedMs, Csv(notes)));
|
r.PlateArea.ToString("F2", CultureInfo.InvariantCulture),
|
||||||
|
r.NetSheetArea.ToString("F2", CultureInfo.InvariantCulture),
|
||||||
|
r.Cost.ToString("F2", CultureInfo.InvariantCulture),
|
||||||
|
r.PlatesUsed,
|
||||||
|
Csv(SizeSummary(r.SizeBreakdown)),
|
||||||
|
r.ElapsedMs,
|
||||||
|
Csv(notes)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
File.WriteAllText(path, sb.ToString());
|
File.WriteAllText(path, sb.ToString());
|
||||||
@@ -134,9 +174,10 @@ namespace OpenNest.Benchmark
|
|||||||
return wins;
|
return wins;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Lower sorts first (better). Valid beats invalid, then higher
|
/// <summary>Lower sorts first (better). Valid beats invalid, complete beats
|
||||||
/// aggregate utilization, then (if both fully placed) fewer plates used.</summary>
|
/// incomplete, then lower cost (relative tolerance, since costs are areas
|
||||||
private static int Compare(JobResult a, JobResult b)
|
/// in whatever units the job uses), then fewer plates.</summary>
|
||||||
|
public static int Compare(JobResult a, JobResult b)
|
||||||
{
|
{
|
||||||
if (a.Valid != b.Valid)
|
if (a.Valid != b.Valid)
|
||||||
return a.Valid ? -1 : 1;
|
return a.Valid ? -1 : 1;
|
||||||
@@ -144,12 +185,16 @@ namespace OpenNest.Benchmark
|
|||||||
if (!a.Valid)
|
if (!a.Valid)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
var utilDiff = b.Utilization - a.Utilization;
|
if (a.FullyPlaced != b.FullyPlaced)
|
||||||
|
return a.FullyPlaced ? -1 : 1;
|
||||||
|
|
||||||
if (System.Math.Abs(utilDiff) > Epsilon)
|
var costDiff = a.Cost - b.Cost;
|
||||||
return utilDiff > 0 ? 1 : -1;
|
var scale = System.Math.Max(1, System.Math.Max(a.Cost, b.Cost));
|
||||||
|
|
||||||
if (a.FullyPlaced && b.FullyPlaced && a.PlatesUsed != b.PlatesUsed)
|
if (System.Math.Abs(costDiff) > Epsilon * scale)
|
||||||
|
return costDiff > 0 ? 1 : -1;
|
||||||
|
|
||||||
|
if (a.PlatesUsed != b.PlatesUsed)
|
||||||
return a.PlatesUsed > b.PlatesUsed ? 1 : -1;
|
return a.PlatesUsed > b.PlatesUsed ? 1 : -1;
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RootNamespace>OpenNest.Console</RootNamespace>
|
<RootNamespace>OpenNest.Console</RootNamespace>
|
||||||
<AssemblyName>OpenNest.Console</AssemblyName>
|
<AssemblyName>OpenNest.Console</AssemblyName>
|
||||||
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
|
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
|
||||||
|
|||||||
+381
-85
@@ -1,13 +1,19 @@
|
|||||||
using OpenNest;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.IO;
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using OpenNest;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Placement;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
using OpenNest.IO.Bending;
|
||||||
|
|
||||||
return NestConsole.Run(args);
|
return NestConsole.Run(args);
|
||||||
|
|
||||||
@@ -15,17 +21,90 @@ static class NestConsole
|
|||||||
{
|
{
|
||||||
public static int Run(string[] args)
|
public static int Run(string[] args)
|
||||||
{
|
{
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
ConsoleCancelEventHandler cancel = (_, e) => { e.Cancel = true; cancellation.Cancel(); };
|
||||||
|
Console.CancelKeyPress += cancel;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return RunCore(args, cancellation.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Nesting cancelled; nothing saved.");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.CancelKeyPress -= cancel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int RunCore(string[] args, CancellationToken token)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
|
||||||
var options = ParseArgs(args);
|
var options = ParseArgs(args);
|
||||||
|
|
||||||
if (options == null)
|
if (options == null)
|
||||||
return 0; // --help was requested
|
return 0; // --help was requested
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.RepairBendsMillimeters.HasValue
|
||||||
|
&& (
|
||||||
|
options.CadUnits == BendRepairUnits.Unspecified
|
||||||
|
|| !double.IsFinite(options.RepairBendsMillimeters.Value)
|
||||||
|
|| options.RepairBendsMillimeters <= 0.001
|
||||||
|
|| options.RepairBendsMillimeters > 3.175
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"Error: --repair-bends-mm requires a limit > 0.001 and <= 3.175 mm and --cad-units inches|mm."
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.ListPosts)
|
if (options.ListPosts)
|
||||||
{
|
{
|
||||||
ListPostProcessors(options);
|
ListPostProcessors(options);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate --engine up front: autonest names a jobs engine, plain fill names a
|
||||||
|
// single-plate placement strategy. Unknown names exit with the valid choices.
|
||||||
|
if (options.AutoNest)
|
||||||
|
{
|
||||||
|
var isJobsEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
||||||
|
e.Name.Equals(options.Engine, StringComparison.OrdinalIgnoreCase)
|
||||||
|
);
|
||||||
|
if (!isJobsEngine)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"Error: unknown engine '{options.Engine}'. Jobs engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PlateFillService.ResolveStrategy(options.Engine);
|
||||||
|
}
|
||||||
|
catch (NotSupportedException)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"Error: unknown engine '{options.Engine}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (jobs engines such as StockLadder require --autonest)"
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (options.InputFiles.Count == 0)
|
if (options.InputFiles.Count == 0)
|
||||||
{
|
{
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
@@ -48,6 +127,12 @@ static class NestConsole
|
|||||||
|
|
||||||
var plate = nest.Plates[options.PlateIndex];
|
var plate = nest.Plates[options.PlateIndex];
|
||||||
|
|
||||||
|
if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles.");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
ApplyTemplate(plate, options);
|
ApplyTemplate(plate, options);
|
||||||
ApplyOverrides(plate, options);
|
ApplyOverrides(plate, options);
|
||||||
|
|
||||||
@@ -58,18 +143,21 @@ static class NestConsole
|
|||||||
|
|
||||||
var existingCount = plate.Parts.Count;
|
var existingCount = plate.Parts.Count;
|
||||||
|
|
||||||
if (!options.KeepParts)
|
if (!options.AutoNest && !options.KeepParts)
|
||||||
plate.Parts.Clear();
|
plate.Parts.Clear();
|
||||||
|
|
||||||
PrintHeader(nest, plate, drawing, existingCount, options);
|
PrintHeader(nest, plate, drawing, existingCount, options);
|
||||||
|
|
||||||
var (success, elapsed) = Fill(nest, plate, drawing, options);
|
var (success, elapsed, accepted) = Fill(nest, plate, drawing, options, token);
|
||||||
|
if (!accepted)
|
||||||
|
return 2; // No save or post may run after a rejected proposal.
|
||||||
|
|
||||||
var overlapCount = CheckOverlaps(plate, options);
|
var overlapCount = CheckOverlaps(plate, options);
|
||||||
|
|
||||||
PrintResults(success, plate, elapsed);
|
PrintResults(success, plate, elapsed);
|
||||||
Save(nest, options);
|
token.ThrowIfCancellationRequested();
|
||||||
PostProcess(nest, options);
|
if (!SaveAndPost(nest, options))
|
||||||
|
return 1;
|
||||||
|
|
||||||
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
|
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
|
||||||
}
|
}
|
||||||
@@ -82,6 +170,26 @@ static class NestConsole
|
|||||||
{
|
{
|
||||||
switch (args[i])
|
switch (args[i])
|
||||||
{
|
{
|
||||||
|
case "--repair-bends-mm":
|
||||||
|
o.RepairBendsMillimeters =
|
||||||
|
i + 1 < args.Length
|
||||||
|
&& double.TryParse(
|
||||||
|
args[++i],
|
||||||
|
NumberStyles.Float,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out var limit
|
||||||
|
)
|
||||||
|
? limit
|
||||||
|
: double.NaN;
|
||||||
|
break;
|
||||||
|
case "--cad-units" when i + 1 < args.Length:
|
||||||
|
o.CadUnits = args[++i] switch
|
||||||
|
{
|
||||||
|
"inches" => BendRepairUnits.Inches,
|
||||||
|
"mm" => BendRepairUnits.Millimeters,
|
||||||
|
_ => BendRepairUnits.Unspecified,
|
||||||
|
};
|
||||||
|
break;
|
||||||
case "--drawing" when i + 1 < args.Length:
|
case "--drawing" when i + 1 < args.Length:
|
||||||
o.DrawingName = args[++i];
|
o.DrawingName = args[++i];
|
||||||
break;
|
break;
|
||||||
@@ -113,15 +221,21 @@ static class NestConsole
|
|||||||
case "--template" when i + 1 < args.Length:
|
case "--template" when i + 1 < args.Length:
|
||||||
o.TemplateFile = args[++i];
|
o.TemplateFile = args[++i];
|
||||||
break;
|
break;
|
||||||
|
case "--allow-invalid":
|
||||||
|
o.AllowInvalid = true;
|
||||||
|
break;
|
||||||
case "--autonest":
|
case "--autonest":
|
||||||
o.AutoNest = true;
|
o.AutoNest = true;
|
||||||
break;
|
break;
|
||||||
case "--engine" when i + 1 < args.Length:
|
case "--engine" when i + 1 < args.Length:
|
||||||
NestEngineRegistry.ActiveEngineName = args[++i];
|
o.Engine = args[++i];
|
||||||
break;
|
break;
|
||||||
case "--post" when i + 1 < args.Length:
|
case "--post" when i + 1 < args.Length:
|
||||||
o.PostName = args[++i];
|
o.PostName = args[++i];
|
||||||
break;
|
break;
|
||||||
|
case "--acknowledge-post-risks":
|
||||||
|
o.AcknowledgePostRisks = true;
|
||||||
|
break;
|
||||||
case "--post-output" when i + 1 < args.Length:
|
case "--post-output" when i + 1 < args.Length:
|
||||||
o.PostOutput = args[++i];
|
o.PostOutput = args[++i];
|
||||||
break;
|
break;
|
||||||
@@ -149,10 +263,14 @@ static class NestConsole
|
|||||||
{
|
{
|
||||||
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
||||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||||
var dxfFiles = options.InputFiles.Where(f =>
|
);
|
||||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
|
var dxfFiles = options
|
||||||
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToList();
|
.InputFiles.Where(f =>
|
||||||
|
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
|
||||||
|
)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
// If we have a nest file, load it and optionally add DXFs.
|
// If we have a nest file, load it and optionally add DXFs.
|
||||||
if (nestFile != null)
|
if (nestFile != null)
|
||||||
@@ -167,13 +285,15 @@ static class NestConsole
|
|||||||
|
|
||||||
if (options.PlateIndex >= nest.Plates.Count)
|
if (options.PlateIndex >= nest.Plates.Count)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"Error: plate index {options.PlateIndex} out of range (0-{nest.Plates.Count - 1})");
|
Console.Error.WriteLine(
|
||||||
|
$"Error: plate index {options.PlateIndex} out of range (0-{nest.Plates.Count - 1})"
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var dxf in dxfFiles)
|
foreach (var dxf in dxfFiles)
|
||||||
{
|
{
|
||||||
var drawing = ImportDxf(dxf);
|
var drawing = ImportDxf(dxf, options);
|
||||||
|
|
||||||
if (drawing == null)
|
if (drawing == null)
|
||||||
return null;
|
return null;
|
||||||
@@ -194,7 +314,9 @@ static class NestConsole
|
|||||||
|
|
||||||
if (!options.PlateSize.HasValue)
|
if (!options.PlateSize.HasValue)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Error: --size WxL is required when importing DXF files without a nest");
|
Console.Error.WriteLine(
|
||||||
|
"Error: --size WxL is required when importing DXF files without a nest"
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +326,7 @@ static class NestConsole
|
|||||||
|
|
||||||
foreach (var dxf in dxfFiles)
|
foreach (var dxf in dxfFiles)
|
||||||
{
|
{
|
||||||
var drawing = ImportDxf(dxf);
|
var drawing = ImportDxf(dxf, options);
|
||||||
|
|
||||||
if (drawing == null)
|
if (drawing == null)
|
||||||
return null;
|
return null;
|
||||||
@@ -216,11 +338,28 @@ static class NestConsole
|
|||||||
return newNest;
|
return newNest;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Drawing ImportDxf(string path)
|
static Drawing ImportDxf(string path, Options options)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return CadImporter.ImportDrawing(path);
|
var result = CadImporter.Import(
|
||||||
|
path,
|
||||||
|
new CadImportOptions
|
||||||
|
{
|
||||||
|
BendRepair = options.RepairBendsMillimeters.HasValue
|
||||||
|
? new BendRepairOptions
|
||||||
|
{
|
||||||
|
DrawingUnits = options.CadUnits,
|
||||||
|
MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
foreach (var report in result.BendRepairReports)
|
||||||
|
Console.WriteLine(
|
||||||
|
$"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})"
|
||||||
|
);
|
||||||
|
return CadImporter.BuildDrawing(result, result.Entities, result.Bends, 1, null, null);
|
||||||
}
|
}
|
||||||
catch (System.Exception ex)
|
catch (System.Exception ex)
|
||||||
{
|
{
|
||||||
@@ -256,7 +395,8 @@ static class NestConsole
|
|||||||
// Only apply size override when it wasn't already used to create the plate.
|
// Only apply size override when it wasn't already used to create the plate.
|
||||||
var hasDxfOnly = !options.InputFiles.Any(f =>
|
var hasDxfOnly = !options.InputFiles.Any(f =>
|
||||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||||
|
);
|
||||||
|
|
||||||
if (options.PlateSize.HasValue && !hasDxfOnly)
|
if (options.PlateSize.HasValue && !hasDxfOnly)
|
||||||
plate.Size = options.PlateSize.Value;
|
plate.Size = options.PlateSize.Value;
|
||||||
@@ -264,36 +404,61 @@ static class NestConsole
|
|||||||
|
|
||||||
static Drawing ResolveDrawing(Nest nest, Options options)
|
static Drawing ResolveDrawing(Nest nest, Options options)
|
||||||
{
|
{
|
||||||
var drawing = options.DrawingName != null
|
var drawing =
|
||||||
|
options.DrawingName != null
|
||||||
? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName)
|
? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName)
|
||||||
: nest.Drawings.FirstOrDefault();
|
: nest.Drawings.FirstOrDefault();
|
||||||
|
|
||||||
if (drawing != null)
|
if (drawing != null)
|
||||||
return drawing;
|
return drawing;
|
||||||
|
|
||||||
Console.Error.WriteLine(options.DrawingName != null
|
Console.Error.WriteLine(
|
||||||
|
options.DrawingName != null
|
||||||
? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}"
|
? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}"
|
||||||
: "Error: nest file contains no drawings");
|
: "Error: nest file contains no drawings"
|
||||||
|
);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void PrintHeader(Nest nest, Plate plate, Drawing drawing, int existingCount, Options options)
|
static void PrintHeader(
|
||||||
|
Nest nest,
|
||||||
|
Plate plate,
|
||||||
|
Drawing drawing,
|
||||||
|
int existingCount,
|
||||||
|
Options options
|
||||||
|
)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Nest: {nest.Name}");
|
Console.WriteLine($"Nest: {nest.Name}");
|
||||||
var wa = plate.WorkArea();
|
var wa = plate.WorkArea();
|
||||||
Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Width:F1}x{wa.Length:F1}");
|
Console.WriteLine(
|
||||||
Console.WriteLine($"Drawing: {drawing.Name}");
|
$"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Width:F1}x{wa.Length:F1}"
|
||||||
Console.WriteLine(options.KeepParts
|
);
|
||||||
|
var existingPartsMessage = options.KeepParts
|
||||||
? $"Keeping {existingCount} existing parts"
|
? $"Keeping {existingCount} existing parts"
|
||||||
: $"Cleared {existingCount} existing parts");
|
: options.AutoNest
|
||||||
Console.WriteLine("---");
|
? $"Will replace {existingCount} existing parts only after acceptance"
|
||||||
|
: $"Cleared {existingCount} existing parts";
|
||||||
|
Console.WriteLine(
|
||||||
|
$"""
|
||||||
|
Drawing: {drawing.Name}
|
||||||
|
{existingPartsMessage}
|
||||||
|
---
|
||||||
|
"""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static (bool success, long elapsedMs) Fill(Nest nest, Plate plate, Drawing drawing, Options options)
|
static (bool success, long elapsedMs, bool accepted) Fill(
|
||||||
|
Nest nest,
|
||||||
|
Plate plate,
|
||||||
|
Drawing drawing,
|
||||||
|
Options options,
|
||||||
|
CancellationToken token
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
bool success;
|
bool success;
|
||||||
|
var accepted = true;
|
||||||
|
|
||||||
if (options.AutoNest)
|
if (options.AutoNest)
|
||||||
{
|
{
|
||||||
@@ -310,22 +475,96 @@ static class NestConsole
|
|||||||
nestItems.Add(new NestItem { Drawing = d, Quantity = qty });
|
nestItems.Add(new NestItem { Drawing = d, Quantity = qty });
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts");
|
Console.WriteLine(
|
||||||
|
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
|
||||||
|
);
|
||||||
|
|
||||||
var engine = NestEngineRegistry.Create(plate);
|
var outcome = AutoNestJob(plate, nestItems, options.Engine, options.AllowInvalid, token);
|
||||||
var nestParts = engine.Nest(nestItems, null, CancellationToken.None);
|
accepted = outcome.accepted;
|
||||||
plate.Parts.AddRange(nestParts);
|
success = outcome.committed > 0;
|
||||||
success = nestParts.Count > 0;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var engine = NestEngineRegistry.Create(plate);
|
// Single-plate fill: explicit placement strategy through the public service;
|
||||||
|
// the process-global engine registry is never consulted.
|
||||||
|
var strategy = ResolveFillStrategy(options.Engine);
|
||||||
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
|
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
|
||||||
success = engine.Fill(item);
|
var parts = PlateFillService.FillItem(
|
||||||
|
strategy,
|
||||||
|
plate,
|
||||||
|
item,
|
||||||
|
plate.WorkArea(),
|
||||||
|
null,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (parts.Count > 0)
|
||||||
|
plate.Parts.AddRange(parts);
|
||||||
|
success = parts.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
return (success, sw.ElapsedMilliseconds);
|
return (success, sw.ElapsedMilliseconds, accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
static (bool accepted, int committed) AutoNestJob(
|
||||||
|
Plate plate, List<NestItem> nestItems, string engineName, bool allowInvalid, CancellationToken token)
|
||||||
|
{
|
||||||
|
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||||
|
engineName, nestItems, NestStockBuilder.SinglePlate(plate)), token: token);
|
||||||
|
foreach (var violation in result.Violations)
|
||||||
|
Console.Error.WriteLine($"Violation: {violation}");
|
||||||
|
|
||||||
|
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(result.Plates.Count > 1
|
||||||
|
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
|
||||||
|
: !result.CanKeep
|
||||||
|
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
|
||||||
|
: "Error: invalid result discarded. Use --allow-invalid to explicitly keep its violations. Nothing saved.");
|
||||||
|
return (false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var proposed = result.Plates.SingleOrDefault();
|
||||||
|
var committed = proposed?.Parts.Count ?? 0;
|
||||||
|
if (committed == 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Error: no placements returned. Existing layout and output left unchanged.");
|
||||||
|
return (false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
plate.Parts.Clear();
|
||||||
|
plate.Size = proposed.Stock.Size;
|
||||||
|
plate.PartSpacing = proposed.Stock.PartSpacing;
|
||||||
|
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
|
||||||
|
plate.Quadrant = proposed.Stock.Quadrant;
|
||||||
|
plate.Quantity = 1;
|
||||||
|
plate.Parts.AddRange(proposed.Parts);
|
||||||
|
Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
|
||||||
|
return (true, committed);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string ResolveFillStrategy(string engineName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return PlateFillService.ResolveStrategy(engineName);
|
||||||
|
}
|
||||||
|
catch (NotSupportedException)
|
||||||
|
{
|
||||||
|
var isJobEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
||||||
|
e.Name.Equals(engineName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
isJobEngine
|
||||||
|
? $"Error: engine '{engineName}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
||||||
|
: $"Error: unknown engine '{engineName}'. Engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
|
||||||
|
);
|
||||||
|
Environment.Exit(1);
|
||||||
|
throw; // unreachable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static int CheckOverlaps(Plate plate, Options options)
|
static int CheckOverlaps(Plate plate, Options options)
|
||||||
@@ -334,19 +573,25 @@ static class NestConsole
|
|||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
||||||
Console.WriteLine(hasOverlaps
|
Console.WriteLine(
|
||||||
|
hasOverlaps
|
||||||
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
||||||
: "Overlap check: PASS");
|
: "Overlap check: PASS"
|
||||||
|
);
|
||||||
|
|
||||||
return overlapPts.Count;
|
return overlapPts.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void PrintResults(bool success, Plate plate, long elapsedMs)
|
static void PrintResults(bool success, Plate plate, long elapsedMs)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Result: {(success ? "success" : "failed")}");
|
Console.WriteLine(
|
||||||
Console.WriteLine($"Parts placed: {plate.Parts.Count}");
|
$"""
|
||||||
Console.WriteLine($"Utilization: {plate.Utilization():P1}");
|
Result: {(success ? "success" : "failed")}
|
||||||
Console.WriteLine($"Time: {elapsedMs}ms");
|
Parts placed: {plate.Parts.Count}
|
||||||
|
Utilization: {plate.Utilization():P1}
|
||||||
|
Time: {elapsedMs}ms
|
||||||
|
"""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void Save(Nest nest, Options options)
|
static void Save(Nest nest, Options options)
|
||||||
@@ -354,22 +599,23 @@ static class NestConsole
|
|||||||
if (options.NoSave)
|
if (options.NoSave)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var firstInput = options.InputFiles[0];
|
var outputFile = NestOutputPath(options);
|
||||||
var outputFile = options.OutputFile ?? Path.Combine(
|
|
||||||
Path.GetDirectoryName(firstInput),
|
|
||||||
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}");
|
|
||||||
|
|
||||||
new NestWriter(nest).Write(outputFile);
|
new NestWriter(nest).Write(outputFile);
|
||||||
Console.WriteLine($"Saved: {outputFile}");
|
Console.WriteLine($"Saved: {outputFile}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string NestOutputPath(Options options) => options.OutputFile
|
||||||
|
?? Path.Combine(Path.GetDirectoryName(options.InputFiles[0]),
|
||||||
|
$"{Path.GetFileNameWithoutExtension(options.InputFiles[0])}-result{NestFormat.FileExtension}");
|
||||||
|
|
||||||
static string ResolvePostsDir(Options options)
|
static string ResolvePostsDir(Options options)
|
||||||
{
|
{
|
||||||
if (options.PostsDir != null)
|
if (options.PostsDir != null)
|
||||||
return options.PostsDir;
|
return options.PostsDir;
|
||||||
|
|
||||||
var exePath = Assembly.GetEntryAssembly()?.Location
|
var exePath =
|
||||||
?? typeof(NestConsole).Assembly.Location;
|
Assembly.GetEntryAssembly()?.Location ?? typeof(NestConsole).Assembly.Location;
|
||||||
return Path.Combine(Path.GetDirectoryName(exePath), "Posts");
|
return Path.Combine(Path.GetDirectoryName(exePath), "Posts");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +634,11 @@ static class NestConsole
|
|||||||
|
|
||||||
foreach (var type in assembly.GetTypes())
|
foreach (var type in assembly.GetTypes())
|
||||||
{
|
{
|
||||||
if (!typeof(IPostProcessor).IsAssignableFrom(type) || type.IsInterface || type.IsAbstract)
|
if (
|
||||||
|
!typeof(IPostProcessor).IsAssignableFrom(type)
|
||||||
|
|| type.IsInterface
|
||||||
|
|| type.IsAbstract
|
||||||
|
)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (Activator.CreateInstance(type) is IPostProcessor processor)
|
if (Activator.CreateInstance(type) is IPostProcessor processor)
|
||||||
@@ -397,7 +647,9 @@ static class NestConsole
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}");
|
Console.Error.WriteLine(
|
||||||
|
$"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,26 +673,40 @@ static class NestConsole
|
|||||||
Console.WriteLine($" {p.Name,-30} {p.Description}");
|
Console.WriteLine($" {p.Name,-30} {p.Description}");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void PostProcess(Nest nest, Options options)
|
static bool SaveAndPost(Nest nest, Options options)
|
||||||
{
|
{
|
||||||
if (options.PostName == null)
|
if (options.PostName == null)
|
||||||
return;
|
{
|
||||||
|
Save(nest, options);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
var postsDir = ResolvePostsDir(options);
|
var postsDir = ResolvePostsDir(options);
|
||||||
var processors = LoadPostProcessors(postsDir);
|
var processors = LoadPostProcessors(postsDir);
|
||||||
var post = processors.FirstOrDefault(p =>
|
var post = processors.FirstOrDefault(p =>
|
||||||
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase));
|
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
);
|
||||||
|
|
||||||
if (post == null)
|
if (post == null)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found");
|
Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found");
|
||||||
|
|
||||||
if (processors.Count > 0)
|
if (processors.Count > 0)
|
||||||
Console.Error.WriteLine($"Available: {string.Join(", ", processors.Select(p => p.Name))}");
|
Console.Error.WriteLine(
|
||||||
|
$"Available: {string.Join(", ", processors.Select(p => p.Name))}"
|
||||||
|
);
|
||||||
else
|
else
|
||||||
Console.Error.WriteLine($"No post processors found in: {postsDir}");
|
Console.Error.WriteLine($"No post processors found in: {postsDir}");
|
||||||
|
|
||||||
return;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var verification = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
|
||||||
|
Console.WriteLine(verification.ToDisplayText());
|
||||||
|
if (!verification.CanPost(options.AcknowledgePostRisks))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Posting blocked: review these warnings. To accept the risks, including possible head crashes and machine or material damage, explicitly use --acknowledge-post-risks for this invocation.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var outputFile = options.PostOutput;
|
var outputFile = options.PostOutput;
|
||||||
@@ -450,42 +716,67 @@ static class NestConsole
|
|||||||
var firstInput = options.InputFiles[0];
|
var firstInput = options.InputFiles[0];
|
||||||
outputFile = Path.Combine(
|
outputFile = Path.Combine(
|
||||||
Path.GetDirectoryName(firstInput),
|
Path.GetDirectoryName(firstInput),
|
||||||
$"{Path.GetFileNameWithoutExtension(firstInput)}.cnc");
|
$"{Path.GetFileNameWithoutExtension(firstInput)}.cnc"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var outputFiles = post is IMultiFilePostProcessor multiFile
|
||||||
|
? multiFile.GetOutputFiles(nest, outputFile)
|
||||||
|
: new[] { outputFile };
|
||||||
|
|
||||||
|
if (!options.NoSave && outputFiles.Any(file => string.Equals(
|
||||||
|
Path.GetFullPath(file), Path.GetFullPath(NestOutputPath(options)), StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Error: nest save and CNC output paths must be different. No output was written.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Save(nest, options);
|
||||||
post.Post(nest, outputFile);
|
post.Post(nest, outputFile);
|
||||||
Console.WriteLine($"Post: {post.Name} -> {outputFile}");
|
foreach (var file in outputFiles)
|
||||||
|
Console.WriteLine($"Post: {post.Name} -> {file}");
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void PrintUsage()
|
static void PrintUsage()
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
Console.Error.WriteLine(
|
||||||
Console.Error.WriteLine();
|
"""
|
||||||
Console.Error.WriteLine("Arguments:");
|
Usage: OpenNest.Console <input-files...> [options]
|
||||||
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf/.dwg drawing files");
|
|
||||||
Console.Error.WriteLine();
|
Arguments:
|
||||||
Console.Error.WriteLine("Modes:");
|
input-files One or more .nest nest files or .dxf/.dwg drawing files
|
||||||
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
|
|
||||||
Console.Error.WriteLine(" <part.dxf> --size WxL Import DXF, create plate, and fill");
|
Modes:
|
||||||
Console.Error.WriteLine(" <nest.nest> <part.dxf> Load nest and add imported DXF drawings");
|
<nest.nest> Load nest and fill (existing behavior)
|
||||||
Console.Error.WriteLine();
|
<part.dxf> --size WxL Import DXF, create plate, and fill
|
||||||
Console.Error.WriteLine("Options:");
|
<nest.nest> <part.dxf> Load nest and add imported DXF drawings
|
||||||
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)");
|
|
||||||
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)");
|
Options:
|
||||||
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
|
--repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm
|
||||||
Console.Error.WriteLine(" --spacing <value> Override part spacing");
|
--cad-units inches|mm Explicit source coordinate units required for bend repair
|
||||||
Console.Error.WriteLine(" --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode");
|
--drawing <name> Drawing name to fill with (default: first drawing)
|
||||||
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.nest)");
|
--plate <index> Plate index to fill (default: 0)
|
||||||
Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)");
|
--quantity <n> Max parts to place (default: 0 = unlimited)
|
||||||
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
|
--spacing <value> Override part spacing
|
||||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
|
||||||
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
--output <path> Output nest file path (default: <input>-result.nest)
|
||||||
Console.Error.WriteLine(" --no-save Skip saving output file");
|
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
|
||||||
Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
|
--autonest Validated whole-job nesting onto one sheet; replaces only after acceptance
|
||||||
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)");
|
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
|
||||||
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
|
--engine <name> With --autonest: jobs engine (default: Default; also StockLadder, Strip, ...).
|
||||||
Console.Error.WriteLine(" --list-posts List available post processors and exit");
|
Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant)
|
||||||
Console.Error.WriteLine(" -h, --help Show this help");
|
--keep-parts Don't clear existing parts before filling
|
||||||
|
--check-overlaps Run overlap detection after fill (exit code 1 if found)
|
||||||
|
--no-save Skip saving output file
|
||||||
|
--post <name> Run a post processor after nesting
|
||||||
|
--post-output <path> Output file for post processor (default: <input>.cnc)
|
||||||
|
--acknowledge-post-risks Explicitly accept displayed verification risks for this invocation
|
||||||
|
--posts-dir <path> Directory containing post processor DLLs (default: Posts/)
|
||||||
|
--list-posts List available post processors and exit
|
||||||
|
-h, --help Show this help
|
||||||
|
"""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class Options
|
class Options
|
||||||
@@ -501,10 +792,15 @@ static class NestConsole
|
|||||||
public bool NoSave;
|
public bool NoSave;
|
||||||
public bool KeepParts;
|
public bool KeepParts;
|
||||||
public bool AutoNest;
|
public bool AutoNest;
|
||||||
|
public bool AllowInvalid;
|
||||||
|
public string Engine = "Default";
|
||||||
public string TemplateFile;
|
public string TemplateFile;
|
||||||
public string PostName;
|
public string PostName;
|
||||||
|
public bool AcknowledgePostRisks;
|
||||||
public string PostOutput;
|
public string PostOutput;
|
||||||
public string PostsDir;
|
public string PostsDir;
|
||||||
public bool ListPosts;
|
public bool ListPosts;
|
||||||
|
public double? RepairBendsMillimeters;
|
||||||
|
public BendRepairUnits CadUnits;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-11
@@ -1,5 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Generic;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest
|
namespace OpenNest
|
||||||
{
|
{
|
||||||
@@ -7,7 +7,10 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
public static void Vertically(Entity fixedEntity, Entity movableEntity)
|
public static void Vertically(Entity fixedEntity, Entity movableEntity)
|
||||||
{
|
{
|
||||||
movableEntity.Offset(fixedEntity.BoundingBox.Center.X - movableEntity.BoundingBox.Center.X, 0);
|
movableEntity.Offset(
|
||||||
|
fixedEntity.BoundingBox.Center.X - movableEntity.BoundingBox.Center.X,
|
||||||
|
0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Vertically(Entity fixedEntity, List<Entity> entities)
|
public static void Vertically(Entity fixedEntity, List<Entity> entities)
|
||||||
@@ -17,7 +20,10 @@ namespace OpenNest
|
|||||||
|
|
||||||
public static void Vertically(Part fixedPart, Part movablePart)
|
public static void Vertically(Part fixedPart, Part movablePart)
|
||||||
{
|
{
|
||||||
movablePart.Offset(fixedPart.BoundingBox.Center.X - movablePart.BoundingBox.Center.X, 0);
|
movablePart.Offset(
|
||||||
|
fixedPart.BoundingBox.Center.X - movablePart.BoundingBox.Center.X,
|
||||||
|
0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Vertically(Part fixedPart, List<Part> parts)
|
public static void Vertically(Part fixedPart, List<Part> parts)
|
||||||
@@ -27,7 +33,10 @@ namespace OpenNest
|
|||||||
|
|
||||||
public static void Horizontally(Entity fixedEntity, Entity movableEntity)
|
public static void Horizontally(Entity fixedEntity, Entity movableEntity)
|
||||||
{
|
{
|
||||||
movableEntity.Offset(0, fixedEntity.BoundingBox.Center.Y - movableEntity.BoundingBox.Center.Y);
|
movableEntity.Offset(
|
||||||
|
0,
|
||||||
|
fixedEntity.BoundingBox.Center.Y - movableEntity.BoundingBox.Center.Y
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Horizontally(Entity fixedEntity, List<Entity> entities)
|
public static void Horizontally(Entity fixedEntity, List<Entity> entities)
|
||||||
@@ -37,7 +46,10 @@ namespace OpenNest
|
|||||||
|
|
||||||
public static void Horizontally(Part fixedPart, Part movablePart)
|
public static void Horizontally(Part fixedPart, Part movablePart)
|
||||||
{
|
{
|
||||||
movablePart.Offset(0, fixedPart.BoundingBox.Center.Y - movablePart.BoundingBox.Center.Y);
|
movablePart.Offset(
|
||||||
|
0,
|
||||||
|
fixedPart.BoundingBox.Center.Y - movablePart.BoundingBox.Center.Y
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Horizontally(Part fixedPart, List<Part> parts)
|
public static void Horizontally(Part fixedPart, List<Part> parts)
|
||||||
@@ -67,7 +79,10 @@ namespace OpenNest
|
|||||||
|
|
||||||
public static void Right(Entity fixedEntity, Entity movableEntity)
|
public static void Right(Entity fixedEntity, Entity movableEntity)
|
||||||
{
|
{
|
||||||
movableEntity.Offset(fixedEntity.BoundingBox.Right - movableEntity.BoundingBox.Right, 0);
|
movableEntity.Offset(
|
||||||
|
fixedEntity.BoundingBox.Right - movableEntity.BoundingBox.Right,
|
||||||
|
0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Right(Entity fixedEntity, List<Entity> entities)
|
public static void Right(Entity fixedEntity, List<Entity> entities)
|
||||||
@@ -107,7 +122,10 @@ namespace OpenNest
|
|||||||
|
|
||||||
public static void Bottom(Entity fixedEntity, Entity movableEntity)
|
public static void Bottom(Entity fixedEntity, Entity movableEntity)
|
||||||
{
|
{
|
||||||
movableEntity.Offset(0, fixedEntity.BoundingBox.Bottom - movableEntity.BoundingBox.Bottom);
|
movableEntity.Offset(
|
||||||
|
0,
|
||||||
|
fixedEntity.BoundingBox.Bottom - movableEntity.BoundingBox.Bottom
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Bottom(Entity fixedEntity, List<Entity> entities)
|
public static void Bottom(Entity fixedEntity, List<Entity> entities)
|
||||||
@@ -137,14 +155,19 @@ namespace OpenNest
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var list = new List<Part>(parts);
|
var list = new List<Part>(parts);
|
||||||
list.Sort((p1, p2) => horizontal
|
list.Sort(
|
||||||
|
(p1, p2) =>
|
||||||
|
horizontal
|
||||||
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)
|
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)
|
||||||
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y));
|
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y)
|
||||||
|
);
|
||||||
|
|
||||||
var lastIndex = list.Count - 1;
|
var lastIndex = list.Count - 1;
|
||||||
|
|
||||||
var start = horizontal ? list[0].BoundingBox.Center.X : list[0].BoundingBox.Center.Y;
|
var start = horizontal ? list[0].BoundingBox.Center.X : list[0].BoundingBox.Center.Y;
|
||||||
var end = horizontal ? list[lastIndex].BoundingBox.Center.X : list[lastIndex].BoundingBox.Center.Y;
|
var end = horizontal
|
||||||
|
? list[lastIndex].BoundingBox.Center.X
|
||||||
|
: list[lastIndex].BoundingBox.Center.Y;
|
||||||
|
|
||||||
var spacing = (end - start) / lastIndex;
|
var spacing = (end - start) / lastIndex;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
|
namespace OpenNest
|
||||||
namespace OpenNest
|
|
||||||
{
|
{
|
||||||
public enum AlignType
|
public enum AlignType
|
||||||
{
|
{
|
||||||
@@ -10,6 +9,6 @@ namespace OpenNest
|
|||||||
Horizontally,
|
Horizontally,
|
||||||
Vertically,
|
Vertically,
|
||||||
EvenlySpaceHorizontally,
|
EvenlySpaceHorizontally,
|
||||||
EvenlySpaceVertically
|
EvenlySpaceVertically,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plans and applies the same automatic-cutoff settings to a stable, ordered set of plates.
|
||||||
|
/// Callers must prevent concurrent edits for the duration of either operation.
|
||||||
|
/// </summary>
|
||||||
|
public static class AutomaticCutOffBatch
|
||||||
|
{
|
||||||
|
/// <summary>Detached plans in plate order. Invalid input identifies the one-based plate number.</summary>
|
||||||
|
public static IReadOnlyList<AutomaticCutOffPlan> Create(IReadOnlyList<Plate> plates,
|
||||||
|
AutomaticCutOffOptions options, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plates);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(settings);
|
||||||
|
if (plates.Distinct(ReferenceEqualityComparer.Instance).Count() != plates.Count)
|
||||||
|
throw new ArgumentException("Each plate must occur only once.", nameof(plates));
|
||||||
|
|
||||||
|
var plans = new List<AutomaticCutOffPlan>(plates.Count);
|
||||||
|
for (var index = 0; index < plates.Count; index++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
plans.Add(AutomaticCutOffPlanner.Create(plates[index], options, settings));
|
||||||
|
}
|
||||||
|
catch (ArgumentException error)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Plate {index + 1}: {error.Message}", nameof(plates), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plans.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replans all plates before changing any. A blocking plan returns without applying any
|
||||||
|
/// definitions; empty/unchanged plates are untouched. On failure, restores cutoff state
|
||||||
|
/// on every touched plate, reporting explicitly if any restoration also fails.
|
||||||
|
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates,
|
||||||
|
AutomaticCutOffOptions options, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
var plans = Create(plates, options, settings);
|
||||||
|
if (plans.Any(plan => plan.HasBlockingDiagnostics))
|
||||||
|
return plans;
|
||||||
|
|
||||||
|
var restoreActions = new List<(int PlateNumber, Action Restore)>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var index = 0; index < plates.Count; index++)
|
||||||
|
{
|
||||||
|
var plan = plans[index];
|
||||||
|
if (plan.Definitions.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var plate = plates[index];
|
||||||
|
// Register recovery before the first observable mutation, including AddRange.
|
||||||
|
restoreActions.Add((index + 1, CaptureRestore(plate, plan)));
|
||||||
|
plate.CutOffs.AddRange(plan.Definitions);
|
||||||
|
plate.RegenerateCutOffs(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception applyError)
|
||||||
|
{
|
||||||
|
var rollbackErrors = new List<Exception>();
|
||||||
|
for (var index = restoreActions.Count - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
var saved = restoreActions[index];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
saved.Restore();
|
||||||
|
}
|
||||||
|
catch (Exception rollbackError)
|
||||||
|
{
|
||||||
|
// A broken observer on one plate must not prevent recovery of the others.
|
||||||
|
rollbackErrors.Add(new InvalidOperationException(
|
||||||
|
$"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rollbackErrors.Count > 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
|
||||||
|
+ string.Join("; ", rollbackErrors.Select(e => e.Message))
|
||||||
|
+ "\nThe nest may be incomplete; review it before saving or cutting.",
|
||||||
|
new AggregateException(new[] { applyError }.Concat(rollbackErrors)));
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
|
||||||
|
}
|
||||||
|
return plans;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan)
|
||||||
|
{
|
||||||
|
// Regeneration replaces drawing programs and removes/reinserts cutoff parts.
|
||||||
|
// Save only that state; real parts, poses, programs and quantities stay untouched.
|
||||||
|
var programs = plate.CutOffs.Select(c => (CutOff: c, Program: c.Drawing.Program)).ToArray();
|
||||||
|
var parts = plate.Parts.Select((part, index) => (Part: part, Index: index))
|
||||||
|
.Where(p => p.Part.BaseDrawing.IsCutOff).ToArray();
|
||||||
|
return () =>
|
||||||
|
{
|
||||||
|
foreach (var definition in plan.Definitions)
|
||||||
|
plate.CutOffs.Remove(definition);
|
||||||
|
for (var index = plate.Parts.Count - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
if (plate.Parts[index].BaseDrawing.IsCutOff)
|
||||||
|
plate.Parts.RemoveAt(index);
|
||||||
|
}
|
||||||
|
foreach (var saved in programs)
|
||||||
|
saved.CutOff.Drawing.Program = saved.Program;
|
||||||
|
foreach (var saved in parts)
|
||||||
|
plate.Parts.Insert(saved.Index, saved.Part);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
public sealed class AutomaticCutOffOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Nominal distance between vertical cut lines in model units. Must be finite and
|
||||||
|
/// greater than AutomaticCutOffPlanner.MinimumSpacing; Create also bounds candidate count.
|
||||||
|
/// </summary>
|
||||||
|
public double Spacing { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum retained-tail length along X in model units. Zero permits any positive tail.
|
||||||
|
/// The desktop default is 12 inches (304.8 mm).
|
||||||
|
/// </summary>
|
||||||
|
public double MinimumTailLength { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AutomaticCutOffDiagnosticCode
|
||||||
|
{
|
||||||
|
ExistingCutOff,
|
||||||
|
LimitedCutOffConflict,
|
||||||
|
EmptyCut,
|
||||||
|
SegmentedCut,
|
||||||
|
NoSafeTailSeparator,
|
||||||
|
TailBelowMinimum,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode Code, string Message, bool IsBlocking = false, double? X = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detached proposal. Collections are read-only; contained definitions/preview parts belong
|
||||||
|
/// to the caller. Never accept a blocked plan or a preview made for an older layout/settings.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutomaticCutOffPlan
|
||||||
|
{
|
||||||
|
/// <summary>Only new, usable definitions; existing equivalent definitions are not returned.</summary>
|
||||||
|
public IReadOnlyList<CutOff> Definitions { get; internal set; } = Array.Empty<CutOff>();
|
||||||
|
|
||||||
|
/// <summary>Detached display parts, in the same order as Definitions. Not for acceptance.</summary>
|
||||||
|
public IReadOnlyList<Part> PreviewParts { get; internal set; } = Array.Empty<Part>();
|
||||||
|
|
||||||
|
public IReadOnlyList<AutomaticCutOffDiagnostic> Diagnostics { get; internal set; } =
|
||||||
|
Array.Empty<AutomaticCutOffDiagnostic>();
|
||||||
|
|
||||||
|
/// <summary>Furthest real-part X distance from the origin, excluding cut-off parts.</summary>
|
||||||
|
public double OccupiedSpan { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Distance to the verified separator, or full sheet length when no separated tail can
|
||||||
|
/// be claimed. Zero on empty sheets. This is not a disconnected-scrap-size guarantee.
|
||||||
|
/// </summary>
|
||||||
|
public double UsedSpan { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>Length beyond a verified separator, otherwise zero (also on empty sheets).</summary>
|
||||||
|
public double TailLength { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>Signed X coordinate of a verified new or equivalent existing separator.</summary>
|
||||||
|
public double? TailSeparatorX { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
|
||||||
|
public bool HasSeparatedTail => TailSeparatorX.HasValue;
|
||||||
|
|
||||||
|
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pure proposals for vertical scrap cuts, measured in the plate's model units.</summary>
|
||||||
|
public static class AutomaticCutOffPlanner
|
||||||
|
{
|
||||||
|
public const double GeometryTolerance = Tolerance.Epsilon;
|
||||||
|
public const double MinimumSpacing = 2 * GeometryTolerance;
|
||||||
|
public const int MaximumCandidateCount = 10000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs.
|
||||||
|
/// Invalid inputs throw ArgumentException. Blocking diagnostics return no definitions.
|
||||||
|
/// Accept by adding Definitions to Plate.CutOffs and calling RegenerateCutOffs with the
|
||||||
|
/// same settings, only while the layout is unchanged. Do not add PreviewParts to the plate.
|
||||||
|
/// Nominal spacing is not a guarantee of fully disconnected, hopper-sized scrap.
|
||||||
|
/// </summary>
|
||||||
|
public static AutomaticCutOffPlan Create(Plate plate, AutomaticCutOffOptions options,
|
||||||
|
CutOffSettings settings)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plate);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(settings);
|
||||||
|
ValidateInputs(plate, options, settings);
|
||||||
|
var bounds = plate.BoundingBox(false);
|
||||||
|
Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel),
|
||||||
|
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
|
||||||
|
|
||||||
|
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
|
||||||
|
var occupied = 0.0;
|
||||||
|
var hasParts = false;
|
||||||
|
foreach (var part in plate.Parts)
|
||||||
|
{
|
||||||
|
Require(part?.BaseDrawing != null, "Every part must have a drawing.", nameof(plate));
|
||||||
|
if (part.BaseDrawing.IsCutOff)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign));
|
||||||
|
hasParts = true;
|
||||||
|
}
|
||||||
|
if (!hasParts)
|
||||||
|
return new AutomaticCutOffPlan();
|
||||||
|
|
||||||
|
// Use distances from the coordinate origin, not the sheet's lower-left corner.
|
||||||
|
var length = plate.Size.Length;
|
||||||
|
occupied = System.Math.Min(occupied, length);
|
||||||
|
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
|
||||||
|
+ GeometryTolerance;
|
||||||
|
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
|
||||||
|
var usedSpan = hasTailCandidate ? separator : length;
|
||||||
|
var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
|
||||||
|
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
|
||||||
|
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
|
||||||
|
|
||||||
|
// Validate and bound the candidate count before preparing geometry or allocating lines.
|
||||||
|
ValidateExisting(plate, bounds, settings);
|
||||||
|
var cache = Plate.BuildPerimeterCache(plate);
|
||||||
|
var definitions = new List<CutOff>();
|
||||||
|
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
||||||
|
var separated = false;
|
||||||
|
var separatorX = (double?)null;
|
||||||
|
|
||||||
|
// Multiplication by an integer avoids drift from repeated floating-point addition.
|
||||||
|
for (var index = 1; index <= (int)candidateCount; index++)
|
||||||
|
{
|
||||||
|
var distance = index * options.Spacing;
|
||||||
|
if (distance >= usedSpan - GeometryTolerance)
|
||||||
|
break;
|
||||||
|
AddCandidate(sign * distance, false);
|
||||||
|
}
|
||||||
|
if (hasTailCandidate)
|
||||||
|
{
|
||||||
|
if (length - separator >= options.MinimumTailLength)
|
||||||
|
AddCandidate(sign * separator, true);
|
||||||
|
else
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
|
||||||
|
"The proposed tail is shorter than the minimum tail length; the final separator was skipped. "
|
||||||
|
+ "Other automatic lines and existing cut-offs are unchanged. No retained tail is claimed.",
|
||||||
|
X: sign * separator));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagnostics.Any(d => d.IsBlocking))
|
||||||
|
{
|
||||||
|
// A partial proposal must not accidentally be accepted around a manual conflict.
|
||||||
|
definitions.Clear();
|
||||||
|
separated = false;
|
||||||
|
separatorX = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AutomaticCutOffPlan
|
||||||
|
{
|
||||||
|
Definitions = definitions.AsReadOnly(),
|
||||||
|
PreviewParts = definitions.Select(c => new Part(c.Drawing)).ToList().AsReadOnly(),
|
||||||
|
Diagnostics = diagnostics.AsReadOnly(),
|
||||||
|
OccupiedSpan = occupied,
|
||||||
|
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
|
||||||
|
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
|
||||||
|
TailSeparatorX = separatorX,
|
||||||
|
};
|
||||||
|
|
||||||
|
void AddCandidate(double x, bool isSeparator)
|
||||||
|
{
|
||||||
|
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
|
||||||
|
Near(c.Position.X, x)).ToList();
|
||||||
|
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
|
||||||
|
{
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
|
||||||
|
"A same-line manual cut has different limits. Manual review is required; no cuts may be applied.",
|
||||||
|
true, x));
|
||||||
|
if (isSeparator)
|
||||||
|
WarnNoSeparator(x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even a duplicate must be regenerated detached with CURRENT settings. Its live
|
||||||
|
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
|
||||||
|
var existing = matches.FirstOrDefault();
|
||||||
|
var candidate = existing == null
|
||||||
|
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
|
||||||
|
: new CutOff(existing.Position, existing.Axis)
|
||||||
|
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
|
||||||
|
candidate.Regenerate(plate, settings, cache);
|
||||||
|
var program = candidate.Drawing.Program;
|
||||||
|
var usable = HasUsableSegments(program);
|
||||||
|
if (existing != null)
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.ExistingCutOff,
|
||||||
|
"An equivalent full-span cut-off already exists; no duplicate was added.", X: x));
|
||||||
|
if (!usable)
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.EmptyCut,
|
||||||
|
"The line has no usable cut segments after part clearance and minimum-length filtering; it is not a partition.",
|
||||||
|
X: x));
|
||||||
|
|
||||||
|
if (isSeparator)
|
||||||
|
{
|
||||||
|
if (!usable || !IsFullSpanProgram(program, bounds))
|
||||||
|
{
|
||||||
|
WarnNoSeparator(x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
separated = true;
|
||||||
|
separatorX = candidate.Position.X;
|
||||||
|
}
|
||||||
|
else if (usable && !IsFullSpanProgram(program, bounds))
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.SegmentedCut,
|
||||||
|
"Part clearance or segment filtering interrupts this line; nominal spacing does not guarantee disconnected scrap.",
|
||||||
|
X: x));
|
||||||
|
|
||||||
|
if (usable && existing == null)
|
||||||
|
definitions.Add(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
|
||||||
|
"No safe full-width tail separator survives the current settings. No separated tail is claimed; review manually.",
|
||||||
|
X: x));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateInputs(Plate plate, AutomaticCutOffOptions options, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
|
||||||
|
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
|
||||||
|
Require(Nonnegative(options.MinimumTailLength),
|
||||||
|
"Minimum tail length must be finite and nonnegative.", nameof(options));
|
||||||
|
Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 &&
|
||||||
|
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));
|
||||||
|
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
|
||||||
|
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
||||||
|
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
|
||||||
|
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
|
||||||
|
Require(plate.Parts != null && plate.CutOffs != null,
|
||||||
|
"Plate parts and cut-off collections are required.", nameof(plate));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ValidatePart(Part part, Box sheet, int sign)
|
||||||
|
{
|
||||||
|
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
|
||||||
|
"Part pose must be finite.", "plate");
|
||||||
|
ValidateProgram(part.Program, new HashSet<Program>());
|
||||||
|
var box = part.Program.BoundingBox();
|
||||||
|
box.Offset(part.Location);
|
||||||
|
Require(ValidBox(box) && box.Length > 0 && box.Width > 0,
|
||||||
|
"Real parts must have finite, nonempty geometry.", "plate");
|
||||||
|
var cached = part.BoundingBox;
|
||||||
|
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
|
||||||
|
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
|
||||||
|
"Part geometry has stale bounds; update it before planning.", "plate");
|
||||||
|
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
||||||
|
|
||||||
|
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
|
||||||
|
// Checking converted entities catches overflowing incremental moves and curve bounds.
|
||||||
|
var hasMaterial = false;
|
||||||
|
var occupied = sign > 0 ? box.Right : -box.Left;
|
||||||
|
var roundoff = CutOff.GetBoundsRoundoff(part);
|
||||||
|
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
|
||||||
|
{
|
||||||
|
var entityBox = entity.BoundingBox;
|
||||||
|
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
|
||||||
|
if (!SpecialLayers.IsMaterial(entity.Layer))
|
||||||
|
continue;
|
||||||
|
entityBox = entityBox.Translate(part.Location);
|
||||||
|
// Permit only bounded floating-point roundoff, with the same conservative
|
||||||
|
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
|
||||||
|
// (including refitted arc centers) are still rejected, even below epsilon.
|
||||||
|
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
|
||||||
|
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
|
||||||
|
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
|
||||||
|
Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
||||||
|
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
|
||||||
|
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
|
||||||
|
}
|
||||||
|
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
|
||||||
|
return occupied;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateProgram(Program program, HashSet<Program> path)
|
||||||
|
{
|
||||||
|
Require(program?.Codes != null && path.Count < 64 && path.Add(program),
|
||||||
|
"Part program is missing, recursive, or nested too deeply.", "plate");
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
{
|
||||||
|
Require(code != null, "Part program contains a missing instruction.", "plate");
|
||||||
|
if (code is Motion motion)
|
||||||
|
Require(Finite(motion.EndPoint), "Part motion coordinates must be finite.", "plate");
|
||||||
|
if (code is ArcMove arc)
|
||||||
|
Require(Finite(arc.CenterPoint) && Enum.IsDefined(arc.Rotation),
|
||||||
|
"Part arc geometry must be finite with a valid direction.", "plate");
|
||||||
|
if (code is SubProgramCall call)
|
||||||
|
{
|
||||||
|
Require(Finite(call.Offset) && double.IsFinite(call.Rotation),
|
||||||
|
"Part sub-program pose must be finite.", "plate");
|
||||||
|
ValidateProgram(call.Program, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.Remove(program);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateExisting(Plate plate, Box bounds, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
foreach (var cut in plate.CutOffs)
|
||||||
|
{
|
||||||
|
Require(cut != null && Enum.IsDefined(cut.Axis) && Finite(cut.Position) &&
|
||||||
|
(!cut.StartLimit.HasValue || double.IsFinite(cut.StartLimit.Value)) &&
|
||||||
|
(!cut.EndLimit.HasValue || double.IsFinite(cut.EndLimit.Value)),
|
||||||
|
"Existing cut-off definitions must be finite with a valid axis.", nameof(plate));
|
||||||
|
var start = cut.StartLimit ?? (cut.Axis == CutOffAxis.Vertical ? bounds.Bottom : bounds.Left);
|
||||||
|
var end = cut.EndLimit ?? ((cut.Axis == CutOffAxis.Vertical ? bounds.Top : bounds.Right)
|
||||||
|
+ settings.Overtravel);
|
||||||
|
Require(double.IsFinite(end) && start < end,
|
||||||
|
"Existing cut-off limits must be finite and ordered.", nameof(plate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||||
|
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
|
||||||
|
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
|
||||||
|
|
||||||
|
private static bool HasUsableSegments(Program program)
|
||||||
|
{
|
||||||
|
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
|
||||||
|
return false;
|
||||||
|
for (var i = 0; i < program.Codes.Count; i += 2)
|
||||||
|
{
|
||||||
|
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
|
||||||
|
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
|
||||||
|
!Near(from.EndPoint.X, to.EndPoint.X) ||
|
||||||
|
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsFullSpanProgram(Program program, Box bounds)
|
||||||
|
{
|
||||||
|
// No gaps, bridges, or filtered middle segments can separate the tail.
|
||||||
|
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
|
||||||
|
program.Codes[1] is not LinearMove to)
|
||||||
|
return false;
|
||||||
|
return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance &&
|
||||||
|
System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
|
||||||
|
Nonnegative(box.Length) && Nonnegative(box.Width) &&
|
||||||
|
double.IsFinite(box.Right) && double.IsFinite(box.Top);
|
||||||
|
|
||||||
|
private static bool Inside(Box box, Box sheet) =>
|
||||||
|
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
|
||||||
|
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
|
||||||
|
|
||||||
|
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||||
|
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
|
||||||
|
private static bool Near(double a, double b)
|
||||||
|
{
|
||||||
|
// An exact tolerance-sized offset can round just above epsilon after subtraction.
|
||||||
|
// Allow two representational steps, not a geometry-scale relative tolerance.
|
||||||
|
var magnitude = System.Math.Max(System.Math.Abs(a), System.Math.Abs(b));
|
||||||
|
var step = System.Math.BitIncrement(magnitude) - magnitude;
|
||||||
|
return System.Math.Abs(a - b) <= GeometryTolerance + (double.IsFinite(step) ? 2 * step : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Require(bool valid, string message, string parameter)
|
||||||
|
{
|
||||||
|
if (!valid)
|
||||||
|
throw new ArgumentException(message, parameter);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.Math;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Bending
|
namespace OpenNest.Bending
|
||||||
{
|
{
|
||||||
@@ -10,7 +10,7 @@ namespace OpenNest.Bending
|
|||||||
public static readonly Layer EtchLayer = new Layer("ETCH")
|
public static readonly Layer EtchLayer = new Layer("ETCH")
|
||||||
{
|
{
|
||||||
Color = Color.Green,
|
Color = Color.Green,
|
||||||
IsVisible = true
|
IsVisible = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
private const double DefaultEtchLength = 1.0;
|
private const double DefaultEtchLength = 1.0;
|
||||||
@@ -32,9 +32,8 @@ namespace OpenNest.Bending
|
|||||||
|
|
||||||
public double Length => StartPoint.DistanceTo(EndPoint);
|
public double Length => StartPoint.DistanceTo(EndPoint);
|
||||||
|
|
||||||
public double AngleRadians => Angle.HasValue
|
public double AngleRadians =>
|
||||||
? OpenNest.Math.Angle.ToRadians(Angle.Value)
|
Angle.HasValue ? OpenNest.Math.Angle.ToRadians(Angle.Value) : 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
public Line ToLine() => new Line(StartPoint, EndPoint);
|
public Line ToLine() => new Line(StartPoint, EndPoint);
|
||||||
|
|
||||||
@@ -66,7 +65,9 @@ namespace OpenNest.Bending
|
|||||||
var dx = System.Math.Cos(angle) * etchLength;
|
var dx = System.Math.Cos(angle) * etchLength;
|
||||||
var dy = System.Math.Sin(angle) * etchLength;
|
var dy = System.Math.Sin(angle) * etchLength;
|
||||||
|
|
||||||
result.Add(CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy)));
|
result.Add(
|
||||||
|
CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy))
|
||||||
|
);
|
||||||
result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint));
|
result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +80,8 @@ namespace OpenNest.Bending
|
|||||||
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
|
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
|
||||||
{
|
{
|
||||||
entities.RemoveAll(e => e.Tag == BendEtchTag);
|
entities.RemoveAll(e => e.Tag == BendEtchTag);
|
||||||
if (bends == null) return;
|
if (bends == null)
|
||||||
|
return;
|
||||||
|
|
||||||
foreach (var bend in bends)
|
foreach (var bend in bends)
|
||||||
entities.AddRange(bend.GetEtchEntities());
|
entities.AddRange(bend.GetEtchEntities());
|
||||||
@@ -87,7 +89,12 @@ namespace OpenNest.Bending
|
|||||||
|
|
||||||
private static Line CreateEtchLine(Vector start, Vector end)
|
private static Line CreateEtchLine(Vector start, Vector end)
|
||||||
{
|
{
|
||||||
return new Line(start, end) { Layer = EtchLayer, Color = Color.Green, Tag = BendEtchTag };
|
return new Line(start, end)
|
||||||
|
{
|
||||||
|
Layer = EtchLayer,
|
||||||
|
Color = Color.Green,
|
||||||
|
Tag = BendEtchTag,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ namespace OpenNest.Bending
|
|||||||
{
|
{
|
||||||
Unknown,
|
Unknown,
|
||||||
Up,
|
Up,
|
||||||
Down
|
Down,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,22 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
public class ArcMove : Motion
|
public class ArcMove : Motion
|
||||||
{
|
{
|
||||||
public ArcMove()
|
public ArcMove() { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public ArcMove(double x, double y, double i, double j, RotationType rotation = RotationType.CCW)
|
public ArcMove(
|
||||||
: this(new Vector(x, y), new Vector(i, j), rotation)
|
double x,
|
||||||
{
|
double y,
|
||||||
}
|
double i,
|
||||||
|
double j,
|
||||||
|
RotationType rotation = RotationType.CCW
|
||||||
|
)
|
||||||
|
: this(new Vector(x, y), new Vector(i, j), rotation) { }
|
||||||
|
|
||||||
public ArcMove(Vector endPoint, Vector centerPoint, RotationType rotation = RotationType.CCW)
|
public ArcMove(
|
||||||
|
Vector endPoint,
|
||||||
|
Vector centerPoint,
|
||||||
|
RotationType rotation = RotationType.CCW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
EndPoint = endPoint;
|
EndPoint = endPoint;
|
||||||
CenterPoint = centerPoint;
|
CenterPoint = centerPoint;
|
||||||
@@ -68,7 +74,8 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
Layer = Layer,
|
Layer = Layer,
|
||||||
Suppressed = Suppressed,
|
Suppressed = Suppressed,
|
||||||
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
|
VariableRefs =
|
||||||
|
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +92,9 @@ namespace OpenNest.CNC
|
|||||||
var i = CenterPoint.X.ToString(dp);
|
var i = CenterPoint.X.ToString(dp);
|
||||||
var j = CenterPoint.Y.ToString(dp);
|
var j = CenterPoint.Y.ToString(dp);
|
||||||
|
|
||||||
return Rotation == RotationType.CW ?
|
return Rotation == RotationType.CW
|
||||||
string.Format("G02 X{0} Y{1} I{2} J{3}", x, y, i, j) :
|
? string.Format("G02 X{0} Y{1} I{2} J{3}", x, y, i, j)
|
||||||
string.Format("G03 X{0} Y{1} I{2} J{3}", x, y, i, j);
|
: string.Format("G03 X{0} Y{1} I{2} J{3}", x, y, i, j);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
|
namespace OpenNest.CNC
|
||||||
namespace OpenNest.CNC
|
|
||||||
{
|
{
|
||||||
public enum CodeType
|
public enum CodeType
|
||||||
{
|
{
|
||||||
@@ -9,6 +8,6 @@ namespace OpenNest.CNC
|
|||||||
RapidMove,
|
RapidMove,
|
||||||
SetFeedrate,
|
SetFeedrate,
|
||||||
SetKerf,
|
SetKerf,
|
||||||
SubProgramCall
|
SubProgramCall,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
{
|
{
|
||||||
public class Comment : ICode
|
public class Comment : ICode
|
||||||
{
|
{
|
||||||
public Comment()
|
public Comment() { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public Comment(string value)
|
public Comment(string value)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.Math;
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -48,7 +48,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
for (var iter = 0; iter < 3; iter++)
|
for (var iter = 0; iter < 3; iter++)
|
||||||
{
|
{
|
||||||
var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point;
|
var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point;
|
||||||
perimeterSeed = FindPerimeterIntersection(profile.Perimeter, lastCutoutPt, nextPartStart, out _);
|
perimeterSeed = FindPerimeterIntersection(
|
||||||
|
profile.Perimeter,
|
||||||
|
lastCutoutPt,
|
||||||
|
nextPartStart,
|
||||||
|
out _
|
||||||
|
);
|
||||||
|
|
||||||
orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed);
|
orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed);
|
||||||
orderedCutouts.Reverse();
|
orderedCutouts.Reverse();
|
||||||
@@ -56,7 +61,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
}
|
}
|
||||||
|
|
||||||
var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point;
|
var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point;
|
||||||
perimeterPt = FindPerimeterIntersection(profile.Perimeter, finalLastCutout, nextPartStart, out perimeterEntity);
|
perimeterPt = FindPerimeterIntersection(
|
||||||
|
profile.Perimeter,
|
||||||
|
finalLastCutout,
|
||||||
|
nextPartStart,
|
||||||
|
out perimeterEntity
|
||||||
|
);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -79,18 +89,25 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
if (!profile.Perimeter.IsClosed())
|
if (!profile.Perimeter.IsClosed())
|
||||||
EmitRawContour(result, profile.Perimeter);
|
EmitRawContour(result, profile.Perimeter);
|
||||||
else
|
else
|
||||||
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
|
EmitContour(
|
||||||
|
result,
|
||||||
|
profile.Perimeter,
|
||||||
|
perimeterPt,
|
||||||
|
perimeterEntity,
|
||||||
|
ContourType.External
|
||||||
|
);
|
||||||
|
|
||||||
result.Mode = Mode.Incremental;
|
result.Mode = Mode.Incremental;
|
||||||
|
|
||||||
return new CuttingResult
|
return new CuttingResult { Program = result, LastCutPoint = perimeterPt };
|
||||||
{
|
|
||||||
Program = result,
|
|
||||||
LastCutPoint = perimeterPt
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public CuttingResult ApplySingle(Program partProgram, Vector point, Entity entity, ContourType contourType)
|
public CuttingResult ApplySingle(
|
||||||
|
Program partProgram,
|
||||||
|
Vector point,
|
||||||
|
Entity entity,
|
||||||
|
ContourType contourType
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var entities = partProgram.ToGeometry();
|
var entities = partProgram.ToGeometry();
|
||||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||||
@@ -141,14 +158,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
result.Mode = Mode.Incremental;
|
result.Mode = Mode.Incremental;
|
||||||
|
|
||||||
return new CuttingResult
|
return new CuttingResult { Program = result, LastCutPoint = point };
|
||||||
{
|
|
||||||
Program = result,
|
|
||||||
LastCutPoint = point
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (Shape Shape, Entity Entity) FindTargetShape(ShapeProfile profile, Vector point, Entity clickedEntity)
|
private static (Shape Shape, Entity Entity) FindTargetShape(
|
||||||
|
ShapeProfile profile,
|
||||||
|
Vector point,
|
||||||
|
Entity clickedEntity
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var matched = FindMatchingEntity(profile.Perimeter, clickedEntity);
|
var matched = FindMatchingEntity(profile.Perimeter, clickedEntity);
|
||||||
if (matched != null)
|
if (matched != null)
|
||||||
@@ -190,20 +207,26 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
if (shapeEntity is Line sLine && clickedEntity is Line cLine)
|
if (shapeEntity is Line sLine && clickedEntity is Line cLine)
|
||||||
{
|
{
|
||||||
if (sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
|
if (
|
||||||
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon)
|
sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
|
||||||
|
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon
|
||||||
|
)
|
||||||
return shapeEntity;
|
return shapeEntity;
|
||||||
}
|
}
|
||||||
else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc)
|
else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc)
|
||||||
{
|
{
|
||||||
if (System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
|
if (
|
||||||
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon)
|
System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
|
||||||
|
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon
|
||||||
|
)
|
||||||
return shapeEntity;
|
return shapeEntity;
|
||||||
}
|
}
|
||||||
else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle)
|
else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle)
|
||||||
{
|
{
|
||||||
if (System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
|
if (
|
||||||
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon)
|
System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
|
||||||
|
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon
|
||||||
|
)
|
||||||
return shapeEntity;
|
return shapeEntity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,7 +241,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint));
|
program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<ContourEntry> ResolveLeadInPoints(List<Shape> cutouts, Vector startPoint)
|
private static List<ContourEntry> ResolveLeadInPoints(
|
||||||
|
List<Shape> cutouts,
|
||||||
|
Vector startPoint
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var entries = new ContourEntry[cutouts.Count];
|
var entries = new ContourEntry[cutouts.Count];
|
||||||
var currentPoint = startPoint;
|
var currentPoint = startPoint;
|
||||||
@@ -235,7 +261,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new List<ContourEntry>(entries);
|
return new List<ContourEntry>(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector FindPerimeterIntersection(Shape perimeter, Vector lastCutout, Vector nextPartStart, out Entity entity)
|
private static Vector FindPerimeterIntersection(
|
||||||
|
Shape perimeter,
|
||||||
|
Vector lastCutout,
|
||||||
|
Vector nextPartStart,
|
||||||
|
out Entity entity
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var ray = new Line(lastCutout, nextPartStart);
|
var ray = new Line(lastCutout, nextPartStart);
|
||||||
|
|
||||||
@@ -269,7 +300,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return HashCode.Combine(r, a);
|
return HashCode.Combine(r, a);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null)
|
private void EmitContour(
|
||||||
|
Program program,
|
||||||
|
Shape shape,
|
||||||
|
Vector point,
|
||||||
|
Entity entity,
|
||||||
|
ContourType? forceType = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var contourType = forceType ?? DetectContourType(shape);
|
var contourType = forceType ?? DetectContourType(shape);
|
||||||
var winding = DetermineWinding(shape);
|
var winding = DetermineWinding(shape);
|
||||||
@@ -289,7 +326,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var outwardAngle = normal - System.Math.PI;
|
var outwardAngle = normal - System.Math.PI;
|
||||||
point = new Vector(
|
point = new Vector(
|
||||||
circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle),
|
circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle),
|
||||||
circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle));
|
circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
|
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
|
||||||
@@ -297,7 +335,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
// Build hole sub-program relative to (0,0)
|
// Build hole sub-program relative to (0,0)
|
||||||
var holeCenter = circle.Center;
|
var holeCenter = circle.Center;
|
||||||
var relativePoint = new Vector(point.X - holeCenter.X, point.Y - holeCenter.Y);
|
var relativePoint = new Vector(point.X - holeCenter.X, point.Y - holeCenter.Y);
|
||||||
var relativeCircle = new Circle(new Vector(0, 0), circle.Radius) { Rotation = circle.Rotation };
|
var relativeCircle = new Circle(new Vector(0, 0), circle.Radius)
|
||||||
|
{
|
||||||
|
Rotation = circle.Rotation,
|
||||||
|
};
|
||||||
var relativeShape = new Shape();
|
var relativeShape = new Shape();
|
||||||
relativeShape.Entities.Add(relativeCircle);
|
relativeShape.Entities.Add(relativeCircle);
|
||||||
|
|
||||||
@@ -314,30 +355,44 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
if (!program.SubPrograms.ContainsKey(key))
|
if (!program.SubPrograms.ContainsKey(key))
|
||||||
program.SubPrograms[key] = subPgm;
|
program.SubPrograms[key] = subPgm;
|
||||||
|
|
||||||
program.Codes.Add(new SubProgramCall
|
program.Codes.Add(
|
||||||
|
new SubProgramCall
|
||||||
{
|
{
|
||||||
Id = key,
|
Id = key,
|
||||||
Program = program.SubPrograms[key],
|
Program = program.SubPrograms[key],
|
||||||
Offset = holeCenter
|
Offset = holeCenter,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
program.Codes.AddRange(leadIn.Generate(point, normal, winding));
|
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
||||||
|
Parameters.PierceClearance, out var leadInNormal);
|
||||||
|
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
|
||||||
|
|
||||||
var reindexedShape = shape.ReindexAt(point, entity);
|
var reindexedShape = shape.ReindexAt(point, entity);
|
||||||
|
|
||||||
if (Parameters.TabsEnabled && Parameters.TabConfig != null && contourType == ContourType.External)
|
var tabbed = Parameters.TabsEnabled
|
||||||
|
&& Parameters.TabConfig != null
|
||||||
|
&& contourType == ContourType.External;
|
||||||
|
if (tabbed)
|
||||||
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
||||||
|
|
||||||
|
// A tab leaves the contour short of the corner; a run-out through it would cut the tab.
|
||||||
|
var leadOutNormal = normal;
|
||||||
|
if (!tabbed)
|
||||||
|
leadOut = ResolveLeadOut(shape, point, entity, contourType, leadOut, winding,
|
||||||
|
Parameters.PierceClearance, out leadOutNormal);
|
||||||
|
|
||||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||||
program.Codes.AddRange(leadOut.Generate(point, normal, winding));
|
program.Codes.AddRange(leadOut.Generate(point, leadOutNormal, winding));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||||
{
|
{
|
||||||
if (scribeEntities.Count == 0) return;
|
if (scribeEntities.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
var shapes = ShapeBuilder.GetShapes(scribeEntities);
|
var shapes = ShapeBuilder.GetShapes(scribeEntities);
|
||||||
foreach (var shape in shapes)
|
foreach (var shape in shapes)
|
||||||
@@ -388,8 +443,265 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return ContourType.Internal;
|
return ContourType.Internal;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static double ComputeNormal(Vector point, Entity entity, ContourType contourType,
|
/// <summary>
|
||||||
RotationType winding = RotationType.CW)
|
/// Uses the inward angle bisector for straight lead-ins at cutout corners.
|
||||||
|
/// Edge interiors and other lead-in styles keep the entity normal. Shared
|
||||||
|
/// by program generation and the manual placement preview.
|
||||||
|
/// </summary>
|
||||||
|
public static double ComputeLeadInNormal(
|
||||||
|
Shape shape,
|
||||||
|
Vector point,
|
||||||
|
Entity entity,
|
||||||
|
ContourType contourType,
|
||||||
|
LeadIn leadIn,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var normal = ComputeNormal(point, entity, contourType, winding);
|
||||||
|
if (contourType != ContourType.Internal || leadIn is not LineLeadIn
|
||||||
|
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||||
|
return normal;
|
||||||
|
|
||||||
|
return BisectCorner(point, corner, contourType, winding) ?? normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the lead-in to emit at <paramref name="point"/> and the normal to
|
||||||
|
/// generate it with. At a corner of an outside perimeter, a straight
|
||||||
|
/// (<see cref="LineLeadIn"/>) lead-in extends the edge cut first so the torch
|
||||||
|
/// enters on that edge's line, provided the pierce keeps
|
||||||
|
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
|
||||||
|
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
|
||||||
|
/// reflex corner it bisects the notch. The result does not depend on which of
|
||||||
|
/// the two edges meeting at the corner was picked. Other styles and contour
|
||||||
|
/// types keep <see cref="ComputeLeadInNormal"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static LeadIn ResolveLeadIn(
|
||||||
|
Shape shape,
|
||||||
|
Vector point,
|
||||||
|
Entity entity,
|
||||||
|
ContourType contourType,
|
||||||
|
LeadIn leadIn,
|
||||||
|
RotationType winding,
|
||||||
|
double pierceClearance,
|
||||||
|
out double normal
|
||||||
|
)
|
||||||
|
{
|
||||||
|
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
|
||||||
|
if (contourType != ContourType.External || leadIn is not LineLeadIn line
|
||||||
|
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||||
|
return leadIn;
|
||||||
|
|
||||||
|
switch (ClassifyCorner(corner, winding))
|
||||||
|
{
|
||||||
|
case CornerKind.Convex:
|
||||||
|
var pierce = point - corner.TangentOut * line.Length;
|
||||||
|
if (IsClearStraightLead(shape, point, pierce, pierceClearance))
|
||||||
|
{
|
||||||
|
normal = Angle.NormalizeRad((-corner.TangentOut).Angle());
|
||||||
|
return new LineLeadIn { Length = line.Length, ApproachAngle = 90 };
|
||||||
|
}
|
||||||
|
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||||
|
return leadIn;
|
||||||
|
case CornerKind.Smooth:
|
||||||
|
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||||
|
return leadIn;
|
||||||
|
case CornerKind.Reflex:
|
||||||
|
normal = BisectCorner(point, corner, contourType, winding) ?? normal;
|
||||||
|
return leadIn;
|
||||||
|
default:
|
||||||
|
return leadIn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lead-out counterpart of <see cref="ResolveLeadIn"/>. At a convex outside
|
||||||
|
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
|
||||||
|
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
|
||||||
|
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
|
||||||
|
/// bisects the notch. Other styles and contour types keep the entity normal.
|
||||||
|
/// </summary>
|
||||||
|
public static LeadOut ResolveLeadOut(
|
||||||
|
Shape shape,
|
||||||
|
Vector point,
|
||||||
|
Entity entity,
|
||||||
|
ContourType contourType,
|
||||||
|
LeadOut leadOut,
|
||||||
|
RotationType winding,
|
||||||
|
double clearance,
|
||||||
|
out double normal
|
||||||
|
)
|
||||||
|
{
|
||||||
|
normal = ComputeNormal(point, entity, contourType, winding);
|
||||||
|
if (contourType != ContourType.External || leadOut is not LineLeadOut line
|
||||||
|
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||||
|
return leadOut;
|
||||||
|
|
||||||
|
switch (ClassifyCorner(corner, winding))
|
||||||
|
{
|
||||||
|
case CornerKind.Convex:
|
||||||
|
var end = point + corner.TangentIn * line.Length;
|
||||||
|
if (IsClearStraightLead(shape, point, end, clearance))
|
||||||
|
{
|
||||||
|
normal = Angle.NormalizeRad(corner.TangentIn.Angle());
|
||||||
|
return new LineLeadOut { Length = line.Length, ApproachAngle = 90 };
|
||||||
|
}
|
||||||
|
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||||
|
return leadOut;
|
||||||
|
case CornerKind.Smooth:
|
||||||
|
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||||
|
return leadOut;
|
||||||
|
case CornerKind.Reflex:
|
||||||
|
normal = BisectCorner(point, corner, contourType, winding) ?? normal;
|
||||||
|
return leadOut;
|
||||||
|
default:
|
||||||
|
return leadOut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CornerKind
|
||||||
|
{
|
||||||
|
Convex,
|
||||||
|
Reflex,
|
||||||
|
Smooth,
|
||||||
|
Cusp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A contour vertex: the entity cut into it and the one cut away from it.</summary>
|
||||||
|
private readonly record struct ContourCorner(
|
||||||
|
Entity Incoming,
|
||||||
|
Entity Outgoing,
|
||||||
|
Vector TangentIn,
|
||||||
|
Vector TangentOut
|
||||||
|
);
|
||||||
|
|
||||||
|
private static bool TryGetCorner(Shape shape, Vector point, Entity entity, out ContourCorner corner)
|
||||||
|
{
|
||||||
|
corner = default;
|
||||||
|
if (entity is not (Line or Arc) || entity.Length <= Tolerance.Epsilon
|
||||||
|
|| shape.Entities.Count < 2 || !shape.IsClosed())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var index = shape.Entities.IndexOf(entity);
|
||||||
|
if (index < 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var atStart = point.DistanceTo(EntityStartPoint(entity)) <= Tolerance.Epsilon;
|
||||||
|
if (!atStart && point.DistanceTo(EntityEndPoint(entity)) > Tolerance.Epsilon)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var adjacentIndex = atStart
|
||||||
|
? (index + shape.Entities.Count - 1) % shape.Entities.Count
|
||||||
|
: (index + 1) % shape.Entities.Count;
|
||||||
|
var adjacent = shape.Entities[adjacentIndex];
|
||||||
|
var adjacentPoint = atStart ? EntityEndPoint(adjacent) : EntityStartPoint(adjacent);
|
||||||
|
|
||||||
|
if (adjacent is not (Line or Arc) || adjacent.Length <= Tolerance.Epsilon
|
||||||
|
|| point.DistanceTo(adjacentPoint) > Tolerance.Epsilon)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var incoming = atStart ? adjacent : entity;
|
||||||
|
var outgoing = atStart ? entity : adjacent;
|
||||||
|
var tangentIn = TravelTangent(incoming, point);
|
||||||
|
var tangentOut = TravelTangent(outgoing, point);
|
||||||
|
if (!IsFinite(tangentIn) || !IsFinite(tangentOut))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
corner = new ContourCorner(incoming, outgoing, tangentIn, tangentOut);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Unit direction of travel along a line or arc at a point on it.</summary>
|
||||||
|
private static Vector TravelTangent(Entity entity, Vector point)
|
||||||
|
{
|
||||||
|
if (entity is Line line)
|
||||||
|
return (line.EndPoint - line.StartPoint).Normalize();
|
||||||
|
|
||||||
|
var arc = (Arc)entity;
|
||||||
|
var radial = (point - arc.Center).Normalize();
|
||||||
|
return arc.IsReversed ? new Vector(radial.Y, -radial.X) : new Vector(-radial.Y, radial.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsFinite(Vector v) => double.IsFinite(v.X) && double.IsFinite(v.Y);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convex corners point away from the part (interior angle under 180 degrees).
|
||||||
|
/// 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)
|
||||||
|
{
|
||||||
|
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
|
||||||
|
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
|
||||||
|
var turn = winding == RotationType.CCW ? cross : -cross;
|
||||||
|
|
||||||
|
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
|
||||||
|
return dot > 0 ? CornerKind.Smooth : CornerKind.Cusp;
|
||||||
|
|
||||||
|
return turn > 0 ? CornerKind.Convex : CornerKind.Reflex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? BisectCorner(
|
||||||
|
Vector point,
|
||||||
|
ContourCorner corner,
|
||||||
|
ContourType contourType,
|
||||||
|
RotationType winding
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||||
|
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||||
|
// Sum unit normals rather than averaging angles (which fails at 0/2π).
|
||||||
|
// Winding makes this point into the scrap even at reflex corners.
|
||||||
|
var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal);
|
||||||
|
var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal);
|
||||||
|
if (!double.IsFinite(x) || !double.IsFinite(y)
|
||||||
|
|| x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
|
||||||
|
return null; // Opposing normals at a cusp have no unique bisector.
|
||||||
|
|
||||||
|
return Angle.NormalizeRad(System.Math.Atan2(y, x));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A straight lead from <paramref name="end"/> to the corner stays in the scrap:
|
||||||
|
/// its free end keeps <paramref name="clearance"/> from the contour and the lead
|
||||||
|
/// crosses the contour nowhere but at the corner.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsClearStraightLead(Shape shape, Vector corner, Vector end, double clearance)
|
||||||
|
{
|
||||||
|
if (!IsFinite(end) || end.DistanceTo(corner) <= Tolerance.Epsilon)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var nearest = shape.ClosestPointTo(end, out _);
|
||||||
|
if (nearest.DistanceTo(end) < System.Math.Max(clearance, 0) - Tolerance.Epsilon)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (shape.Intersects(new Line(end, corner), out var crossings))
|
||||||
|
{
|
||||||
|
foreach (var crossing in crossings)
|
||||||
|
{
|
||||||
|
if (crossing.DistanceTo(corner) > Tolerance.ChainTolerance)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector EntityEndPoint(Entity entity)
|
||||||
|
{
|
||||||
|
if (entity is Line line)
|
||||||
|
return line.EndPoint;
|
||||||
|
if (entity is Arc arc)
|
||||||
|
return arc.EndPoint();
|
||||||
|
return Vector.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double ComputeNormal(
|
||||||
|
Vector point,
|
||||||
|
Entity entity,
|
||||||
|
ContourType contourType,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
double normal;
|
double normal;
|
||||||
|
|
||||||
@@ -442,7 +754,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return polygon.RotationDirection();
|
return polygon.RotationDirection();
|
||||||
}
|
}
|
||||||
|
|
||||||
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle)
|
private LeadIn ClampLeadInForCircle(
|
||||||
|
LeadIn leadIn,
|
||||||
|
Circle circle,
|
||||||
|
Vector contourPoint,
|
||||||
|
double normalAngle
|
||||||
|
)
|
||||||
{
|
{
|
||||||
if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0)
|
if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0)
|
||||||
return leadIn;
|
return leadIn;
|
||||||
@@ -492,7 +809,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn,
|
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn,
|
||||||
ContourType.Internal => Parameters.InternalLeadIn,
|
ContourType.Internal => Parameters.InternalLeadIn,
|
||||||
_ => Parameters.ExternalLeadIn
|
_ => Parameters.ExternalLeadIn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +819,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut,
|
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut,
|
||||||
ContourType.Internal => Parameters.InternalLeadOut,
|
ContourType.Internal => Parameters.InternalLeadOut,
|
||||||
_ => Parameters.ExternalLeadOut
|
_ => Parameters.ExternalLeadOut,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,12 +882,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
private static Vector EntityStartPoint(Entity entity)
|
private static Vector EntityStartPoint(Entity entity)
|
||||||
{
|
{
|
||||||
if (entity is Line line) return line.StartPoint;
|
if (entity is Line line)
|
||||||
if (entity is Arc arc) return arc.StartPoint();
|
return line.StartPoint;
|
||||||
|
if (entity is Arc arc)
|
||||||
|
return arc.StartPoint();
|
||||||
return Vector.Zero;
|
return Vector.Zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint, LayerType layer = LayerType.Display)
|
private List<ICode> ConvertShapeToMoves(
|
||||||
|
Shape shape,
|
||||||
|
Vector startPoint,
|
||||||
|
LayerType layer = LayerType.Display
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var moves = new List<ICode>();
|
var moves = new List<ICode>();
|
||||||
|
|
||||||
@@ -582,15 +905,28 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
}
|
}
|
||||||
else if (entity is Arc arc)
|
else if (entity is Arc arc)
|
||||||
{
|
{
|
||||||
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
|
moves.Add(
|
||||||
|
new ArcMove(
|
||||||
|
arc.EndPoint(),
|
||||||
|
arc.Center,
|
||||||
|
arc.IsReversed ? RotationType.CW : RotationType.CCW
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Layer = layer,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
else if (entity is Circle circle)
|
else if (entity is Circle circle)
|
||||||
{
|
{
|
||||||
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer });
|
moves.Add(
|
||||||
|
new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
throw new System.InvalidOperationException($"Unsupported entity type: {entity.Type}");
|
throw new System.InvalidOperationException(
|
||||||
|
$"Unsupported entity type: {entity.Type}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,9 +936,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
private static Vector GetShapeStartPoint(Shape shape)
|
private static Vector GetShapeStartPoint(Shape shape)
|
||||||
{
|
{
|
||||||
var first = shape.Entities[0];
|
var first = shape.Entities[0];
|
||||||
if (first is Line line) return line.StartPoint;
|
if (first is Line line)
|
||||||
if (first is Arc arc) return arc.StartPoint();
|
return line.StartPoint;
|
||||||
if (first is Circle circle) return new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
|
if (first is Arc arc)
|
||||||
|
return arc.StartPoint();
|
||||||
|
if (first is Circle circle)
|
||||||
|
return new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
|
||||||
return Vector.Zero;
|
return Vector.Zero;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
External,
|
External,
|
||||||
Internal,
|
Internal,
|
||||||
ArcCircle
|
ArcCircle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
|
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
|
||||||
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
|
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
|
||||||
|
|
||||||
public LeadIn InternalLeadIn { get; set; } = new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
|
public LeadIn InternalLeadIn { get; set; } =
|
||||||
|
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
|
||||||
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
|
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
|
||||||
|
|
||||||
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
|
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -7,19 +7,23 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
public double Radius { get; set; }
|
public double Radius { get; set; }
|
||||||
|
|
||||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
public override List<ICode> Generate(
|
||||||
RotationType winding = RotationType.CW)
|
Vector contourStartPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||||
|
|
||||||
var arcCenter = new Vector(
|
var arcCenter = new Vector(
|
||||||
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
|
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
|
||||||
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle));
|
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
|
||||||
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(piercePoint),
|
new RapidMove(piercePoint),
|
||||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,10 +34,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
return new Vector(
|
return new Vector(
|
||||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
|
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
|
||||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle));
|
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override LeadIn Scale(double factor) =>
|
public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor };
|
||||||
new ArcLeadIn { Radius = Radius * factor };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public double ArcRadius { get; set; }
|
public double ArcRadius { get; set; }
|
||||||
public double Kerf { get; set; }
|
public double Kerf { get; set; }
|
||||||
|
|
||||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
public override List<ICode> Generate(
|
||||||
RotationType winding = RotationType.CW)
|
Vector contourStartPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||||
|
|
||||||
@@ -22,13 +25,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
|
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
|
||||||
var arcStart = new Vector(
|
var arcStart = new Vector(
|
||||||
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
||||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle));
|
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
||||||
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(piercePoint),
|
new RapidMove(piercePoint),
|
||||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +47,16 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
return new Vector(
|
return new Vector(
|
||||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||||
arcStartY + LineLength * System.Math.Sin(lineAngle));
|
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override LeadIn Scale(double factor) =>
|
public override LeadIn Scale(double factor) =>
|
||||||
new CleanHoleLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, Kerf = Kerf };
|
new CleanHoleLeadIn
|
||||||
|
{
|
||||||
|
LineLength = LineLength * factor,
|
||||||
|
ArcRadius = ArcRadius * factor,
|
||||||
|
Kerf = Kerf,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
public abstract class LeadIn
|
public abstract class LeadIn
|
||||||
{
|
{
|
||||||
public abstract List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
public abstract List<ICode> Generate(
|
||||||
RotationType winding = RotationType.CW);
|
Vector contourStartPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
);
|
||||||
|
|
||||||
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
|
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public double ApproachAngle { get; set; } = 135.0;
|
public double ApproachAngle { get; set; } = 135.0;
|
||||||
public double ArcRadius { get; set; }
|
public double ArcRadius { get; set; }
|
||||||
|
|
||||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
public override List<ICode> Generate(
|
||||||
RotationType winding = RotationType.CW)
|
Vector contourStartPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||||
|
|
||||||
@@ -22,13 +25,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
||||||
var arcStart = new Vector(
|
var arcStart = new Vector(
|
||||||
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
||||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle));
|
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
||||||
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(piercePoint),
|
new RapidMove(piercePoint),
|
||||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +47,16 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
return new Vector(
|
return new Vector(
|
||||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||||
arcStartY + LineLength * System.Math.Sin(lineAngle));
|
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override LeadIn Scale(double factor) =>
|
public override LeadIn Scale(double factor) =>
|
||||||
new LineArcLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, ApproachAngle = ApproachAngle };
|
new LineArcLeadIn
|
||||||
|
{
|
||||||
|
LineLength = LineLength * factor,
|
||||||
|
ArcRadius = ArcRadius * factor,
|
||||||
|
ApproachAngle = ApproachAngle,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -9,15 +9,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public double Length { get; set; }
|
public double Length { get; set; }
|
||||||
public double ApproachAngle { get; set; } = 90.0;
|
public double ApproachAngle { get; set; } = 90.0;
|
||||||
|
|
||||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
public override List<ICode> Generate(
|
||||||
RotationType winding = RotationType.CW)
|
Vector contourStartPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(piercePoint),
|
new RapidMove(piercePoint),
|
||||||
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
|
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +29,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
||||||
return new Vector(
|
return new Vector(
|
||||||
contourStartPoint.X + Length * System.Math.Cos(approachAngle),
|
contourStartPoint.X + Length * System.Math.Cos(approachAngle),
|
||||||
contourStartPoint.Y + Length * System.Math.Sin(approachAngle));
|
contourStartPoint.Y + Length * System.Math.Sin(approachAngle)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override LeadIn Scale(double factor) =>
|
public override LeadIn Scale(double factor) =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -11,21 +11,25 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public double Length2 { get; set; }
|
public double Length2 { get; set; }
|
||||||
public double ApproachAngle2 { get; set; } = 90.0;
|
public double ApproachAngle2 { get; set; } = 90.0;
|
||||||
|
|
||||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
public override List<ICode> Generate(
|
||||||
RotationType winding = RotationType.CW)
|
Vector contourStartPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||||
|
|
||||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||||
var midPoint = new Vector(
|
var midPoint = new Vector(
|
||||||
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
|
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
|
||||||
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle));
|
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle)
|
||||||
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(piercePoint),
|
new RapidMove(piercePoint),
|
||||||
new LinearMove(midPoint) { Layer = LayerType.Leadin },
|
new LinearMove(midPoint) { Layer = LayerType.Leadin },
|
||||||
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
|
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,10 +42,17 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2);
|
var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2);
|
||||||
return new Vector(
|
return new Vector(
|
||||||
midX + Length1 * System.Math.Cos(firstAngle),
|
midX + Length1 * System.Math.Cos(firstAngle),
|
||||||
midY + Length1 * System.Math.Sin(firstAngle));
|
midY + Length1 * System.Math.Sin(firstAngle)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override LeadIn Scale(double factor) =>
|
public override LeadIn Scale(double factor) =>
|
||||||
new LineLineLeadIn { Length1 = Length1 * factor, ApproachAngle1 = ApproachAngle1, Length2 = Length2 * factor, ApproachAngle2 = ApproachAngle2 };
|
new LineLineLeadIn
|
||||||
|
{
|
||||||
|
Length1 = Length1 * factor,
|
||||||
|
ApproachAngle1 = ApproachAngle1,
|
||||||
|
Length2 = Length2 * factor,
|
||||||
|
ApproachAngle2 = ApproachAngle2,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
public class NoLeadIn : LeadIn
|
public class NoLeadIn : LeadIn
|
||||||
{
|
{
|
||||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
public override List<ICode> Generate(
|
||||||
RotationType winding = RotationType.CW)
|
Vector contourStartPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
return new List<ICode>
|
return new List<ICode> { new RapidMove(contourStartPoint) };
|
||||||
{
|
|
||||||
new RapidMove(contourStartPoint)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -7,8 +7,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
public double Radius { get; set; }
|
public double Radius { get; set; }
|
||||||
|
|
||||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
public override List<ICode> Generate(
|
||||||
RotationType winding = RotationType.CW)
|
Vector contourEndPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle);
|
var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle);
|
||||||
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
||||||
@@ -16,11 +19,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
var endPoint = new Vector(
|
var endPoint = new Vector(
|
||||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2),
|
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2),
|
||||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2));
|
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)
|
||||||
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout }
|
new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
public abstract class LeadOut
|
public abstract class LeadOut
|
||||||
{
|
{
|
||||||
public abstract List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
public abstract List<ICode> Generate(
|
||||||
RotationType winding = RotationType.CW);
|
Vector contourEndPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -9,18 +9,19 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public double Length { get; set; }
|
public double Length { get; set; }
|
||||||
public double ApproachAngle { get; set; } = 90.0;
|
public double ApproachAngle { get; set; } = 90.0;
|
||||||
|
|
||||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
public override List<ICode> Generate(
|
||||||
RotationType winding = RotationType.CW)
|
Vector contourEndPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
|
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
|
||||||
var endPoint = new Vector(
|
var endPoint = new Vector(
|
||||||
contourEndPoint.X + Length * System.Math.Cos(overcutAngle),
|
contourEndPoint.X + Length * System.Math.Cos(overcutAngle),
|
||||||
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle));
|
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle)
|
||||||
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode> { new LinearMove(endPoint) { Layer = LayerType.Leadout } };
|
||||||
{
|
|
||||||
new LinearMove(endPoint) { Layer = LayerType.Leadout }
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
public class NoLeadOut : LeadOut
|
public class NoLeadOut : LeadOut
|
||||||
{
|
{
|
||||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
public override List<ICode> Generate(
|
||||||
RotationType winding = RotationType.CW)
|
Vector contourEndPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
return new List<ICode>();
|
return new List<ICode>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
BottomSide = 4,
|
BottomSide = 4,
|
||||||
EdgeStart = 5,
|
EdgeStart = 5,
|
||||||
LeftSide = 7,
|
LeftSide = 7,
|
||||||
RightSideAlt = 8
|
RightSideAlt = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SequenceParameters
|
public class SequenceParameters
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public double BreakerAngle { get; set; }
|
public double BreakerAngle { get; set; }
|
||||||
|
|
||||||
public override List<ICode> Generate(
|
public override List<ICode> Generate(
|
||||||
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
|
Vector tabStartPoint,
|
||||||
RotationType winding = RotationType.CW)
|
Vector tabEndPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var codes = new List<ICode>();
|
var codes = new List<ICode>();
|
||||||
|
|
||||||
@@ -21,7 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var scoreAngle = contourNormalAngle + System.Math.PI;
|
var scoreAngle = contourNormalAngle + System.Math.PI;
|
||||||
var scoreEnd = new Vector(
|
var scoreEnd = new Vector(
|
||||||
tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle),
|
tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle),
|
||||||
tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle));
|
tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle)
|
||||||
|
);
|
||||||
codes.Add(new LinearMove(scoreEnd));
|
codes.Add(new LinearMove(scoreEnd));
|
||||||
codes.Add(new RapidMove(tabEndPoint));
|
codes.Add(new RapidMove(tabEndPoint));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -8,13 +8,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public int MachineTabId { get; set; }
|
public int MachineTabId { get; set; }
|
||||||
|
|
||||||
public override List<ICode> Generate(
|
public override List<ICode> Generate(
|
||||||
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
|
Vector tabStartPoint,
|
||||||
RotationType winding = RotationType.CW)
|
Vector tabEndPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
return new List<ICode>
|
return new List<ICode> { new RapidMove(tabEndPoint) };
|
||||||
{
|
|
||||||
new RapidMove(tabEndPoint)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -11,8 +11,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public double CutoutMaxHeight { get; set; }
|
public double CutoutMaxHeight { get; set; }
|
||||||
|
|
||||||
public override List<ICode> Generate(
|
public override List<ICode> Generate(
|
||||||
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
|
Vector tabStartPoint,
|
||||||
RotationType winding = RotationType.CW)
|
Vector tabEndPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var codes = new List<ICode>();
|
var codes = new List<ICode>();
|
||||||
|
|
||||||
@@ -29,8 +32,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
public bool AppliesToCutout(double cutoutWidth, double cutoutHeight)
|
public bool AppliesToCutout(double cutoutWidth, double cutoutHeight)
|
||||||
{
|
{
|
||||||
return cutoutWidth >= CutoutMinWidth && cutoutWidth <= CutoutMaxWidth
|
return cutoutWidth >= CutoutMinWidth
|
||||||
&& cutoutHeight >= CutoutMinHeight && cutoutHeight <= CutoutMaxHeight;
|
&& cutoutWidth <= CutoutMaxWidth
|
||||||
|
&& cutoutHeight >= CutoutMinHeight
|
||||||
|
&& cutoutHeight <= CutoutMaxHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -10,7 +10,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public LeadOut TabLeadOut { get; set; }
|
public LeadOut TabLeadOut { get; set; }
|
||||||
|
|
||||||
public abstract List<ICode> Generate(
|
public abstract List<ICode> Generate(
|
||||||
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
|
Vector tabStartPoint,
|
||||||
RotationType winding = RotationType.CW);
|
Vector tabEndPoint,
|
||||||
|
double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
|
|
||||||
public const int UseMax = -2;
|
public const int UseMax = -2;
|
||||||
|
|
||||||
public Feedrate()
|
public Feedrate() { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public Feedrate(double value)
|
public Feedrate(double value)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
namespace OpenNest.CNC
|
||||||
namespace OpenNest.CNC
|
|
||||||
{
|
{
|
||||||
public enum KerfType
|
public enum KerfType
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
Left,
|
Left,
|
||||||
Right
|
Right,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
|
namespace OpenNest.CNC
|
||||||
namespace OpenNest.CNC
|
|
||||||
{
|
{
|
||||||
public enum LayerType
|
public enum LayerType
|
||||||
{
|
{
|
||||||
@@ -7,6 +6,6 @@ namespace OpenNest.CNC
|
|||||||
Scribe,
|
Scribe,
|
||||||
Cut,
|
Cut,
|
||||||
Leadin,
|
Leadin,
|
||||||
Leadout
|
Leadout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,10 @@ namespace OpenNest.CNC
|
|||||||
public class LinearMove : Motion
|
public class LinearMove : Motion
|
||||||
{
|
{
|
||||||
public LinearMove()
|
public LinearMove()
|
||||||
: this(new Vector())
|
: this(new Vector()) { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public LinearMove(double x, double y)
|
public LinearMove(double x, double y)
|
||||||
: this(new Vector(x, y))
|
: this(new Vector(x, y)) { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public LinearMove(Vector endPoint)
|
public LinearMove(Vector endPoint)
|
||||||
{
|
{
|
||||||
@@ -34,7 +30,8 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
Layer = Layer,
|
Layer = Layer,
|
||||||
Suppressed = Suppressed,
|
Suppressed = Suppressed,
|
||||||
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
|
VariableRefs =
|
||||||
|
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
namespace OpenNest.CNC
|
||||||
namespace OpenNest.CNC
|
|
||||||
{
|
{
|
||||||
public enum Mode
|
public enum Mode
|
||||||
{
|
{
|
||||||
Absolute,
|
Absolute,
|
||||||
Incremental
|
Incremental,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
namespace OpenNest.CNC
|
||||||
{
|
{
|
||||||
@@ -10,7 +10,8 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
public List<ICode> Codes;
|
public List<ICode> Codes;
|
||||||
|
|
||||||
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
|
public Dictionary<string, VariableDefinition> Variables { get; } =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public Dictionary<int, Program> SubPrograms { get; } = new();
|
public Dictionary<int, Program> SubPrograms { get; } = new();
|
||||||
|
|
||||||
@@ -66,9 +67,17 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
if (code is Motion m)
|
if (code is Motion m)
|
||||||
{
|
{
|
||||||
var cmd = m is RapidMove ? "G00" : (m is ArcMove am ? (am.Rotation == RotationType.CW ? "G02" : "G03") : "G01");
|
var cmd =
|
||||||
|
m is RapidMove
|
||||||
|
? "G00"
|
||||||
|
: (
|
||||||
|
m is ArcMove am
|
||||||
|
? (am.Rotation == RotationType.CW ? "G02" : "G03")
|
||||||
|
: "G01"
|
||||||
|
);
|
||||||
sb.Append($"{cmd}X{m.EndPoint.X:F4}Y{m.EndPoint.Y:F4}");
|
sb.Append($"{cmd}X{m.EndPoint.X:F4}Y{m.EndPoint.Y:F4}");
|
||||||
if (m is ArcMove arc) sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}");
|
if (m is ArcMove arc)
|
||||||
|
sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +90,9 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
SetModeAbs();
|
SetModeAbs();
|
||||||
|
|
||||||
|
// Several calls can share one sub-program (identical holes); rotate each once.
|
||||||
|
var rotatedSubPrograms = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
for (int i = 0; i < Codes.Count; ++i)
|
for (int i = 0; i < Codes.Count; ++i)
|
||||||
{
|
{
|
||||||
var code = Codes[i];
|
var code = Codes[i];
|
||||||
@@ -97,10 +109,11 @@ namespace OpenNest.CNC
|
|||||||
var dy = subpgm.Offset.Y - origin.Y;
|
var dy = subpgm.Offset.Y - origin.Y;
|
||||||
subpgm.Offset = new Geometry.Vector(
|
subpgm.Offset = new Geometry.Vector(
|
||||||
origin.X + dx * cos - dy * sin,
|
origin.X + dx * cos - dy * sin,
|
||||||
origin.Y + dx * sin + dy * cos);
|
origin.Y + dx * sin + dy * cos
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subpgm.Program != null)
|
if (subpgm.Program != null && rotatedSubPrograms.Add(subpgm.Program))
|
||||||
subpgm.Program.Rotate(angle, origin);
|
subpgm.Program.Rotate(angle, origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,8 +143,7 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
if (code is SubProgramCall subpgm)
|
if (code is SubProgramCall subpgm)
|
||||||
{
|
{
|
||||||
subpgm.Offset = new Geometry.Vector(
|
subpgm.Offset = new Geometry.Vector(subpgm.Offset.X + x, subpgm.Offset.Y + y);
|
||||||
subpgm.Offset.X + x, subpgm.Offset.Y + y);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code is Motion == false)
|
if (code is Motion == false)
|
||||||
@@ -159,7 +171,9 @@ namespace OpenNest.CNC
|
|||||||
if (code is SubProgramCall subpgm)
|
if (code is SubProgramCall subpgm)
|
||||||
{
|
{
|
||||||
subpgm.Offset = new Geometry.Vector(
|
subpgm.Offset = new Geometry.Vector(
|
||||||
subpgm.Offset.X + voffset.X, subpgm.Offset.Y + voffset.Y);
|
subpgm.Offset.X + voffset.X,
|
||||||
|
subpgm.Offset.Y + voffset.Y
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code is Motion == false)
|
if (code is Motion == false)
|
||||||
@@ -264,7 +278,8 @@ namespace OpenNest.CNC
|
|||||||
var code = Codes[i];
|
var code = Codes[i];
|
||||||
var motion = code as Motion;
|
var motion = code as Motion;
|
||||||
|
|
||||||
if (motion == null) continue;
|
if (motion == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
return motion.EndPoint;
|
return motion.EndPoint;
|
||||||
}
|
}
|
||||||
@@ -280,7 +295,8 @@ namespace OpenNest.CNC
|
|||||||
var code = Codes[i];
|
var code = Codes[i];
|
||||||
var motion = code as Motion;
|
var motion = code as Motion;
|
||||||
|
|
||||||
if (motion == null) continue;
|
if (motion == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
pos += motion.EndPoint;
|
pos += motion.EndPoint;
|
||||||
}
|
}
|
||||||
@@ -292,22 +308,27 @@ namespace OpenNest.CNC
|
|||||||
return new Vector(0, 0);
|
return new Vector(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bounding box of the geometry the program visits. The tool's starting position is not
|
||||||
|
/// part of the geometry, so the origin only contributes when the program reaches it.
|
||||||
|
/// An empty program returns a zero-size box at the origin.
|
||||||
|
/// </summary>
|
||||||
public Box BoundingBox()
|
public Box BoundingBox()
|
||||||
{
|
{
|
||||||
var origin = new Vector(0, 0);
|
var origin = new Vector(0, 0);
|
||||||
return BoundingBox(ref origin);
|
return BoundingBox(ref origin, out var box) ? box : new Box(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Box BoundingBox(ref Vector pos)
|
private bool BoundingBox(ref Vector pos, out Box result)
|
||||||
{
|
{
|
||||||
// Capture the frame origin at entry. Sub-program Offsets and
|
// Capture the frame origin at entry. Sub-program Offsets and
|
||||||
// absolute-mode endpoints are relative to this fixed origin.
|
// absolute-mode endpoints are relative to this fixed origin.
|
||||||
var frameOrigin = pos;
|
var frameOrigin = pos;
|
||||||
|
|
||||||
double minX = 0.0;
|
var minX = double.PositiveInfinity;
|
||||||
double minY = 0.0;
|
var minY = double.PositiveInfinity;
|
||||||
double maxX = 0.0;
|
var maxX = double.NegativeInfinity;
|
||||||
double maxY = 0.0;
|
var maxY = double.NegativeInfinity;
|
||||||
|
|
||||||
for (int i = 0; i < Codes.Count; ++i)
|
for (int i = 0; i < Codes.Count; ++i)
|
||||||
{
|
{
|
||||||
@@ -318,18 +339,19 @@ namespace OpenNest.CNC
|
|||||||
case CodeType.LinearMove:
|
case CodeType.LinearMove:
|
||||||
{
|
{
|
||||||
var line = (LinearMove)code;
|
var line = (LinearMove)code;
|
||||||
var pt = Mode == Mode.Absolute ?
|
var pt =
|
||||||
frameOrigin + line.EndPoint :
|
Mode == Mode.Absolute
|
||||||
line.EndPoint + pos;
|
? frameOrigin + line.EndPoint
|
||||||
|
: line.EndPoint + pos;
|
||||||
|
|
||||||
if (pt.X > maxX)
|
if (pt.X > maxX)
|
||||||
maxX = pt.X;
|
maxX = pt.X;
|
||||||
else if (pt.X < minX)
|
if (pt.X < minX)
|
||||||
minX = pt.X;
|
minX = pt.X;
|
||||||
|
|
||||||
if (pt.Y > maxY)
|
if (pt.Y > maxY)
|
||||||
maxY = pt.Y;
|
maxY = pt.Y;
|
||||||
else if (pt.Y < minY)
|
if (pt.Y < minY)
|
||||||
minY = pt.Y;
|
minY = pt.Y;
|
||||||
|
|
||||||
pos = pt;
|
pos = pt;
|
||||||
@@ -340,18 +362,19 @@ namespace OpenNest.CNC
|
|||||||
case CodeType.RapidMove:
|
case CodeType.RapidMove:
|
||||||
{
|
{
|
||||||
var line = (RapidMove)code;
|
var line = (RapidMove)code;
|
||||||
var pt = Mode == Mode.Absolute
|
var pt =
|
||||||
|
Mode == Mode.Absolute
|
||||||
? frameOrigin + line.EndPoint
|
? frameOrigin + line.EndPoint
|
||||||
: line.EndPoint + pos;
|
: line.EndPoint + pos;
|
||||||
|
|
||||||
if (pt.X > maxX)
|
if (pt.X > maxX)
|
||||||
maxX = pt.X;
|
maxX = pt.X;
|
||||||
else if (pt.X < minX)
|
if (pt.X < minX)
|
||||||
minX = pt.X;
|
minX = pt.X;
|
||||||
|
|
||||||
if (pt.Y > maxY)
|
if (pt.Y > maxY)
|
||||||
maxY = pt.Y;
|
maxY = pt.Y;
|
||||||
else if (pt.Y < minY)
|
if (pt.Y < minY)
|
||||||
minY = pt.Y;
|
minY = pt.Y;
|
||||||
|
|
||||||
pos = pt;
|
pos = pt;
|
||||||
@@ -455,7 +478,8 @@ namespace OpenNest.CNC
|
|||||||
// Sub-program frame origin in this program's frame
|
// Sub-program frame origin in this program's frame
|
||||||
// is frameOrigin + Offset, regardless of current pos.
|
// is frameOrigin + Offset, regardless of current pos.
|
||||||
pos = frameOrigin + subpgm.Offset;
|
pos = frameOrigin + subpgm.Offset;
|
||||||
var box = subpgm.Program.BoundingBox(ref pos);
|
if (!subpgm.Program.BoundingBox(ref pos, out var box))
|
||||||
|
break;
|
||||||
|
|
||||||
if (box.Left < minX)
|
if (box.Left < minX)
|
||||||
minX = box.Left;
|
minX = box.Left;
|
||||||
@@ -474,16 +498,19 @@ namespace OpenNest.CNC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Box(minX, minY, maxX - minX, maxY - minY);
|
if (minX > maxX || minY > maxY)
|
||||||
|
{
|
||||||
|
result = new Box(0, 0, 0, 0);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = new Box(minX, minY, maxX - minX, maxY - minY);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public object Clone()
|
public object Clone()
|
||||||
{
|
{
|
||||||
var pgm = new Program()
|
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
|
||||||
{
|
|
||||||
mode = this.mode,
|
|
||||||
Rotation = this.Rotation
|
|
||||||
};
|
|
||||||
|
|
||||||
var codes = new ICode[Length];
|
var codes = new ICode[Length];
|
||||||
|
|
||||||
@@ -495,8 +522,31 @@ namespace OpenNest.CNC
|
|||||||
foreach (var kvp in Variables)
|
foreach (var kvp in Variables)
|
||||||
pgm.Variables[kvp.Key] = kvp.Value;
|
pgm.Variables[kvp.Key] = kvp.Value;
|
||||||
|
|
||||||
|
// The copy owns its sub-programs: rotating it must never turn the source's holes.
|
||||||
|
// Calls that shared one sub-program keep sharing one copy.
|
||||||
|
Dictionary<Program, Program> subCopies = null;
|
||||||
|
|
||||||
|
Program CopyOf(Program sub)
|
||||||
|
{
|
||||||
|
subCopies ??= new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
|
if (!subCopies.TryGetValue(sub, out var copy))
|
||||||
|
{
|
||||||
|
copy = (Program)sub.Clone();
|
||||||
|
subCopies[sub] = copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var kvp in SubPrograms)
|
foreach (var kvp in SubPrograms)
|
||||||
pgm.SubPrograms[kvp.Key] = (Program)kvp.Value.Clone();
|
pgm.SubPrograms[kvp.Key] = CopyOf(kvp.Value);
|
||||||
|
|
||||||
|
foreach (var code in codes)
|
||||||
|
{
|
||||||
|
if (code is SubProgramCall call && call.Program != null)
|
||||||
|
call.BindProgram(CopyOf(call.Program));
|
||||||
|
}
|
||||||
|
|
||||||
return pgm;
|
return pgm;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A character range in generated G-code assigned by one highlighting rule.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Index">Zero-based UTF-16 index of the first colored character.</param>
|
||||||
|
/// <param name="Length">Number of UTF-16 characters to color.</param>
|
||||||
|
/// <param name="RuleIndex">
|
||||||
|
/// Zero-based rule index: 0 comments, 1 motion modes (G90/G91), 2 rapid moves (G00),
|
||||||
|
/// 3 linear moves (G01), 4 arcs (G02/G03).
|
||||||
|
/// </param>
|
||||||
|
public readonly record struct HighlightSpan(int Index, int Length, int RuleIndex);
|
||||||
|
|
||||||
|
/// <summary>Computes cosmetic G-code highlight spans without changing the generated text.</summary>
|
||||||
|
public static class ProgramHighlighting
|
||||||
|
{
|
||||||
|
private static readonly Regex[] Rules =
|
||||||
|
{
|
||||||
|
new(@"^;.*$", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||||
|
new(@"^G9[01]\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||||
|
new(@"^G00\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||||
|
new(@"^G01\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||||
|
new(@"^G0[23]\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materializes all matches in rule/application order (later rules overwrite earlier ones).
|
||||||
|
/// A timeout propagates before any result is published; no partial span list escapes.
|
||||||
|
/// The timeout is per regex match, not a deadline for the complete operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="RegexMatchTimeoutException">A rule exceeded its match budget.</exception>
|
||||||
|
public static IReadOnlyList<HighlightSpan> ComputeSpans(string text)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(text);
|
||||||
|
var spans = new List<HighlightSpan>();
|
||||||
|
for (var ruleIndex = 0; ruleIndex < Rules.Length; ruleIndex++)
|
||||||
|
{
|
||||||
|
// MatchCollection is lazy: enumerate every rule before returning any spans.
|
||||||
|
foreach (Match match in Rules[ruleIndex].Matches(text))
|
||||||
|
spans.Add(new HighlightSpan(match.Index, match.Length, ruleIndex));
|
||||||
|
}
|
||||||
|
return spans.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
public List<string> EmitDeclarations()
|
public List<string> EmitDeclarations()
|
||||||
{
|
{
|
||||||
return _variables.Values
|
return _variables
|
||||||
.Where(v => v.Expression != null)
|
.Values.Where(v => v.Expression != null)
|
||||||
.OrderBy(v => v.Number)
|
.OrderBy(v => v.Number)
|
||||||
.Select(v => $"{v.Reference}={v.Expression} ({FormatComment(v.Name)})")
|
.Select(v => $"{v.Reference}={v.Expression} ({FormatComment(v.Name)})")
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
namespace OpenNest.CNC
|
||||||
{
|
{
|
||||||
@@ -7,20 +7,42 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
public readonly record struct Segment(Vector From, Vector To);
|
public readonly record struct Segment(Vector From, Vector To);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates plate rapids in cutting order, advancing through all cutting
|
||||||
|
/// motions before connecting to the next part (including scrap cutoffs).
|
||||||
|
/// </summary>
|
||||||
|
public static List<Segment> Enumerate(IEnumerable<Part> parts)
|
||||||
|
{
|
||||||
|
var results = new List<Segment>();
|
||||||
|
var pos = Vector.Zero;
|
||||||
|
|
||||||
|
foreach (var part in parts)
|
||||||
|
pos = AppendProgram(part.Program, part.Location, pos, results);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
|
public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
|
||||||
{
|
{
|
||||||
var results = new List<Segment>();
|
var results = new List<Segment>();
|
||||||
|
AppendProgram(pgm, basePos, startPos, results);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
|
||||||
|
{
|
||||||
// Draw the rapid from the previous tool position to the program's first
|
// Draw the rapid from the previous tool position to the program's first
|
||||||
// pierce point. This also primes pos so the interior walk interprets
|
// pierce point. The walk then starts at the program origin (basePos), not
|
||||||
// Incremental deltas from the correct absolute location (basePos), which
|
// the pierce: the skipped first rapid still advances pos, so starting at
|
||||||
// matters for raw pre-lead-in programs that are emitted Incremental.
|
// the pierce would apply a nonzero Incremental first delta twice (as in
|
||||||
|
// lead-in programs) and shift every later rapid by it.
|
||||||
var firstPierce = FirstPiercePoint(pgm, basePos);
|
var firstPierce = FirstPiercePoint(pgm, basePos);
|
||||||
results.Add(new Segment(startPos, firstPierce));
|
results.Add(new Segment(startPos, firstPierce));
|
||||||
|
|
||||||
var pos = firstPierce;
|
var pos = basePos;
|
||||||
Walk(pgm, basePos, ref pos, skipFirst: true, results);
|
Walk(pgm, basePos, ref pos, skipFirst: true, results);
|
||||||
return results;
|
// The last rapid ends at a pierce, not necessarily the final tool position.
|
||||||
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
|
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
|
||||||
@@ -36,7 +58,13 @@ namespace OpenNest.CNC
|
|||||||
return basePos;
|
return basePos;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Walk(Program pgm, Vector basePos, ref Vector pos, bool skipFirst, List<Segment> results)
|
private static void Walk(
|
||||||
|
Program pgm,
|
||||||
|
Vector basePos,
|
||||||
|
ref Vector pos,
|
||||||
|
bool skipFirst,
|
||||||
|
List<Segment> results
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var skipped = !skipFirst;
|
var skipped = !skipFirst;
|
||||||
|
|
||||||
@@ -60,7 +88,8 @@ namespace OpenNest.CNC
|
|||||||
}
|
}
|
||||||
else if (code is Motion motion)
|
else if (code is Motion motion)
|
||||||
{
|
{
|
||||||
var endpt = pgm.Mode == Mode.Incremental
|
var endpt =
|
||||||
|
pgm.Mode == Mode.Incremental
|
||||||
? motion.EndPoint + pos
|
? motion.EndPoint + pos
|
||||||
: motion.EndPoint + basePos;
|
: motion.EndPoint + basePos;
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ namespace OpenNest.CNC
|
|||||||
return new RapidMove(EndPoint)
|
return new RapidMove(EndPoint)
|
||||||
{
|
{
|
||||||
Suppressed = Suppressed,
|
Suppressed = Suppressed,
|
||||||
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
|
VariableRefs =
|
||||||
|
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
|
||||||
@@ -9,9 +9,7 @@ namespace OpenNest.CNC
|
|||||||
private double rotation;
|
private double rotation;
|
||||||
private Program program;
|
private Program program;
|
||||||
|
|
||||||
public SubProgramCall()
|
public SubProgramCall() { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public SubProgramCall(Program program, double rotation)
|
public SubProgramCall(Program program, double rotation)
|
||||||
{
|
{
|
||||||
@@ -81,12 +79,28 @@ namespace OpenNest.CNC
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a shallow copy.
|
/// Gets a shallow copy that references the same program. Copies the fields
|
||||||
|
/// directly: going through the setters would re-align (rotate) the shared program.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public ICode Clone()
|
public ICode Clone()
|
||||||
{
|
{
|
||||||
return new SubProgramCall(program, Rotation) { Id = Id, Offset = Offset };
|
return new SubProgramCall
|
||||||
|
{
|
||||||
|
program = program,
|
||||||
|
rotation = rotation,
|
||||||
|
Id = Id,
|
||||||
|
Offset = Offset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Points the call at <paramref name="copy"/>, a copy of its current program, without
|
||||||
|
/// re-aligning its rotation: the copy already has the geometry the call executes.
|
||||||
|
/// </summary>
|
||||||
|
internal void BindProgram(Program copy)
|
||||||
|
{
|
||||||
|
program = copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ namespace OpenNest.CNC
|
|||||||
public bool Inline { get; }
|
public bool Inline { get; }
|
||||||
public bool Global { get; }
|
public bool Global { get; }
|
||||||
|
|
||||||
public VariableDefinition(string name, string expression, double value,
|
public VariableDefinition(
|
||||||
bool inline = false, bool global = false)
|
string name,
|
||||||
|
string expression,
|
||||||
|
double value,
|
||||||
|
bool inline = false,
|
||||||
|
bool global = false
|
||||||
|
)
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
Expression = expression;
|
Expression = expression;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
using System.Linq;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace OpenNest
|
namespace OpenNest
|
||||||
{
|
{
|
||||||
@@ -14,6 +14,12 @@ namespace OpenNest
|
|||||||
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
|
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
|
||||||
public const double SnapToZero = 0.001;
|
public const double SnapToZero = 0.001;
|
||||||
|
|
||||||
|
/// <summary>Centroid offsets below this fraction of the MBR extent count as symmetric.</summary>
|
||||||
|
private const double SymmetryTolerance = 1e-6;
|
||||||
|
|
||||||
|
/// <summary>Angular margin (radians) keeping axis-aligned centroid offsets off the edge of the preferred quadrant.</summary>
|
||||||
|
private const double PreferenceMargin = 0.001;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which
|
/// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which
|
||||||
/// computes the MBR itself) and by PartClassifier (which already has one). Single formula
|
/// computes the MBR itself) and by PartClassifier (which already has one). Single formula
|
||||||
@@ -44,8 +50,9 @@ namespace OpenNest
|
|||||||
if (drawing?.Program == null)
|
if (drawing?.Program == null)
|
||||||
return 0.0;
|
return 0.0;
|
||||||
|
|
||||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
var entities = ConvertProgram
|
||||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
.ToGeometry(drawing.Program)
|
||||||
|
.Where(e => SpecialLayers.IsMaterial(e.Layer));
|
||||||
|
|
||||||
var shapes = ShapeBuilder.GetShapes(entities);
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
if (shapes.Count == 0)
|
if (shapes.Count == 0)
|
||||||
@@ -72,7 +79,89 @@ namespace OpenNest
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
|
|
||||||
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
|
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
|
||||||
return FromMbr(mbr);
|
var angle = FromMbr(mbr);
|
||||||
|
if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon)
|
||||||
|
return angle;
|
||||||
|
|
||||||
|
var quarterTurns = PreferredQuarterTurns(polygon, hull, angle);
|
||||||
|
if (quarterTurns == 0)
|
||||||
|
return angle;
|
||||||
|
|
||||||
|
return NormalizeSigned(angle + quarterTurns * System.Math.PI / 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The MBR only fixes the frame modulo 90°, leaving four equivalent orientations. Nest
|
||||||
|
/// results are not 90°-symmetric, so pick one deterministically: the quarter-turn count
|
||||||
|
/// that puts the perimeter's centroid toward the lower-left of its MBR. Shapes with no
|
||||||
|
/// centroid offset (rectangles, circles) are symmetric and keep the MBR orientation.
|
||||||
|
/// </summary>
|
||||||
|
private static int PreferredQuarterTurns(Polygon polygon, Polygon hull, double angle)
|
||||||
|
{
|
||||||
|
var minX = double.MaxValue;
|
||||||
|
var minY = double.MaxValue;
|
||||||
|
var maxX = double.MinValue;
|
||||||
|
var maxY = double.MinValue;
|
||||||
|
foreach (var vertex in hull.Vertices)
|
||||||
|
{
|
||||||
|
var rotated = vertex.Rotate(angle);
|
||||||
|
minX = System.Math.Min(minX, rotated.X);
|
||||||
|
minY = System.Math.Min(minY, rotated.Y);
|
||||||
|
maxX = System.Math.Max(maxX, rotated.X);
|
||||||
|
maxY = System.Math.Max(maxY, rotated.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
var centroid = Centroid(polygon).Rotate(angle);
|
||||||
|
var dx = centroid.X - (minX + maxX) / 2.0;
|
||||||
|
var dy = centroid.Y - (minY + maxY) / 2.0;
|
||||||
|
|
||||||
|
var extent = System.Math.Max(maxX - minX, maxY - minY);
|
||||||
|
if (System.Math.Sqrt(dx * dx + dy * dy) <= SymmetryTolerance * extent)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// Choose k so the offset direction lands in [PI - margin, 3PI/2 - margin). The margin
|
||||||
|
// keeps offsets lying exactly on an axis (mirror-symmetric parts) away from the
|
||||||
|
// interval edge so floating-point noise cannot flip the choice.
|
||||||
|
var halfPi = System.Math.PI / 2.0;
|
||||||
|
var direction = System.Math.Atan2(dy, dx);
|
||||||
|
for (var turns = 0; turns < 4; turns++)
|
||||||
|
{
|
||||||
|
var relative = direction + turns * halfPi - (System.Math.PI - PreferenceMargin);
|
||||||
|
relative -= 2.0 * System.Math.PI * System.Math.Floor(relative / (2.0 * System.Math.PI));
|
||||||
|
if (relative < halfPi)
|
||||||
|
return turns;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector Centroid(Polygon polygon)
|
||||||
|
{
|
||||||
|
var vertices = polygon.Vertices;
|
||||||
|
var doubleArea = 0.0;
|
||||||
|
var cx = 0.0;
|
||||||
|
var cy = 0.0;
|
||||||
|
for (var i = 0; i < vertices.Count; i++)
|
||||||
|
{
|
||||||
|
var p = vertices[i];
|
||||||
|
var q = vertices[(i + 1) % vertices.Count];
|
||||||
|
var cross = p.X * q.Y - q.X * p.Y;
|
||||||
|
doubleArea += cross;
|
||||||
|
cx += (p.X + q.X) * cross;
|
||||||
|
cy += (p.Y + q.Y) * cross;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (System.Math.Abs(doubleArea) <= OpenNest.Math.Tolerance.Epsilon)
|
||||||
|
return new Vector(vertices.Average(v => v.X), vertices.Average(v => v.Y));
|
||||||
|
|
||||||
|
return new Vector(cx / (3.0 * doubleArea), cy / (3.0 * doubleArea));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double NormalizeSigned(double angle)
|
||||||
|
{
|
||||||
|
var twoPi = 2.0 * System.Math.PI;
|
||||||
|
angle -= twoPi * System.Math.Floor((angle + System.Math.PI) / twoPi);
|
||||||
|
return angle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,5 @@
|
|||||||
|
|
||||||
namespace OpenNest.Collections
|
namespace OpenNest.Collections
|
||||||
{
|
{
|
||||||
public class DrawingCollection : HashSet<Drawing>
|
public class DrawingCollection : HashSet<Drawing> { }
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Converters
|
namespace OpenNest.Converters
|
||||||
{
|
{
|
||||||
@@ -10,7 +10,7 @@ namespace OpenNest.Converters
|
|||||||
Perimeter,
|
Perimeter,
|
||||||
Hole,
|
Hole,
|
||||||
Etch,
|
Etch,
|
||||||
Open
|
Open,
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ContourInfo
|
public sealed class ContourInfo
|
||||||
@@ -91,7 +91,8 @@ namespace OpenNest.Converters
|
|||||||
// Non-perimeter shapes first (matches CNC cut order: holes before perimeter)
|
// Non-perimeter shapes first (matches CNC cut order: holes before perimeter)
|
||||||
for (var i = 0; i < shapes.Count; i++)
|
for (var i = 0; i < shapes.Count; i++)
|
||||||
{
|
{
|
||||||
if (i == perimeterIndex) continue;
|
if (i == perimeterIndex)
|
||||||
|
continue;
|
||||||
var shape = shapes[i];
|
var shape = shapes[i];
|
||||||
var type = ClassifyShape(shape);
|
var type = ClassifyShape(shape);
|
||||||
|
|
||||||
@@ -116,7 +117,13 @@ namespace OpenNest.Converters
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Perimeter last
|
// Perimeter last
|
||||||
result.Add(new ContourInfo(shapes[perimeterIndex], ContourClassification.Perimeter, "Perimeter"));
|
result.Add(
|
||||||
|
new ContourInfo(
|
||||||
|
shapes[perimeterIndex],
|
||||||
|
ContourClassification.Perimeter,
|
||||||
|
"Perimeter"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -124,8 +131,12 @@ namespace OpenNest.Converters
|
|||||||
private static ContourClassification ClassifyShape(Shape shape)
|
private static ContourClassification ClassifyShape(Shape shape)
|
||||||
{
|
{
|
||||||
// Check etch layer — all entities must be on ETCH layer
|
// Check etch layer — all entities must be on ETCH layer
|
||||||
if (shape.Entities.Count > 0 &&
|
if (
|
||||||
shape.Entities.All(e => string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)))
|
shape.Entities.Count > 0
|
||||||
|
&& shape.Entities.All(e =>
|
||||||
|
string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|
||||||
|
)
|
||||||
|
)
|
||||||
return ContourClassification.Etch;
|
return ContourClassification.Etch;
|
||||||
|
|
||||||
if (shape.IsClosed())
|
if (shape.IsClosed())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using OpenNest.CNC;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace OpenNest.Converters
|
namespace OpenNest.Converters
|
||||||
{
|
{
|
||||||
@@ -87,14 +87,24 @@ namespace OpenNest.Converters
|
|||||||
|
|
||||||
lastpt = endpt;
|
lastpt = endpt;
|
||||||
|
|
||||||
|
var layer = ClassifyLayer(arc);
|
||||||
var sweep = System.Math.Abs(arc.SweepAngle());
|
var sweep = System.Math.Abs(arc.SweepAngle());
|
||||||
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
||||||
{
|
{
|
||||||
pgm.LineTo(endpt);
|
pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
|
pgm.Codes.Add(
|
||||||
|
new ArcMove(
|
||||||
|
endpt,
|
||||||
|
arc.Center,
|
||||||
|
arc.IsReversed ? RotationType.CW : RotationType.CCW
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Layer = layer,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lastpt;
|
return lastpt;
|
||||||
@@ -107,7 +117,12 @@ namespace OpenNest.Converters
|
|||||||
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(startpt);
|
pgm.MoveTo(startpt);
|
||||||
|
|
||||||
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
pgm.Codes.Add(
|
||||||
|
new ArcMove(startpt, circle.Center, circle.Rotation)
|
||||||
|
{
|
||||||
|
Layer = ClassifyLayer(circle),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
lastpt = startpt;
|
lastpt = startpt;
|
||||||
return lastpt;
|
return lastpt;
|
||||||
@@ -118,13 +133,27 @@ namespace OpenNest.Converters
|
|||||||
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(line.StartPoint);
|
pgm.MoveTo(line.StartPoint);
|
||||||
|
|
||||||
var move = new LinearMove(line.EndPoint);
|
pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
|
||||||
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
|
||||||
move.Layer = LayerType.Scribe;
|
|
||||||
pgm.Codes.Add(move);
|
|
||||||
|
|
||||||
lastpt = line.EndPoint;
|
lastpt = line.EndPoint;
|
||||||
return lastpt;
|
return lastpt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Engrave/etch/scribe geometry maps to Scribe so the post processor can treat it as a
|
||||||
|
// separate tool pass; everything else keeps the move's default Cut layer. SCRIBE is the
|
||||||
|
// name marks carry once saved (SpecialLayers.Scribe), so drawings rebuilt from stored
|
||||||
|
// entities must map it too or their marks silently become cut moves.
|
||||||
|
private static LayerType ClassifyLayer(Entity geo)
|
||||||
|
{
|
||||||
|
var name = geo.Layer?.Name;
|
||||||
|
if (
|
||||||
|
string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(name, SpecialLayers.Scribe.Name, System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
)
|
||||||
|
return LayerType.Scribe;
|
||||||
|
|
||||||
|
return LayerType.Cut;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using OpenNest.CNC;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace OpenNest.Converters
|
namespace OpenNest.Converters
|
||||||
{
|
{
|
||||||
@@ -18,7 +18,12 @@ namespace OpenNest.Converters
|
|||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddProgram(Program program, ref Mode mode, ref Vector curpos, ref List<Entity> geometry)
|
private static void AddProgram(
|
||||||
|
Program program,
|
||||||
|
ref Mode mode,
|
||||||
|
ref Vector curpos,
|
||||||
|
ref List<Entity> geometry
|
||||||
|
)
|
||||||
{
|
{
|
||||||
// Capture the frame origin at entry. Sub-program Offsets are relative
|
// Capture the frame origin at entry. Sub-program Offsets are relative
|
||||||
// to this fixed origin, not to the current tool position.
|
// to this fixed origin, not to the current tool position.
|
||||||
@@ -49,7 +54,10 @@ namespace OpenNest.Converters
|
|||||||
|
|
||||||
// The sub-program's frame origin in this program's frame is
|
// The sub-program's frame origin in this program's frame is
|
||||||
// frameOrigin + Offset — independent of current tool position.
|
// frameOrigin + Offset — independent of current tool position.
|
||||||
curpos = new Vector(frameOrigin.X + subpgm.Offset.X, frameOrigin.Y + subpgm.Offset.Y);
|
curpos = new Vector(
|
||||||
|
frameOrigin.X + subpgm.Offset.X,
|
||||||
|
frameOrigin.Y + subpgm.Offset.Y
|
||||||
|
);
|
||||||
|
|
||||||
AddProgram(subpgm.Program, ref mode, ref curpos, ref geometry);
|
AddProgram(subpgm.Program, ref mode, ref curpos, ref geometry);
|
||||||
mode = savedMode;
|
mode = savedMode;
|
||||||
@@ -58,7 +66,12 @@ namespace OpenNest.Converters
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddLinearMove(LinearMove linearMove, ref Mode mode, ref Vector curpos, ref List<Entity> geometry)
|
private static void AddLinearMove(
|
||||||
|
LinearMove linearMove,
|
||||||
|
ref Mode mode,
|
||||||
|
ref Vector curpos,
|
||||||
|
ref List<Entity> geometry
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var pt = linearMove.EndPoint;
|
var pt = linearMove.EndPoint;
|
||||||
|
|
||||||
@@ -66,16 +79,17 @@ namespace OpenNest.Converters
|
|||||||
pt += curpos;
|
pt += curpos;
|
||||||
|
|
||||||
var layer = ConvertLayer(linearMove.Layer);
|
var layer = ConvertLayer(linearMove.Layer);
|
||||||
var line = new Line(curpos, pt)
|
var line = new Line(curpos, pt) { Layer = layer, Color = layer.Color };
|
||||||
{
|
|
||||||
Layer = layer,
|
|
||||||
Color = layer.Color
|
|
||||||
};
|
|
||||||
geometry.Add(line);
|
geometry.Add(line);
|
||||||
curpos = pt;
|
curpos = pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddRapidMove(RapidMove rapidMove, ref Mode mode, ref Vector curpos, ref List<Entity> geometry)
|
private static void AddRapidMove(
|
||||||
|
RapidMove rapidMove,
|
||||||
|
ref Mode mode,
|
||||||
|
ref Vector curpos,
|
||||||
|
ref List<Entity> geometry
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var pt = rapidMove.EndPoint;
|
var pt = rapidMove.EndPoint;
|
||||||
|
|
||||||
@@ -85,13 +99,18 @@ namespace OpenNest.Converters
|
|||||||
var line = new Line(curpos, pt)
|
var line = new Line(curpos, pt)
|
||||||
{
|
{
|
||||||
Layer = SpecialLayers.Rapid,
|
Layer = SpecialLayers.Rapid,
|
||||||
Color = SpecialLayers.Rapid.Color
|
Color = SpecialLayers.Rapid.Color,
|
||||||
};
|
};
|
||||||
geometry.Add(line);
|
geometry.Add(line);
|
||||||
curpos = pt;
|
curpos = pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddArcMove(ArcMove arcMove, ref Mode mode, ref Vector curpos, ref List<Entity> geometry)
|
private static void AddArcMove(
|
||||||
|
ArcMove arcMove,
|
||||||
|
ref Mode mode,
|
||||||
|
ref Vector curpos,
|
||||||
|
ref List<Entity> geometry
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var center = arcMove.CenterPoint;
|
var center = arcMove.CenterPoint;
|
||||||
var endpt = arcMove.EndPoint;
|
var endpt = arcMove.EndPoint;
|
||||||
@@ -102,6 +121,8 @@ namespace OpenNest.Converters
|
|||||||
center += curpos;
|
center += curpos;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
center = FitCenterToEndpoints(center, curpos, endpt);
|
||||||
|
|
||||||
var startAngle = center.AngleTo(curpos);
|
var startAngle = center.AngleTo(curpos);
|
||||||
var endAngle = center.AngleTo(endpt);
|
var endAngle = center.AngleTo(endpt);
|
||||||
|
|
||||||
@@ -112,13 +133,61 @@ namespace OpenNest.Converters
|
|||||||
var layer = ConvertLayer(arcMove.Layer);
|
var layer = ConvertLayer(arcMove.Layer);
|
||||||
|
|
||||||
if (startAngle.IsEqualTo(endAngle))
|
if (startAngle.IsEqualTo(endAngle))
|
||||||
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color, Rotation = arcMove.Rotation });
|
geometry.Add(
|
||||||
|
new Circle(center, radius)
|
||||||
|
{
|
||||||
|
Layer = layer,
|
||||||
|
Color = layer.Color,
|
||||||
|
Rotation = arcMove.Rotation,
|
||||||
|
}
|
||||||
|
);
|
||||||
else
|
else
|
||||||
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color });
|
geometry.Add(
|
||||||
|
new Arc(
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
startAngle,
|
||||||
|
endAngle,
|
||||||
|
arcMove.Rotation == RotationType.CW
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Layer = layer,
|
||||||
|
Color = layer.Color,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
curpos = endpt;
|
curpos = endpt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Programs can carry arc centers that are not quite equidistant from the
|
||||||
|
/// start and end points (e.g. I0.03 on a 0.0598 chord). Building the arc from
|
||||||
|
/// the end radius alone then leaves its start point off the previous move's
|
||||||
|
/// end, which breaks contour chaining. Project the center onto the chord's
|
||||||
|
/// perpendicular bisector so the arc passes through both endpoints exactly.
|
||||||
|
/// </summary>
|
||||||
|
private static Vector FitCenterToEndpoints(Vector center, Vector start, Vector end)
|
||||||
|
{
|
||||||
|
var startRadius = center.DistanceTo(start);
|
||||||
|
var endRadius = center.DistanceTo(end);
|
||||||
|
|
||||||
|
if (startRadius.IsEqualTo(endRadius))
|
||||||
|
return center;
|
||||||
|
|
||||||
|
var chord = end - start;
|
||||||
|
var chordLengthSq = chord.X * chord.X + chord.Y * chord.Y;
|
||||||
|
|
||||||
|
// Full circle (start == end): no chord to fit against.
|
||||||
|
if (chordLengthSq < Tolerance.Epsilon * Tolerance.Epsilon)
|
||||||
|
return center;
|
||||||
|
|
||||||
|
var mid = new Vector((start.X + end.X) * 0.5, (start.Y + end.Y) * 0.5);
|
||||||
|
var normal = new Vector(-chord.Y, chord.X);
|
||||||
|
var t = ((center.X - mid.X) * normal.X + (center.Y - mid.Y) * normal.Y) / chordLengthSq;
|
||||||
|
|
||||||
|
return new Vector(mid.X + normal.X * t, mid.Y + normal.Y * t);
|
||||||
|
}
|
||||||
|
|
||||||
private static Layer ConvertLayer(LayerType layer)
|
private static Layer ConvertLayer(LayerType layer)
|
||||||
{
|
{
|
||||||
switch (layer)
|
switch (layer)
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Converters
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Restores the Scribe layer on program moves that were saved as cuts. Programs built from
|
||||||
|
/// stored entities before ConvertGeometry recognized the SCRIBE layer name turned etch marks
|
||||||
|
/// into Cut moves, which nesting then treated as open cut geometry. The drawing's source
|
||||||
|
/// entities still carry the mark layer, so matching moves are reclassified from them.
|
||||||
|
/// </summary>
|
||||||
|
public static class ScribeLayerRepair
|
||||||
|
{
|
||||||
|
private const double MatchTolerance = 0.001;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reclassifies Cut moves in <paramref name="program"/> that lie on a mark entity in
|
||||||
|
/// <paramref name="sourceEntities"/>. Program coordinates are source coordinates shifted
|
||||||
|
/// by -<paramref name="sourceOffset"/>. Returns the number of moves reclassified.
|
||||||
|
/// </summary>
|
||||||
|
public static int Apply(Program program, IEnumerable<Entity> sourceEntities, Vector sourceOffset)
|
||||||
|
{
|
||||||
|
if (program == null || sourceEntities == null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var marks = sourceEntities.Where(e => IsMarkLayer(e.Layer)).ToList();
|
||||||
|
if (marks.Count == 0 || program.Codes.Any(c => c is SubProgramCall))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// ToGeometry emits exactly one entity per rapid/linear/arc move of a flat program.
|
||||||
|
var motions = program.Codes.Where(c => c is RapidMove or LinearMove or ArcMove).ToList();
|
||||||
|
var geometry = ConvertProgram.ToGeometry(program);
|
||||||
|
if (geometry.Count != motions.Count)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var repaired = 0;
|
||||||
|
for (var i = 0; i < motions.Count; i++)
|
||||||
|
{
|
||||||
|
var source = Translate(geometry[i], sourceOffset);
|
||||||
|
switch (motions[i])
|
||||||
|
{
|
||||||
|
case LinearMove line when line.Layer == LayerType.Cut && IsOnMark(source, marks):
|
||||||
|
line.Layer = LayerType.Scribe;
|
||||||
|
repaired++;
|
||||||
|
break;
|
||||||
|
case ArcMove arc when arc.Layer == LayerType.Cut && IsOnMark(source, marks):
|
||||||
|
arc.Layer = LayerType.Scribe;
|
||||||
|
repaired++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repaired;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsMarkLayer(Layer layer) =>
|
||||||
|
layer != null
|
||||||
|
&& (
|
||||||
|
layer == SpecialLayers.Scribe
|
||||||
|
|| string.Equals(layer.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(layer.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(layer.Name, "ENGRAVE", StringComparison.OrdinalIgnoreCase)
|
||||||
|
);
|
||||||
|
|
||||||
|
private static List<Vector> Translate(Entity entity, Vector offset)
|
||||||
|
{
|
||||||
|
var points = entity switch
|
||||||
|
{
|
||||||
|
Line l => new List<Vector>
|
||||||
|
{
|
||||||
|
l.StartPoint,
|
||||||
|
l.EndPoint,
|
||||||
|
new Vector((l.StartPoint.X + l.EndPoint.X) / 2, (l.StartPoint.Y + l.EndPoint.Y) / 2),
|
||||||
|
},
|
||||||
|
Arc a => new List<Vector> { a.StartPoint(), a.EndPoint(), a.MidPoint() },
|
||||||
|
Circle c => new List<Vector>
|
||||||
|
{
|
||||||
|
new Vector(c.Center.X + c.Radius, c.Center.Y),
|
||||||
|
new Vector(c.Center.X - c.Radius, c.Center.Y),
|
||||||
|
new Vector(c.Center.X, c.Center.Y + c.Radius),
|
||||||
|
},
|
||||||
|
_ => new List<Vector>(),
|
||||||
|
};
|
||||||
|
return points.ConvertAll(p => new Vector(p.X + offset.X, p.Y + offset.Y));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A move is a mark when every sample point lies on one single mark entity.
|
||||||
|
private static bool IsOnMark(List<Vector> points, List<Entity> marks) =>
|
||||||
|
points.Count > 0
|
||||||
|
&& marks.Any(m => points.All(p => m.ClosestPointTo(p).DistanceTo(p) <= MatchTolerance));
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
-27
@@ -1,18 +1,20 @@
|
|||||||
using OpenNest.CNC;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest
|
namespace OpenNest
|
||||||
{
|
{
|
||||||
public enum CutOffAxis
|
public enum CutOffAxis
|
||||||
{
|
{
|
||||||
Horizontal,
|
Horizontal,
|
||||||
Vertical
|
Vertical,
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CutOff
|
public class CutOff
|
||||||
{
|
{
|
||||||
|
private const double OffsetTolerance = 0.001;
|
||||||
|
|
||||||
public Vector Position { get; set; }
|
public Vector Position { get; set; }
|
||||||
public CutOffAxis Axis { get; set; }
|
public CutOffAxis Axis { get; set; }
|
||||||
public double? StartLimit { get; set; }
|
public double? StartLimit { get; set; }
|
||||||
@@ -26,7 +28,11 @@ namespace OpenNest
|
|||||||
Drawing = new Drawing(GetName()) { IsCutOff = true };
|
Drawing = new Drawing(GetName()) { IsCutOff = true };
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Regenerate(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache = null)
|
public void Regenerate(
|
||||||
|
Plate plate,
|
||||||
|
CutOffSettings settings,
|
||||||
|
Dictionary<Part, Entity> cache = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var segments = ComputeSegments(plate, settings, cache);
|
var segments = ComputeSegments(plate, settings, cache);
|
||||||
var program = BuildProgram(segments, settings);
|
var program = BuildProgram(segments, settings);
|
||||||
@@ -40,11 +46,17 @@ namespace OpenNest
|
|||||||
return $"CutOff-{axisChar}-{coord:F2}";
|
return $"CutOff-{axisChar}-{coord:F2}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<(double Start, double End)> ComputeSegments(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache)
|
private List<(double Start, double End)> ComputeSegments(
|
||||||
|
Plate plate,
|
||||||
|
CutOffSettings settings,
|
||||||
|
Dictionary<Part, Entity> cache
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var bounds = plate.BoundingBox(includeParts: false);
|
var bounds = plate.BoundingBox(includeParts: false);
|
||||||
|
|
||||||
double lineStart, lineEnd, cutPosition;
|
double lineStart,
|
||||||
|
lineEnd,
|
||||||
|
cutPosition;
|
||||||
|
|
||||||
if (Axis == CutOffAxis.Vertical)
|
if (Axis == CutOffAxis.Vertical)
|
||||||
{
|
{
|
||||||
@@ -68,7 +80,14 @@ namespace OpenNest
|
|||||||
|
|
||||||
Entity perimeter = null;
|
Entity perimeter = null;
|
||||||
cache?.TryGetValue(part, out perimeter);
|
cache?.TryGetValue(part, out perimeter);
|
||||||
var partExclusions = GetPartExclusions(part, perimeter, cutPosition, lineStart, lineEnd, settings.PartClearance);
|
var partExclusions = GetPartExclusions(
|
||||||
|
part,
|
||||||
|
perimeter,
|
||||||
|
cutPosition,
|
||||||
|
lineStart,
|
||||||
|
lineEnd,
|
||||||
|
settings.PartClearance
|
||||||
|
);
|
||||||
exclusions.AddRange(partExclusions);
|
exclusions.AddRange(partExclusions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,18 +126,37 @@ namespace OpenNest
|
|||||||
private static readonly List<(double Start, double End)> EmptyExclusions = new();
|
private static readonly List<(double Start, double End)> EmptyExclusions = new();
|
||||||
|
|
||||||
private List<(double Start, double End)> GetPartExclusions(
|
private List<(double Start, double End)> GetPartExclusions(
|
||||||
Part part, Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
|
Part part,
|
||||||
|
Entity perimeter,
|
||||||
|
double cutPosition,
|
||||||
|
double lineStart,
|
||||||
|
double lineEnd,
|
||||||
|
double clearance
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var bb = part.BoundingBox;
|
var bb = part.BoundingBox;
|
||||||
|
var roundoff = GetBoundsRoundoff(part);
|
||||||
var (partMin, partMax) = AxisBounds(bb, clearance);
|
var (partMin, partMax) = AxisBounds(bb, clearance);
|
||||||
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
||||||
|
// Match the planner's representational allowance in BOTH pruning and
|
||||||
|
// fallback exclusions. Tolerating it only in validation could cut an edge.
|
||||||
|
partMin -= roundoff;
|
||||||
|
partMax += roundoff;
|
||||||
|
partStart -= roundoff;
|
||||||
|
partEnd += roundoff;
|
||||||
|
|
||||||
if (cutPosition < partMin || cutPosition > partMax)
|
if (cutPosition < partMin || cutPosition > partMax)
|
||||||
return EmptyExclusions;
|
return EmptyExclusions;
|
||||||
|
|
||||||
if (perimeter != null)
|
if (perimeter != null)
|
||||||
{
|
{
|
||||||
var perimeterExclusions = IntersectPerimeter(perimeter, cutPosition, lineStart, lineEnd, clearance);
|
var perimeterExclusions = IntersectPerimeter(
|
||||||
|
perimeter,
|
||||||
|
cutPosition,
|
||||||
|
lineStart,
|
||||||
|
lineEnd,
|
||||||
|
clearance
|
||||||
|
);
|
||||||
if (perimeterExclusions != null)
|
if (perimeterExclusions != null)
|
||||||
return perimeterExclusions;
|
return perimeterExclusions;
|
||||||
}
|
}
|
||||||
@@ -126,24 +164,68 @@ namespace OpenNest
|
|||||||
return new List<(double Start, double End)> { (partStart, partEnd) };
|
return new List<(double Start, double End)> { (partStart, partEnd) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<(double Start, double End)> IntersectPerimeter(
|
/// <summary>
|
||||||
Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
|
/// Bounds reconstructed as (local minimum + placement) + size can differ
|
||||||
|
/// from translated material endpoints by a few floating-point steps. This
|
||||||
|
/// is not a geometry tolerance: cap it well below epsilon so inconsistent
|
||||||
|
/// arcs still require repair. Do not change shared Part bounds or programs.
|
||||||
|
/// </summary>
|
||||||
|
internal static double GetBoundsRoundoff(Part part)
|
||||||
{
|
{
|
||||||
var target = OffsetOutward(perimeter, clearance) ?? perimeter;
|
var bb = part.BoundingBox;
|
||||||
var usedOffset = target != perimeter;
|
var magnitude = System.Math.Max(System.Math.Abs(bb.Left), System.Math.Abs(bb.Right));
|
||||||
var cutLine = new Line(MakePoint(cutPosition, lineStart), MakePoint(cutPosition, lineEnd));
|
magnitude = System.Math.Max(magnitude,
|
||||||
|
System.Math.Max(System.Math.Abs(bb.Bottom), System.Math.Abs(bb.Top)));
|
||||||
|
magnitude = System.Math.Max(magnitude, System.Math.Max(bb.Length, bb.Width));
|
||||||
|
magnitude = System.Math.Max(magnitude,
|
||||||
|
System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y)));
|
||||||
|
var step = System.Math.BitIncrement(magnitude) - magnitude;
|
||||||
|
return double.IsFinite(step) ? System.Math.Min(8 * step, Math.Tolerance.Epsilon / 4) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (!target.Intersects(cutLine, out var pts) || pts.Count < 2)
|
private List<(double Start, double End)> IntersectPerimeter(
|
||||||
|
Entity perimeter,
|
||||||
|
double cutPosition,
|
||||||
|
double lineStart,
|
||||||
|
double lineEnd,
|
||||||
|
double clearance
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var offset = OffsetOutward(perimeter, clearance);
|
||||||
|
var usedOffset = offset != null;
|
||||||
|
var targets = offset ?? new List<Entity> { perimeter };
|
||||||
|
var cutLine = new Line(
|
||||||
|
MakePoint(cutPosition, lineStart),
|
||||||
|
MakePoint(cutPosition, lineEnd)
|
||||||
|
);
|
||||||
|
|
||||||
|
var pts = new List<Vector>();
|
||||||
|
|
||||||
|
foreach (var target in targets)
|
||||||
|
{
|
||||||
|
if (target.Intersects(cutLine, out var targetPts))
|
||||||
|
pts.AddRange(targetPts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pts.Count < 2)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var coords = pts
|
var coords = pts.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
|
||||||
.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
|
|
||||||
.OrderBy(c => c)
|
.OrderBy(c => c)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (coords.Count % 2 != 0)
|
if (coords.Count % 2 != 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
// Intersects reports both incident edges at a shared vertex. Such hits may be
|
||||||
|
// crossings or tangencies, so neither pairing nor deduplicating them preserves
|
||||||
|
// inside/outside parity. Fall back to the clearance-expanded part bounds.
|
||||||
|
for (var i = 1; i < coords.Count; i++)
|
||||||
|
{
|
||||||
|
if (coords[i] - coords[i - 1] <= Math.Tolerance.Epsilon)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var padding = usedOffset ? 0 : clearance;
|
var padding = usedOffset ? 0 : clearance;
|
||||||
var result = new List<(double Start, double End)>();
|
var result = new List<(double Start, double End)>();
|
||||||
for (var i = 0; i < coords.Count; i += 2)
|
for (var i = 0; i < coords.Count; i += 2)
|
||||||
@@ -152,21 +234,37 @@ namespace OpenNest
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Entity OffsetOutward(Entity perimeter, double clearance)
|
/// <summary>
|
||||||
|
/// Grows the perimeter by the clearance as one Clipper region offset, so slots
|
||||||
|
/// narrower than twice the clearance close up instead of leaving a gap the cut
|
||||||
|
/// could run into. Holes appear only where the perimeter curls back on itself.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Entity> OffsetOutward(Entity perimeter, double clearance)
|
||||||
{
|
{
|
||||||
if (clearance <= 0)
|
if (clearance <= 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
try
|
var offset = perimeter switch
|
||||||
{
|
|
||||||
var offset = perimeter.OffsetEntity(clearance, OffsetSide.Left);
|
|
||||||
offset?.UpdateBounds();
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
{
|
||||||
|
Shape shape => ClipperBridge.OffsetPerimeter(
|
||||||
|
shape,
|
||||||
|
clearance,
|
||||||
|
OffsetTolerance,
|
||||||
|
circumscribe: true
|
||||||
|
),
|
||||||
|
Polygon polygon => ClipperBridge.OffsetPerimeter(
|
||||||
|
polygon,
|
||||||
|
clearance,
|
||||||
|
OffsetTolerance,
|
||||||
|
circumscribe: true
|
||||||
|
),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (offset == null || offset.Outers.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
return offset.Outers.Concat(offset.Holes).Cast<Entity>().ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Vector MakePoint(double cutCoord, double lineCoord) =>
|
private Vector MakePoint(double cutCoord, double lineCoord) =>
|
||||||
@@ -184,7 +282,10 @@ namespace OpenNest
|
|||||||
? (bb.Y - clearance, bb.Y + bb.Width + clearance)
|
? (bb.Y - clearance, bb.Y + bb.Width + clearance)
|
||||||
: (bb.X - clearance, bb.X + bb.Length + clearance);
|
: (bb.X - clearance, bb.X + bb.Length + clearance);
|
||||||
|
|
||||||
private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings)
|
private Program BuildProgram(
|
||||||
|
List<(double Start, double End)> segments,
|
||||||
|
CutOffSettings settings
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var program = new Program();
|
var program = new Program();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace OpenNest
|
|||||||
public enum CutDirection
|
public enum CutDirection
|
||||||
{
|
{
|
||||||
TowardOrigin,
|
TowardOrigin,
|
||||||
AwayFromOrigin
|
AwayFromOrigin,
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CutOffSettings
|
public class CutOffSettings
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ public class CutParameters
|
|||||||
public string PostProcessor { get; set; }
|
public string PostProcessor { get; set; }
|
||||||
public Units Units { get; set; }
|
public Units Units { get; set; }
|
||||||
|
|
||||||
public static CutParameters Default => new()
|
public static CutParameters Default =>
|
||||||
|
new()
|
||||||
{
|
{
|
||||||
Feedrate = 100,
|
Feedrate = 100,
|
||||||
RapidTravelRate = 300,
|
RapidTravelRate = 300,
|
||||||
PierceTime = TimeSpan.FromSeconds(0.5),
|
PierceTime = TimeSpan.FromSeconds(0.5),
|
||||||
Units = OpenNest.Units.Inches
|
Units = OpenNest.Units.Inches,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
public enum OverlapAutoCheckStep { None, Wait, Check }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UI-thread debounce policy for automatic overlap rechecks; the host owns the timer.
|
||||||
|
/// Restart the quiet-period timer whenever <see cref="Observe"/> returns true, and call
|
||||||
|
/// <see cref="Elapsed"/> when it fires. A check starts only after the ordered layout stamp
|
||||||
|
/// has stayed unchanged for a whole quiet period with no interaction in progress.
|
||||||
|
/// A canceled or failed request is not retried until the layout differs from the one it
|
||||||
|
/// started from, so cancellation is respected and a failing layout cannot loop.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OverlapAutoCheckScheduler
|
||||||
|
{
|
||||||
|
private OverlapGeometryStamp pending;
|
||||||
|
private OverlapGeometryStamp lastRequest;
|
||||||
|
|
||||||
|
public bool IsWaiting => pending != null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call after the report state's freshness check. True means (re)start the quiet-period
|
||||||
|
/// timer; false means leave it as it is.
|
||||||
|
/// </summary>
|
||||||
|
public bool Observe(Plate plate, OverlapReportState state)
|
||||||
|
{
|
||||||
|
if (plate == null || !NeedsCheck(plate, state))
|
||||||
|
{
|
||||||
|
pending = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (pending != null && pending.Matches(plate))
|
||||||
|
return false;
|
||||||
|
pending = OverlapGeometryStamp.Capture(plate);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call when the quiet period ends. <see cref="OverlapAutoCheckStep.Wait"/> means restart the
|
||||||
|
/// timer (the layout moved or an interaction is still running); only
|
||||||
|
/// <see cref="OverlapAutoCheckStep.Check"/> starts a request.
|
||||||
|
/// </summary>
|
||||||
|
public OverlapAutoCheckStep Elapsed(Plate plate, OverlapReportState state, bool interactionActive)
|
||||||
|
{
|
||||||
|
if (pending == null)
|
||||||
|
return OverlapAutoCheckStep.None;
|
||||||
|
if (plate == null || !NeedsCheck(plate, state))
|
||||||
|
{
|
||||||
|
pending = null;
|
||||||
|
return OverlapAutoCheckStep.None;
|
||||||
|
}
|
||||||
|
if (interactionActive || !pending.Matches(plate))
|
||||||
|
{
|
||||||
|
pending = OverlapGeometryStamp.Capture(plate);
|
||||||
|
return OverlapAutoCheckStep.Wait;
|
||||||
|
}
|
||||||
|
pending = null;
|
||||||
|
return OverlapAutoCheckStep.Check;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Record every request start, manual or automatic.</summary>
|
||||||
|
public void Started(Plate plate)
|
||||||
|
{
|
||||||
|
pending = null;
|
||||||
|
lastRequest = OverlapGeometryStamp.Capture(plate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forget pending and previous requests (plate switch, handle loss, disable).</summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
pending = null;
|
||||||
|
lastRequest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool NeedsCheck(Plate plate, OverlapReportState state) => state.Status switch
|
||||||
|
{
|
||||||
|
OverlapCheckStatus.NotChecked or OverlapCheckStatus.Stale => true,
|
||||||
|
OverlapCheckStatus.Canceled or OverlapCheckStatus.Failed => lastRequest?.Matches(plate) != true,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cheap ordered identity/pose check, not a geometry hash. In-place geometry editors must
|
||||||
|
/// explicitly invalidate before mutating. Capture and match only on the model's UI thread.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OverlapGeometryStamp
|
||||||
|
{
|
||||||
|
private readonly Plate plate;
|
||||||
|
private readonly Entry[] entries;
|
||||||
|
|
||||||
|
private OverlapGeometryStamp(Plate plate)
|
||||||
|
{
|
||||||
|
this.plate = plate;
|
||||||
|
entries = new Entry[plate.Parts.Count];
|
||||||
|
for (var i = 0; i < entries.Length; i++)
|
||||||
|
entries[i] = new Entry(plate.Parts[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
|
||||||
|
|
||||||
|
public bool Matches(Plate current)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(plate, current) || current.Parts.Count != entries.Length)
|
||||||
|
return false;
|
||||||
|
for (var i = 0; i < entries.Length; i++)
|
||||||
|
if (!entries[i].Matches(current.Parts[i]))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly struct Entry
|
||||||
|
{
|
||||||
|
private readonly Part part;
|
||||||
|
private readonly Drawing drawing;
|
||||||
|
private readonly Program placedProgram;
|
||||||
|
private readonly Program cleanProgram;
|
||||||
|
private readonly long x, y, rotation;
|
||||||
|
private readonly bool isCutOff;
|
||||||
|
|
||||||
|
public Entry(Part part)
|
||||||
|
{
|
||||||
|
this.part = part;
|
||||||
|
drawing = part.BaseDrawing;
|
||||||
|
placedProgram = part.Program;
|
||||||
|
cleanProgram = drawing.Program;
|
||||||
|
x = BitConverter.DoubleToInt64Bits(part.Location.X);
|
||||||
|
y = BitConverter.DoubleToInt64Bits(part.Location.Y);
|
||||||
|
rotation = BitConverter.DoubleToInt64Bits(part.Rotation);
|
||||||
|
isCutOff = drawing.IsCutOff;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Matches(Part current) =>
|
||||||
|
ReferenceEquals(part, current)
|
||||||
|
&& ReferenceEquals(drawing, current.BaseDrawing)
|
||||||
|
&& ReferenceEquals(placedProgram, current.Program)
|
||||||
|
&& ReferenceEquals(cleanProgram, current.BaseDrawing.Program)
|
||||||
|
&& x == BitConverter.DoubleToInt64Bits(current.Location.X)
|
||||||
|
&& y == BitConverter.DoubleToInt64Bits(current.Location.Y)
|
||||||
|
&& rotation == BitConverter.DoubleToInt64Bits(current.Rotation)
|
||||||
|
&& isCutOff == current.BaseDrawing.IsCutOff;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Viewport-sized pages of cached overlap details, including continuation pages for a
|
||||||
|
/// single long pair. The caller supplies its actual single-line font measurement.
|
||||||
|
/// No names or numeric details are elided, and no geometry is queried here.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OverlapHoverPages
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<string>[] pages;
|
||||||
|
private readonly IReadOnlyList<string>[] navigation;
|
||||||
|
|
||||||
|
private OverlapHoverPages(IReadOnlyList<string>[] pages, IReadOnlyList<string>[] navigation,
|
||||||
|
bool needsLargerViewport = false)
|
||||||
|
{
|
||||||
|
this.pages = pages;
|
||||||
|
this.navigation = navigation;
|
||||||
|
NeedsLargerViewport = needsLargerViewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Lines => PageCount == 0 ? Array.Empty<string>() : pages[PageIndex];
|
||||||
|
public IReadOnlyList<string> NavigationLines => PageCount == 0 ? Array.Empty<string>() : navigation[PageIndex];
|
||||||
|
public int PageIndex { get; private set; }
|
||||||
|
public int PageCount => pages.Length;
|
||||||
|
public bool NeedsLargerViewport { get; }
|
||||||
|
|
||||||
|
public void MovePage(int delta) => PageIndex = (int)System.Math.Clamp((long)PageIndex + delta, 0,
|
||||||
|
System.Math.Max(0, PageCount - 1));
|
||||||
|
|
||||||
|
public static OverlapHoverPages Create(string text, int pairCount, int maxRows,
|
||||||
|
double maxWidth, Func<string, double> measure)
|
||||||
|
{
|
||||||
|
var tooSmall = new OverlapHoverPages([], [], true);
|
||||||
|
if (maxRows < 1 || !double.IsFinite(maxWidth) || maxWidth <= 0)
|
||||||
|
return tooSmall;
|
||||||
|
var lines = Wrap(text, maxWidth, measure);
|
||||||
|
if (lines == null)
|
||||||
|
return tooSmall;
|
||||||
|
if (lines.Count <= maxRows)
|
||||||
|
return new OverlapHoverPages([lines.AsReadOnly()], [Array.Empty<string>()]);
|
||||||
|
|
||||||
|
// Reserve the real, wrapped hint as well as content. Increasing the reserved
|
||||||
|
// rows can increase the page count/digit count, so converge before slicing.
|
||||||
|
for (var hintRows = 2; hintRows < maxRows;)
|
||||||
|
{
|
||||||
|
var contentRows = maxRows - hintRows;
|
||||||
|
var count = (lines.Count - 1) / contentRows + 1;
|
||||||
|
var hints = new IReadOnlyList<string>[count];
|
||||||
|
var requiredHintRows = hintRows;
|
||||||
|
for (var page = 0; page < count; page++)
|
||||||
|
{
|
||||||
|
var hint = Wrap($"Page {page + 1}/{count} · {pairCount} pairs\nPgUp/PgDn", maxWidth, measure);
|
||||||
|
if (hint == null)
|
||||||
|
return tooSmall;
|
||||||
|
hints[page] = hint.AsReadOnly();
|
||||||
|
requiredHintRows = System.Math.Max(requiredHintRows, hint.Count);
|
||||||
|
}
|
||||||
|
if (requiredHintRows > hintRows)
|
||||||
|
{
|
||||||
|
hintRows = requiredHintRows;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var pages = Enumerable.Range(0, count)
|
||||||
|
.Select(page => (IReadOnlyList<string>)Array.AsReadOnly(lines.Skip(page * contentRows).Take(contentRows).ToArray()))
|
||||||
|
.ToArray();
|
||||||
|
return new OverlapHoverPages(pages, hints);
|
||||||
|
}
|
||||||
|
// There must be room for at least one complete content line AND navigation.
|
||||||
|
return tooSmall;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> Wrap(string text, double width, Func<string, double> measure)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
foreach (var paragraph in text.Replace("\r\n", "\n").Split('\n'))
|
||||||
|
{
|
||||||
|
if (paragraph.Length == 0)
|
||||||
|
{
|
||||||
|
lines.Add("");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var starts = StringInfo.ParseCombiningCharacters(paragraph).Append(paragraph.Length).ToArray();
|
||||||
|
for (var first = 0; first < starts.Length - 1;)
|
||||||
|
{
|
||||||
|
var low = first;
|
||||||
|
var high = starts.Length - 1;
|
||||||
|
while (low < high)
|
||||||
|
{
|
||||||
|
var end = low + (high - low + 1) / 2;
|
||||||
|
if (measure(paragraph[starts[first]..starts[end]]) <= width)
|
||||||
|
low = end;
|
||||||
|
else
|
||||||
|
high = end - 1;
|
||||||
|
}
|
||||||
|
if (low == first)
|
||||||
|
return null; // Even one grapheme cannot fit; do not silently clip it.
|
||||||
|
var last = low;
|
||||||
|
if (last < starts.Length - 1)
|
||||||
|
{
|
||||||
|
for (var end = last; end > first; end--)
|
||||||
|
{
|
||||||
|
if (char.IsWhiteSpace(paragraph[starts[end - 1]]))
|
||||||
|
{
|
||||||
|
last = end;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.Add(paragraph[starts[first]..starts[last]]);
|
||||||
|
first = last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>Request-local, validated single-outer material. Never exposed to report consumers.</summary>
|
||||||
|
internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||||
|
{
|
||||||
|
internal static OverlapMaterial Read(List<Entity> entities, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// ShapeBuilder can reverse entities while chaining. Own a fresh copy for each analysis.
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities.Select(entity => entity.Clone()));
|
||||||
|
if (shapes.Count == 0)
|
||||||
|
throw new ArgumentException("Drawing has no closed material contour.");
|
||||||
|
var polygons = new List<Polygon>();
|
||||||
|
foreach (var shape in shapes)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
ValidateChain(shape);
|
||||||
|
var polygon = shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance);
|
||||||
|
// The analytic chain was validated above. Normalize its sampled seam (e.g.
|
||||||
|
// sin(2*pi) is not exactly zero), rather than adding a spurious microscopic edge.
|
||||||
|
if (polygon.IsClosed())
|
||||||
|
polygon.Vertices[^1] = polygon.Vertices[0];
|
||||||
|
ValidatePolygon(polygon, cancellationToken);
|
||||||
|
polygons.Add(polygon);
|
||||||
|
}
|
||||||
|
// Sampling can hide a crossing or tangency between curves. Reject native
|
||||||
|
// contour contact before asking the polygon approximation about containment.
|
||||||
|
for (var i = 0; i < shapes.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
for (var j = 0; j < i; j++)
|
||||||
|
{
|
||||||
|
shapes[i].Intersects(shapes[j], out var intersections);
|
||||||
|
if (intersections.Count > 0)
|
||||||
|
throw new ArgumentException("Native material contours cross or touch.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
|
||||||
|
var outer = ordered[0];
|
||||||
|
var holes = ordered.Skip(1).ToList();
|
||||||
|
for (var i = 0; i < holes.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (BoundariesTouch(outer, holes[i], cancellationToken)
|
||||||
|
|| !Inside(outer, 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]))
|
||||||
|
throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new OverlapMaterial(outer, holes);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal OverlapMaterial Transform(double rotation, Vector offset) =>
|
||||||
|
new(TransformPolygon(Outer, rotation, offset),
|
||||||
|
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
|
||||||
|
|
||||||
|
private static Polygon TransformPolygon(Polygon polygon, double rotation, Vector offset)
|
||||||
|
{
|
||||||
|
var transformed = new Polygon();
|
||||||
|
transformed.Vertices.AddRange(polygon.Vertices.Select(point =>
|
||||||
|
(rotation == 0 ? point : point.Rotate(rotation)) + offset));
|
||||||
|
if (transformed.Vertices.Any(point => !IsFinite(point)))
|
||||||
|
throw new ArithmeticException("Transformed contour has nonfinite coordinates.");
|
||||||
|
transformed.UpdateBounds();
|
||||||
|
if (!double.IsFinite(transformed.BoundingBox.Length)
|
||||||
|
|| !double.IsFinite(transformed.BoundingBox.Width))
|
||||||
|
throw new ArithmeticException("Transformed contour bounds overflowed.");
|
||||||
|
var sourceArea = Area(polygon.Vertices);
|
||||||
|
var transformedArea = Area(transformed.Vertices);
|
||||||
|
if (!double.IsFinite(transformedArea) || transformedArea <= Tolerance.Epsilon
|
||||||
|
|| System.Math.Abs(sourceArea - transformedArea)
|
||||||
|
> System.Math.Max(Tolerance.Epsilon, sourceArea * 1e-8))
|
||||||
|
throw new ArithmeticException("Coordinate precision cannot preserve the contour area at this pose.");
|
||||||
|
for (var i = 0; i + 1 < transformed.Vertices.Count; i++)
|
||||||
|
{
|
||||||
|
var a = transformed.Vertices[i];
|
||||||
|
var b = transformed.Vertices[i + 1];
|
||||||
|
if (a.X == b.X && a.Y == b.Y)
|
||||||
|
throw new ArithmeticException("Coordinate precision collapsed a contour edge at this pose.");
|
||||||
|
}
|
||||||
|
return transformed;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||||
|
|
||||||
|
/// <summary>Translation-stable unsigned shoelace area; accepts an explicit closing vertex.</summary>
|
||||||
|
internal static double Area(IReadOnlyList<Vector> vertices)
|
||||||
|
{
|
||||||
|
var twiceArea = 0.0;
|
||||||
|
for (var i = 1; i + 1 < vertices.Count; i++)
|
||||||
|
twiceArea += Cross(vertices[0], vertices[i], vertices[i + 1]);
|
||||||
|
return System.Math.Abs(twiceArea) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateChain(Shape shape)
|
||||||
|
{
|
||||||
|
if (!shape.IsClosed())
|
||||||
|
throw new ArgumentException("Material contour is open.");
|
||||||
|
foreach (var entity in shape.Entities)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(entity.Length) || entity.Length <= 0)
|
||||||
|
throw new ArgumentException("Material contour has a nonfinite or zero-length edge.");
|
||||||
|
}
|
||||||
|
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
|
||||||
|
{
|
||||||
|
if (!IsFinite(circle.Center) || !double.IsFinite(circle.Radius) || circle.Radius <= 0)
|
||||||
|
throw new ArgumentException("Material circle is invalid.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < shape.Entities.Count; i++)
|
||||||
|
{
|
||||||
|
var end = Endpoints(shape.Entities[i]).End;
|
||||||
|
var start = Endpoints(shape.Entities[(i + 1) % shape.Entities.Count]).Start;
|
||||||
|
// Do not let ShapeBuilder's larger chain tolerance silently repair a broken cut.
|
||||||
|
if (!IsFinite(start) || !IsFinite(end) || end.DistanceTo(start) > Tolerance.Epsilon)
|
||||||
|
throw new ArgumentException("Material contour has a gap or invalid endpoint.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Vector Start, Vector End) Endpoints(Entity entity) => entity switch
|
||||||
|
{
|
||||||
|
Line line => (line.StartPoint, line.EndPoint),
|
||||||
|
Arc arc => (arc.StartPoint(), arc.EndPoint()),
|
||||||
|
_ => throw new ArgumentException("Unsupported material entity."),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void ValidatePolygon(Polygon polygon, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var vertices = polygon.Vertices;
|
||||||
|
var area = Area(vertices);
|
||||||
|
if (vertices.Count < 4 || vertices.Any(point => !IsFinite(point))
|
||||||
|
|| !double.IsFinite(area) || area <= Tolerance.Epsilon)
|
||||||
|
throw new ArgumentException("Material contour is degenerate or nonfinite.");
|
||||||
|
var count = vertices.Count - 1;
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var previous = vertices[(i + count - 1) % count];
|
||||||
|
var current = vertices[i];
|
||||||
|
var next = vertices[i + 1];
|
||||||
|
if (current.X == next.X && current.Y == next.Y)
|
||||||
|
throw new ArgumentException("Material polygon has a zero-length edge.");
|
||||||
|
if (Cross(previous, current, next) == 0
|
||||||
|
&& (previous.X - current.X) * (next.X - current.X)
|
||||||
|
+ (previous.Y - current.Y) * (next.Y - current.Y) > 0)
|
||||||
|
throw new ArgumentException("Material polygon has a retraced edge.");
|
||||||
|
for (var j = i + 2; j < count; j++)
|
||||||
|
{
|
||||||
|
if (i == 0 && j == count - 1)
|
||||||
|
continue;
|
||||||
|
if (SegmentsTouch(vertices[i], vertices[i + 1], vertices[j], vertices[j + 1]))
|
||||||
|
throw new ArgumentException("Material contour self-intersects or touches itself.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ear clipping can stop early on unusable geometry. Do not certify that as clear.
|
||||||
|
var local = TransformPolygon(polygon, 0, vertices[0] * -1);
|
||||||
|
var triangulatedArea = Collision.Triangulate(local).Sum(triangle => Area(triangle.Vertices));
|
||||||
|
if (!double.IsFinite(triangulatedArea)
|
||||||
|
|| System.Math.Abs(triangulatedArea - area) > System.Math.Max(Tolerance.Epsilon, area * 1e-9))
|
||||||
|
throw new ArgumentException("Material contour could not be completely triangulated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (var i = 0; i + 1 < a.Vertices.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
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]))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SegmentsTouch(Vector a, Vector b, Vector c, Vector d)
|
||||||
|
{
|
||||||
|
var ac = Cross(a, b, c);
|
||||||
|
var ad = Cross(a, b, d);
|
||||||
|
var ca = Cross(c, d, a);
|
||||||
|
var cb = Cross(c, d, b);
|
||||||
|
return ac == 0 && OnSegment(a, b, c) || ad == 0 && OnSegment(a, b, d)
|
||||||
|
|| ca == 0 && OnSegment(c, d, a) || cb == 0 && OnSegment(c, d, b)
|
||||||
|
|| (ac < 0 && ad > 0 || ac > 0 && ad < 0)
|
||||||
|
&& (ca < 0 && cb > 0 || ca > 0 && cb < 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool OnSegment(Vector a, Vector b, Vector point) =>
|
||||||
|
point.X >= System.Math.Min(a.X, b.X) && point.X <= System.Math.Max(a.X, b.X)
|
||||||
|
&& 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.
|
||||||
|
private static bool Inside(Polygon polygon, Vector point)
|
||||||
|
{
|
||||||
|
var winding = 0;
|
||||||
|
for (var i = 0; i + 1 < polygon.Vertices.Count; i++)
|
||||||
|
{
|
||||||
|
var a = polygon.Vertices[i];
|
||||||
|
var b = polygon.Vertices[i + 1];
|
||||||
|
if (a.Y <= point.Y && b.Y > point.Y && Cross(a, b, point) > 0)
|
||||||
|
winding++;
|
||||||
|
else if (a.Y > point.Y && b.Y <= point.Y && Cross(a, b, point) < 0)
|
||||||
|
winding--;
|
||||||
|
}
|
||||||
|
return winding != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Cross(Vector a, Vector b, Vector point) =>
|
||||||
|
(b.X - a.X) * (point.Y - a.Y) - (b.Y - a.Y) * (point.X - a.X);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reuses each clean drawing program's converted entities and prepared (validated, chorded,
|
||||||
|
/// triangulation-checked) material across overlap requests. Preparation dominates the cost of
|
||||||
|
/// drawings with many holes, and it depends only on the drawing, not on where parts sit, so a
|
||||||
|
/// recheck after moving parts only repeats the cheap pose transforms and pair clipping.
|
||||||
|
/// Entries are keyed by <see cref="Program"/> reference and released with it. A changed code
|
||||||
|
/// count or program rotation is detected, but that is not a geometry hash: call
|
||||||
|
/// <see cref="Clear"/> before any in-place edit of a clean program or its hole subprograms.
|
||||||
|
/// Capture on one thread at a time; prepared entries may be shared by concurrent analyses.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OverlapMaterialCache
|
||||||
|
{
|
||||||
|
private readonly ConditionalWeakTable<Program, OverlapSource> sources = new();
|
||||||
|
|
||||||
|
public void Clear() => sources.Clear();
|
||||||
|
|
||||||
|
internal OverlapSource Get(Program program, Func<Program, OverlapSource> create)
|
||||||
|
{
|
||||||
|
if (sources.TryGetValue(program, out var source) && source.Matches(program))
|
||||||
|
return source;
|
||||||
|
source = create(program);
|
||||||
|
sources.AddOrUpdate(program, source);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Owned converted entities for one clean program, plus its lazily prepared material.</summary>
|
||||||
|
internal sealed class OverlapSource
|
||||||
|
{
|
||||||
|
private readonly object gate = new();
|
||||||
|
private readonly int codeCount;
|
||||||
|
private readonly long rotation;
|
||||||
|
private PreparedMaterial prepared;
|
||||||
|
|
||||||
|
internal OverlapSource(Program program, List<Entity> entities, string error)
|
||||||
|
{
|
||||||
|
codeCount = program.Codes.Count;
|
||||||
|
rotation = BitConverter.DoubleToInt64Bits(program.Rotation);
|
||||||
|
Entities = entities;
|
||||||
|
Error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Never mutated; preparation clones before chaining.</summary>
|
||||||
|
internal List<Entity> Entities { get; }
|
||||||
|
internal string Error { get; }
|
||||||
|
|
||||||
|
internal bool Matches(Program program) =>
|
||||||
|
program.Codes.Count == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prepares once and shares the result. A geometry failure is cached like a success;
|
||||||
|
/// cancellation is not, so a superseded request cannot poison the next one.
|
||||||
|
/// </summary>
|
||||||
|
internal PreparedMaterial Prepare(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = Volatile.Read(ref prepared);
|
||||||
|
if (current != null)
|
||||||
|
return current;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (prepared != null)
|
||||||
|
return prepared;
|
||||||
|
PreparedMaterial result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = new PreparedMaterial(OverlapMaterial.Read(Entities, cancellationToken), null);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (PlateOverlapAnalyzer.IsGeometryFailure(exception))
|
||||||
|
{
|
||||||
|
result = new PreparedMaterial(null, exception.Message);
|
||||||
|
}
|
||||||
|
Volatile.Write(ref prepared, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Validated local-frame material, only ever read (transformed into new polygons).</summary>
|
||||||
|
internal sealed record PreparedMaterial(OverlapMaterial Material, string Error);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
|
||||||
|
public static class OverlapPairPresentation
|
||||||
|
{
|
||||||
|
public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}";
|
||||||
|
|
||||||
|
public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
|
||||||
|
{
|
||||||
|
var units = UnitsHelper.GetShortString(capturedUnits);
|
||||||
|
return $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}\n"
|
||||||
|
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
|
||||||
|
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Significant figures adapt to scale: a positive sliver must never read as zero.
|
||||||
|
public static string Number(double value, IFormatProvider provider = null) =>
|
||||||
|
value.ToString("G6", provider ?? CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
|
public static double MarkerHalfSize(int deviceDpi) => 6.0 * deviceDpi / 96;
|
||||||
|
public static double HitRadius(int deviceDpi) => 10.0 * deviceDpi / 96;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects cached centroids into a screen-pixel coordinate space. The pointer must
|
||||||
|
/// use that same space; view zoom never scales the DPI-adjusted hit radius.
|
||||||
|
/// Coincident or nearby markers return every matching pair in stable ID order.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<PlateOverlapPair> HitTest(IEnumerable<PlateOverlapPair> pairs,
|
||||||
|
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi)
|
||||||
|
{
|
||||||
|
var radius = HitRadius(deviceDpi);
|
||||||
|
return pairs.Where(pair =>
|
||||||
|
{
|
||||||
|
var center = worldToScreen(pair.Centroid);
|
||||||
|
var dx = pointer.X - center.X;
|
||||||
|
var dy = pointer.Y - center.Y;
|
||||||
|
return dx * dx + dy * dy <= radius * radius;
|
||||||
|
}).OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
public enum OverlapDisplayMode { Off, Areas, Centroids, Both }
|
||||||
|
public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Failed, Canceled, Stale }
|
||||||
|
|
||||||
|
/// <summary>UI-thread lifecycle policy, independent of workers, GDI and view transforms.</summary>
|
||||||
|
public sealed class OverlapReportState
|
||||||
|
{
|
||||||
|
private OverlapGeometryStamp stamp;
|
||||||
|
private int uncheckedPartCount;
|
||||||
|
|
||||||
|
public long Generation { get; private set; }
|
||||||
|
public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked;
|
||||||
|
public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas;
|
||||||
|
public PlateOverlapReport Report { get; private set; }
|
||||||
|
public bool IsRunning => Status == OverlapCheckStatus.Checking;
|
||||||
|
|
||||||
|
public string Message => Status switch
|
||||||
|
{
|
||||||
|
OverlapCheckStatus.Checking => "Checking overlaps…",
|
||||||
|
OverlapCheckStatus.Current => Report.Pairs.Count == 0
|
||||||
|
? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs",
|
||||||
|
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
|
||||||
|
+ $"{uncheckedPartCount} parts could not be checked",
|
||||||
|
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
|
||||||
|
OverlapCheckStatus.Canceled => "Overlap check canceled",
|
||||||
|
OverlapCheckStatus.Stale => "Overlap check out of date — run Check Overlaps again",
|
||||||
|
_ => "Overlaps: not checked"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a request. A manual check from Off shows Areas; an automatic recheck keeps
|
||||||
|
/// the user's display choice, including Off.
|
||||||
|
/// </summary>
|
||||||
|
public long Begin(Plate plate, bool automatic = false)
|
||||||
|
{
|
||||||
|
Clear(OverlapCheckStatus.Checking);
|
||||||
|
stamp = OverlapGeometryStamp.Capture(plate);
|
||||||
|
if (!automatic && DisplayMode == OverlapDisplayMode.Off)
|
||||||
|
DisplayMode = OverlapDisplayMode.Areas;
|
||||||
|
return Generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryPublish(long generation, Plate plate, PlateOverlapReport report)
|
||||||
|
{
|
||||||
|
if (!CanComplete(generation, plate))
|
||||||
|
return false;
|
||||||
|
Report = report;
|
||||||
|
uncheckedPartCount = CountUncheckedParts(report.Issues);
|
||||||
|
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryFail(long generation, Plate plate)
|
||||||
|
{
|
||||||
|
if (!CanComplete(generation, plate))
|
||||||
|
return false;
|
||||||
|
Clear(OverlapCheckStatus.Failed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanComplete(long generation, Plate plate) =>
|
||||||
|
generation == Generation && IsRunning && EnsureFresh(plate);
|
||||||
|
|
||||||
|
public bool EnsureFresh(Plate plate)
|
||||||
|
{
|
||||||
|
if (stamp == null)
|
||||||
|
return false;
|
||||||
|
if (stamp.Matches(plate))
|
||||||
|
return true;
|
||||||
|
Invalidate();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Invalidate()
|
||||||
|
{
|
||||||
|
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
|
||||||
|
Clear(OverlapCheckStatus.Stale);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Cancel()
|
||||||
|
{
|
||||||
|
if (IsRunning)
|
||||||
|
Clear(OverlapCheckStatus.Canceled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
|
||||||
|
|
||||||
|
private void Clear(OverlapCheckStatus status)
|
||||||
|
{
|
||||||
|
Generation++;
|
||||||
|
Report = null;
|
||||||
|
uncheckedPartCount = 0;
|
||||||
|
stamp = null;
|
||||||
|
Status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int CountUncheckedParts(IEnumerable<PlateOverlapIssue> issues)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<int>();
|
||||||
|
foreach (var issue in issues)
|
||||||
|
{
|
||||||
|
ids.Add(issue.PartAId);
|
||||||
|
if (issue.PartBId.HasValue)
|
||||||
|
ids.Add(issue.PartBId.Value);
|
||||||
|
}
|
||||||
|
return ids.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only, hole-aware material overlap diagnostics, separate from the engine's boolean checks.
|
||||||
|
/// Uses clean drawing outlines, not placed lead-in/tab toolpaths or spacing offsets.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlateOverlapAnalyzer
|
||||||
|
{
|
||||||
|
public const double ChordTolerance = 0.001;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures poses and converts each distinct clean source program to owned entities once.
|
||||||
|
/// Inputs must not change during capture. Later analysis never reads live domain objects.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Capture(parts, new OverlapMaterialCache(), cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
|
||||||
|
/// and prepared drawing material from <paramref name="cache"/> across requests. Clear the
|
||||||
|
/// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
|
||||||
|
/// </summary>
|
||||||
|
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(parts);
|
||||||
|
ArgumentNullException.ThrowIfNull(cache);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var captured = new List<CapturedOverlapPart>();
|
||||||
|
var issues = new List<PlateOverlapIssue>();
|
||||||
|
for (var id = 0; id < parts.Count; id++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var part = parts[id];
|
||||||
|
if (part?.BaseDrawing?.IsCutOff == true)
|
||||||
|
continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (part?.BaseDrawing?.Program == null)
|
||||||
|
throw new ArgumentException("Part has no clean drawing program.");
|
||||||
|
var program = part.BaseDrawing.Program;
|
||||||
|
var rotation = part.Rotation - program.Rotation;
|
||||||
|
var location = part.Location;
|
||||||
|
if (!double.IsFinite(rotation) || !OverlapMaterial.IsFinite(location))
|
||||||
|
throw new ArgumentException("Part pose must be finite.");
|
||||||
|
var source = cache.Get(program, CaptureSource);
|
||||||
|
if (source.Error != null)
|
||||||
|
throw new ArgumentException(source.Error);
|
||||||
|
captured.Add(new CapturedOverlapPart(id, part.BaseDrawing.Name,
|
||||||
|
source, rotation, location));
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||||
|
{
|
||||||
|
issues.Add(new PlateOverlapIssue(id, null, exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return new PlateOverlapSnapshot(captured, issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OverlapSource CaptureSource(Program program)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
|
||||||
|
// Conversion creates fresh geometry, including expanded shared hole calls;
|
||||||
|
// no cloning/rotation of a live program or subprogram is necessary.
|
||||||
|
return new OverlapSource(program, ConvertProgram.ToGeometry(program)
|
||||||
|
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
|
||||||
|
&& entity.Layer != SpecialLayers.Leadin
|
||||||
|
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||||
|
{
|
||||||
|
return new OverlapSource(program, null, exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Convenience synchronous capture and analysis of a group of parts.</summary>
|
||||||
|
public static PlateOverlapReport Analyze(IReadOnlyList<Part> parts,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Analyze(Capture(parts, cancellationToken), cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns deterministic pair reports containing closed world-coordinate overlap fragments.
|
||||||
|
/// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Analyze(snapshot, null, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Incremental recheck: identical to a full analysis of <paramref name="snapshot"/>, but a
|
||||||
|
/// pair whose two parts are unchanged since <paramref name="previous"/> (same captured source
|
||||||
|
/// and bit-identical pose, in the same relative order) reuses that report's result instead
|
||||||
|
/// of being clipped again. After moving one part only its own neighbors are recomputed.
|
||||||
|
/// Reuse needs sources shared through one <see cref="OverlapMaterialCache"/>; otherwise every
|
||||||
|
/// pair is recomputed. Null <paramref name="previous"/> performs a full analysis.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot, PlateOverlapReport previous,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var reuse = PairReuse.Create(previous, snapshot);
|
||||||
|
var issues = snapshot.Issues.ToList();
|
||||||
|
var pairs = new List<PlateOverlapPair>();
|
||||||
|
var prepared = new List<PreparedPart>();
|
||||||
|
foreach (var part in snapshot.Parts)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Prepared material is shared by every part and request using this source.
|
||||||
|
var source = part.Source.Prepare(cancellationToken);
|
||||||
|
if (source.Error != null)
|
||||||
|
throw new ArgumentException(source.Error);
|
||||||
|
var material = source.Material.Transform(part.Rotation, part.Location);
|
||||||
|
prepared.Add(new PreparedPart(part, material));
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||||
|
{
|
||||||
|
issues.Add(new PlateOverlapIssue(part.Id, null, exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sorted = prepared.OrderBy(part => part.Material.Outer.BoundingBox.Left)
|
||||||
|
.ThenBy(part => part.Input.Id).ToArray();
|
||||||
|
for (var i = 0; i < sorted.Length; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var first = sorted[i];
|
||||||
|
var bounds = first.Material.Outer.BoundingBox;
|
||||||
|
for (var j = i + 1; j < sorted.Length; j++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var second = sorted[j];
|
||||||
|
var otherBounds = second.Material.Outer.BoundingBox;
|
||||||
|
if (otherBounds.Left >= bounds.Right)
|
||||||
|
break;
|
||||||
|
if (otherBounds.Bottom >= bounds.Top || bounds.Bottom >= otherBounds.Top)
|
||||||
|
continue;
|
||||||
|
var a = first.Input.Id < second.Input.Id ? first : second;
|
||||||
|
var b = first.Input.Id < second.Input.Id ? second : first;
|
||||||
|
if (reuse != null && reuse.TryReuse(a.Input, b.Input, pairs, issues))
|
||||||
|
continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Keep pair clipping arithmetic near the parts where possible, then
|
||||||
|
// restore output to world space. Triangulation itself also uses stable
|
||||||
|
// local-origin winding so tiny holes in a huge part remain correct.
|
||||||
|
var origin = a.Material.Outer.Vertices[0];
|
||||||
|
var localA = a.Material.Transform(0, origin * -1);
|
||||||
|
var localB = b.Material.Transform(0, origin * -1);
|
||||||
|
var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
|
||||||
|
if (!result.Overlaps)
|
||||||
|
continue;
|
||||||
|
// Evaluate every hole-subtracted fragment before restoring world space.
|
||||||
|
// A failed moment must make this pair incomplete, never an origin marker.
|
||||||
|
var moments = new List<PolygonAreaMoments>();
|
||||||
|
var regions = new List<PlateOverlapRegion>();
|
||||||
|
foreach (var region in result.OverlapRegions)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment))
|
||||||
|
throw new ArithmeticException("Overlap fragment area moments are invalid.");
|
||||||
|
moments.Add(fragment);
|
||||||
|
regions.Add(new PlateOverlapRegion(region.Vertices.Select(point => point + origin),
|
||||||
|
fragment.Area));
|
||||||
|
}
|
||||||
|
if (!PolygonAreaMoments.TryCombine(moments, out var combined))
|
||||||
|
throw new ArithmeticException("Combined overlap area moments are invalid.");
|
||||||
|
var centroid = combined.Centroid + origin;
|
||||||
|
if (!OverlapMaterial.IsFinite(centroid))
|
||||||
|
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
|
||||||
|
pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id,
|
||||||
|
a.Input.Name, b.Input.Name, regions, centroid));
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||||
|
{
|
||||||
|
issues.Add(new PlateOverlapIssue(a.Input.Id, b.Input.Id, exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
|
||||||
|
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
|
||||||
|
.ThenBy(issue => issue.PartBId).ToList(), snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps unchanged parts to their previous input positions and looks up previous pair results.
|
||||||
|
/// A part is unchanged when its captured source object and exact pose bits match; identical
|
||||||
|
/// duplicates are matched in input order, which is safe because their inputs are bit-identical.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class PairReuse
|
||||||
|
{
|
||||||
|
private readonly Dictionary<int, int> previousIds;
|
||||||
|
private readonly Dictionary<(int, int), PlateOverlapPair> pairs = new();
|
||||||
|
private readonly Dictionary<(int, int), PlateOverlapIssue> issues = new();
|
||||||
|
|
||||||
|
private PairReuse(Dictionary<int, int> previousIds, PlateOverlapReport previous)
|
||||||
|
{
|
||||||
|
this.previousIds = previousIds;
|
||||||
|
foreach (var pair in previous.Pairs)
|
||||||
|
pairs[(pair.PartAId, pair.PartBId)] = pair;
|
||||||
|
foreach (var issue in previous.Issues)
|
||||||
|
if (issue.PartBId.HasValue)
|
||||||
|
issues[(issue.PartAId, issue.PartBId.Value)] = issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PairReuse Create(PlateOverlapReport previous, PlateOverlapSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (previous?.Snapshot == null)
|
||||||
|
return null;
|
||||||
|
var available = new Dictionary<PoseKey, Queue<int>>();
|
||||||
|
foreach (var part in previous.Snapshot.Parts)
|
||||||
|
{
|
||||||
|
var key = PoseKey.Of(part);
|
||||||
|
if (!available.TryGetValue(key, out var ids))
|
||||||
|
available.Add(key, ids = new Queue<int>());
|
||||||
|
ids.Enqueue(part.Id);
|
||||||
|
}
|
||||||
|
var previousIds = new Dictionary<int, int>();
|
||||||
|
foreach (var part in snapshot.Parts)
|
||||||
|
if (available.TryGetValue(PoseKey.Of(part), out var ids) && ids.Count > 0)
|
||||||
|
previousIds.Add(part.Id, ids.Dequeue());
|
||||||
|
return previousIds.Count < 2 ? null : new PairReuse(previousIds, previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every bounding-box candidate pair among prepared parts was evaluated by the previous
|
||||||
|
/// analysis, and unchanged parts have identical bounds, so absence there means clear.
|
||||||
|
/// The previous pair must have had the same operand order: clipping is order-sensitive.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryReuse(CapturedOverlapPart a, CapturedOverlapPart b,
|
||||||
|
List<PlateOverlapPair> pairOutput, List<PlateOverlapIssue> issueOutput)
|
||||||
|
{
|
||||||
|
if (!previousIds.TryGetValue(a.Id, out var oldA) || !previousIds.TryGetValue(b.Id, out var oldB)
|
||||||
|
|| oldA >= oldB)
|
||||||
|
return false;
|
||||||
|
if (pairs.TryGetValue((oldA, oldB), out var pair))
|
||||||
|
pairOutput.Add(pair.Renumber(a.Id, b.Id, a.Name, b.Name));
|
||||||
|
else if (issues.TryGetValue((oldA, oldB), out var issue))
|
||||||
|
issueOutput.Add(issue with { PartAId = a.Id, PartBId = b.Id });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct PoseKey(OverlapSource Source, long Rotation, long X, long Y)
|
||||||
|
{
|
||||||
|
public static PoseKey Of(CapturedOverlapPart part) => new(part.Source,
|
||||||
|
BitConverter.DoubleToInt64Bits(part.Rotation),
|
||||||
|
BitConverter.DoubleToInt64Bits(part.Location.X),
|
||||||
|
BitConverter.DoubleToInt64Bits(part.Location.Y));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsGeometryFailure(Exception exception) => exception is
|
||||||
|
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||||
|
|
||||||
|
private static void ValidateProgram(Program program, HashSet<Program> visiting)
|
||||||
|
{
|
||||||
|
if (program == null || !visiting.Add(program) || visiting.Count > 64)
|
||||||
|
throw new ArgumentException("Missing, recursive, or excessively nested subprogram.");
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
{
|
||||||
|
if (code == null)
|
||||||
|
throw new ArgumentException("Program contains a missing instruction.");
|
||||||
|
if (code is Motion motion && !OverlapMaterial.IsFinite(motion.EndPoint)
|
||||||
|
|| code is ArcMove arc && !OverlapMaterial.IsFinite(arc.CenterPoint))
|
||||||
|
throw new ArgumentException("Program coordinates must be finite.");
|
||||||
|
if (code is SubProgramCall call)
|
||||||
|
{
|
||||||
|
if (!OverlapMaterial.IsFinite(call.Offset) || !double.IsFinite(call.Rotation))
|
||||||
|
throw new ArgumentException("Subprogram pose must be finite.");
|
||||||
|
ValidateProgram(call.Program, visiting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visiting.Remove(program);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PreparedPart(CapturedOverlapPart Input, OverlapMaterial Material);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>An owned diagnostic result. An empty Pairs list is clear only if IsComplete is true.</summary>
|
||||||
|
public sealed class PlateOverlapReport
|
||||||
|
{
|
||||||
|
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
|
||||||
|
PlateOverlapSnapshot snapshot)
|
||||||
|
{
|
||||||
|
Pairs = pairs.AsReadOnly();
|
||||||
|
Issues = issues.AsReadOnly();
|
||||||
|
Snapshot = snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
|
||||||
|
internal PlateOverlapSnapshot Snapshot { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
|
||||||
|
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||||
|
public bool IsComplete => Issues.Count == 0;
|
||||||
|
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shared material for two input positions, ordered by zero-based input index.</summary>
|
||||||
|
public sealed class PlateOverlapPair
|
||||||
|
{
|
||||||
|
private readonly Box bounds;
|
||||||
|
|
||||||
|
private PlateOverlapPair(PlateOverlapPair source, int partAId, int partBId, string partAName,
|
||||||
|
string partBName)
|
||||||
|
{
|
||||||
|
PartAId = partAId;
|
||||||
|
PartBId = partBId;
|
||||||
|
PartAName = partAName;
|
||||||
|
PartBName = partBName;
|
||||||
|
Regions = source.Regions;
|
||||||
|
Area = source.Area;
|
||||||
|
Centroid = source.Centroid;
|
||||||
|
bounds = source.bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The same immutable geometry under new input positions and captured names.</summary>
|
||||||
|
internal PlateOverlapPair Renumber(int partAId, int partBId, string partAName, string partBName) =>
|
||||||
|
new(this, partAId, partBId, partAName, partBName);
|
||||||
|
|
||||||
|
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
|
||||||
|
List<PlateOverlapRegion> regions, Vector centroid)
|
||||||
|
{
|
||||||
|
PartAId = partAId;
|
||||||
|
PartBId = partBId;
|
||||||
|
PartAName = partAName;
|
||||||
|
PartBName = partBName;
|
||||||
|
Regions = regions.AsReadOnly();
|
||||||
|
Area = regions.Sum(region => region.Area);
|
||||||
|
Centroid = centroid;
|
||||||
|
var points = regions.SelectMany(region => region.Vertices).ToArray();
|
||||||
|
var left = points.Min(point => point.X);
|
||||||
|
var bottom = points.Min(point => point.Y);
|
||||||
|
bounds = new Box(left, bottom, points.Max(point => point.X) - left,
|
||||||
|
points.Max(point => point.Y) - bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int PartAId { get; }
|
||||||
|
public int PartBId { get; }
|
||||||
|
public string PartAName { get; }
|
||||||
|
public string PartBName { get; }
|
||||||
|
/// <summary>Convex fragments, not connected islands; no mutable kernel polygons are exposed.</summary>
|
||||||
|
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
|
||||||
|
public double Area { get; }
|
||||||
|
/// <summary>
|
||||||
|
/// Finite world-coordinate area centroid of all shared material, after hole subtraction.
|
||||||
|
/// This can lie outside disconnected or concave shared material. Returned by value.
|
||||||
|
/// </summary>
|
||||||
|
public Vector Centroid { get; }
|
||||||
|
/// <summary>A fresh world-coordinate bounds copy.</summary>
|
||||||
|
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A positive-area, hole-subtracted convex polygon in world coordinates.</summary>
|
||||||
|
public sealed class PlateOverlapRegion
|
||||||
|
{
|
||||||
|
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area)
|
||||||
|
{
|
||||||
|
Vertices = System.Array.AsReadOnly(vertices.ToArray());
|
||||||
|
Area = area;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Read-only vertices with an exactly repeated closing vertex.</summary>
|
||||||
|
public IReadOnlyList<Vector> Vertices { get; }
|
||||||
|
public double Area { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An input or pair that could not be checked. IDs are zero-based input positions.</summary>
|
||||||
|
public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owned clean geometry and poses. Capture while inputs are stable, then analyze on a worker.
|
||||||
|
/// No live Part, Drawing, Program, or subprogram is retained.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlateOverlapSnapshot
|
||||||
|
{
|
||||||
|
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues)
|
||||||
|
{
|
||||||
|
Parts = parts.AsReadOnly();
|
||||||
|
Issues = issues.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
||||||
|
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
|
||||||
|
double Rotation, Vector Location);
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only pre-post diagnostics. The caller must keep the nest stable for the entire call.
|
||||||
|
/// Placed programs are already rotated; only their placement translation is applied here.
|
||||||
|
/// This intentionally does not invoke a post or promise machine collision avoidance.
|
||||||
|
/// </summary>
|
||||||
|
public static class PostVerificationAnalyzer
|
||||||
|
{
|
||||||
|
public static PostVerificationReport AnalyzeForPost(Nest nest, IPostProcessor postProcessor,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var report = Analyze(nest, cancellationToken);
|
||||||
|
if (postProcessor is IPostVerificationSupport { PreservesPlacedProgramOrder: true })
|
||||||
|
return report;
|
||||||
|
var findings = report.Findings.ToList();
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, 0, null, null,
|
||||||
|
$"Post '{postProcessor?.Name ?? "unknown"}' does not declare that it preserves placed part/contour order " +
|
||||||
|
"and pierce positions. Nest-level checks ran, but the final rapid sequence requires manual review."));
|
||||||
|
return new PostVerificationReport(findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(nest);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var findings = new List<PostVerificationFinding>();
|
||||||
|
if (nest.Plates == null)
|
||||||
|
{
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, 1, null, null, "Nest has no plate collection."));
|
||||||
|
return new PostVerificationReport(findings);
|
||||||
|
}
|
||||||
|
for (var plateIndex = 0; plateIndex < nest.Plates.Count; plateIndex++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var plate = nest.Plates[plateIndex];
|
||||||
|
var plateNumber = plateIndex + 1;
|
||||||
|
if (plate?.Parts == null)
|
||||||
|
{
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||||
|
"Plate has no part collection."));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var materialParts = new List<Part>();
|
||||||
|
var indices = new List<int>();
|
||||||
|
var obstacles = new List<Obstacle>();
|
||||||
|
Vector? position = Vector.Zero;
|
||||||
|
for (var index = 0; index < plate.Parts.Count; index++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var part = plate.Parts[index];
|
||||||
|
var partNumber = index + 1;
|
||||||
|
var cutoff = part?.BaseDrawing?.IsCutOff == true;
|
||||||
|
var expectsCuts = cutoff;
|
||||||
|
// Preflight even clean programs before calling the overlap converter: malformed
|
||||||
|
// recursive graphs must never reach Program.Clone or unguarded conversion.
|
||||||
|
if (!cutoff)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var clean = Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken);
|
||||||
|
expectsCuts = clean.Any(move => !move.Rapid && move.Layer != LayerType.Scribe);
|
||||||
|
if (!clean.Any(move => move.Layer == LayerType.Scribe)
|
||||||
|
|| expectsCuts)
|
||||||
|
{
|
||||||
|
materialParts.Add(part);
|
||||||
|
indices.Add(partNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
|
{
|
||||||
|
Incomplete("Overlap check: " + exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (part == null || !double.IsFinite(part.Rotation))
|
||||||
|
throw new ArgumentException("Missing part or invalid rotation.");
|
||||||
|
var moves = Read(part.Program, part.Location, position, cancellationToken);
|
||||||
|
if (expectsCuts && !moves.Any(move => !move.Rapid
|
||||||
|
&& move.Layer is LayerType.Cut or LayerType.Display
|
||||||
|
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
||||||
|
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
||||||
|
AnalyzeMoves(moves, cutoff, obstacles, findings, plateNumber, partNumber, cancellationToken);
|
||||||
|
position = moves[^1].End;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
|
{
|
||||||
|
Incomplete("Lead-in/rapid check: " + exception.Message);
|
||||||
|
// Subsequent internal moves can still be checked, but the incoming segment
|
||||||
|
// cannot be reconstructed after an invalid program.
|
||||||
|
position = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Incomplete(string message) => findings.Add(new(PostVerificationKind.Incomplete,
|
||||||
|
plateNumber, partNumber, null, message));
|
||||||
|
}
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var overlap = PlateOverlapAnalyzer.Analyze(
|
||||||
|
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
|
||||||
|
foreach (var pair in overlap.Pairs)
|
||||||
|
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||||
|
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted)."));
|
||||||
|
foreach (var issue in overlap.Issues)
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
|
||||||
|
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
|
||||||
|
}
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return new PostVerificationReport(findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInvalid(Exception exception) => exception is
|
||||||
|
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||||
|
|
||||||
|
private static List<Move> Read(Program program, Vector origin, Vector? previous,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
var moves = new List<Move>();
|
||||||
|
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
var budget = 1000000;
|
||||||
|
Walk(program, origin, previous);
|
||||||
|
return moves;
|
||||||
|
|
||||||
|
Vector Walk(Program current, Vector frame, Vector? arrival)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
PostVerificationGeometry.Validate(frame);
|
||||||
|
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
|
||||||
|
throw new ArgumentException("Missing, recursive or excessively nested program.");
|
||||||
|
var pos = frame;
|
||||||
|
var first = true;
|
||||||
|
var countBefore = moves.Count;
|
||||||
|
foreach (var code in current.Codes)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (--budget < 0)
|
||||||
|
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
||||||
|
if (code == null)
|
||||||
|
throw new ArgumentException("Program contains a missing instruction.");
|
||||||
|
if (code is SubProgramCall call)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(call.Rotation))
|
||||||
|
throw new ArgumentException("Subprogram rotation is not finite.");
|
||||||
|
// Call rotation is baked into the shared program by its setter. Do not
|
||||||
|
// rotate again; offsets are frame-relative even in incremental mode.
|
||||||
|
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code is not Motion motion)
|
||||||
|
{
|
||||||
|
if (code is not (Comment or Feedrate or Kerf))
|
||||||
|
throw new NotSupportedException("Unsupported program instruction.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Posts disagree about incremental position after suppressed instructions.
|
||||||
|
// Never silently certify a trajectory whose semantics are ambiguous.
|
||||||
|
if (motion.Suppressed)
|
||||||
|
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
|
||||||
|
if (motion is not (RapidMove or LinearMove or ArcMove))
|
||||||
|
throw new NotSupportedException("Unsupported motion.");
|
||||||
|
var reference = current.Mode == Mode.Incremental ? pos : frame;
|
||||||
|
var end = reference + motion.EndPoint;
|
||||||
|
PostVerificationGeometry.Validate(end);
|
||||||
|
var rapid = motion is RapidMove;
|
||||||
|
if (first && !rapid)
|
||||||
|
moves.Add(new(arrival, frame, true, LayerType.Display, null));
|
||||||
|
var start = first && rapid ? arrival : pos;
|
||||||
|
var layer = motion switch
|
||||||
|
{
|
||||||
|
LinearMove line => line.Layer,
|
||||||
|
ArcMove arc => arc.Layer,
|
||||||
|
_ => LayerType.Display
|
||||||
|
};
|
||||||
|
if (!Enum.IsDefined(layer))
|
||||||
|
throw new NotSupportedException("Unsupported motion layer.");
|
||||||
|
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
|
||||||
|
throw new NotSupportedException("Unsupported arc direction.");
|
||||||
|
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));
|
||||||
|
pos = end;
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
visiting.Remove(current);
|
||||||
|
if (moves.Count == countBefore)
|
||||||
|
throw new ArgumentException("Program has no motions.");
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AnalyzeMoves(List<Move> moves, bool cutoff, List<Obstacle> obstacles,
|
||||||
|
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
|
||||||
|
{
|
||||||
|
var contour = new List<PostVerificationGeometry.Curve>();
|
||||||
|
var unfinished = new List<PostVerificationGeometry.Curve[]>();
|
||||||
|
var hasLead = false;
|
||||||
|
var contourNumber = 0;
|
||||||
|
foreach (var move in moves)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (move.Rapid)
|
||||||
|
{
|
||||||
|
Finish();
|
||||||
|
hasLead = false;
|
||||||
|
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
|
||||||
|
continue;
|
||||||
|
foreach (var obstacle in obstacles)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
|
||||||
|
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||||
|
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
||||||
|
$"of part {obstacle.Part}."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (move.Layer == LayerType.Leadin)
|
||||||
|
{
|
||||||
|
Finish();
|
||||||
|
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
|
||||||
|
}
|
||||||
|
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
|
||||||
|
{
|
||||||
|
if (move.Layer == LayerType.Leadout && contour.Count > 0
|
||||||
|
&& !PostVerificationGeometry.Closed(contour)
|
||||||
|
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||||
|
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
|
||||||
|
"Rapid safety for that contour requires manual review."));
|
||||||
|
Finish();
|
||||||
|
hasLead = false;
|
||||||
|
}
|
||||||
|
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||||
|
{
|
||||||
|
if (contour.Count == 0)
|
||||||
|
{
|
||||||
|
contourNumber++;
|
||||||
|
if (!cutoff && !hasLead)
|
||||||
|
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||||
|
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
|
||||||
|
hasLead = false;
|
||||||
|
// A rapid can pause/reposition without leaving any material gap.
|
||||||
|
// Retain already-cut fragments, but do not turn them into obstacles
|
||||||
|
// until an actually continuous chain closes.
|
||||||
|
var previous = unfinished.FindIndex(chain =>
|
||||||
|
chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
|
||||||
|
if (previous >= 0)
|
||||||
|
{
|
||||||
|
contour.AddRange(unfinished[previous]);
|
||||||
|
unfinished.RemoveAt(previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contour.Add(move.Curve);
|
||||||
|
// A completed contour becomes an obstacle immediately, not at part end.
|
||||||
|
if (PostVerificationGeometry.Closed(contour))
|
||||||
|
Finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Finish();
|
||||||
|
if (unfinished.Count > 1)
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||||
|
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
|
||||||
|
"they cannot be assumed to be retained by tabs. Review rapid travel manually."));
|
||||||
|
|
||||||
|
void Finish()
|
||||||
|
{
|
||||||
|
if (contour.Count == 0)
|
||||||
|
return;
|
||||||
|
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
|
||||||
|
// no flag or tab configuration is used as evidence of retention.
|
||||||
|
if (!cutoff && PostVerificationGeometry.Closed(contour))
|
||||||
|
obstacles.Add(new(part, contourNumber, contour.ToArray()));
|
||||||
|
else if (!cutoff)
|
||||||
|
unfinished.Add(contour.ToArray());
|
||||||
|
contour.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Move(Vector? Start, Vector End, bool Rapid, LayerType Layer,
|
||||||
|
PostVerificationGeometry.Curve Curve);
|
||||||
|
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>Local native line/arc queries, without changing the engine's geometry semantics.</summary>
|
||||||
|
internal static class PostVerificationGeometry
|
||||||
|
{
|
||||||
|
internal const double Epsilon = 1e-8;
|
||||||
|
private const double TwoPi = 2 * System.Math.PI;
|
||||||
|
|
||||||
|
internal static void Validate(Vector point)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y)
|
||||||
|
|| System.Math.Abs(point.X) > 1e12 || System.Math.Abs(point.Y) > 1e12)
|
||||||
|
throw new ArgumentException("Nonfinite or numerically unsupported program coordinates.");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool Closed(IReadOnlyList<Curve> curves) => curves.Count > 0
|
||||||
|
&& curves[0].Start.DistanceTo(curves[^1].End) <= Epsilon;
|
||||||
|
|
||||||
|
internal static bool Crosses(Vector start, Vector end, IReadOnlyList<Curve> curves,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
var delta = end - start;
|
||||||
|
var length = start.DistanceTo(end);
|
||||||
|
if (length <= Epsilon)
|
||||||
|
return false;
|
||||||
|
var direction = delta * (1 / length);
|
||||||
|
foreach (var curve in curves)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (curve.ContactAfterStart(start, direction, length))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// If there are no contacts except possibly departure, all open-segment points
|
||||||
|
// have the same inside/outside status. A midpoint catches travel entirely inside
|
||||||
|
// and departure into the interior, without flagging start-only outward contact.
|
||||||
|
var midpoint = start + delta * 0.5;
|
||||||
|
var inside = false;
|
||||||
|
foreach (var curve in curves)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (curve.CrossesRay(midpoint))
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
||||||
|
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||||
|
private static double Normalize(double angle)
|
||||||
|
{
|
||||||
|
angle %= TwoPi;
|
||||||
|
return angle < 0 ? angle + TwoPi : angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Curve
|
||||||
|
{
|
||||||
|
private Curve(Vector start, Vector end, Vector? center, double radius, double sweep)
|
||||||
|
{
|
||||||
|
Start = start;
|
||||||
|
End = end;
|
||||||
|
Center = center;
|
||||||
|
Radius = radius;
|
||||||
|
Sweep = sweep;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Vector Start { get; }
|
||||||
|
internal Vector End { get; }
|
||||||
|
private Vector? Center { get; }
|
||||||
|
private double Radius { get; }
|
||||||
|
private double Sweep { get; }
|
||||||
|
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
|
||||||
|
|
||||||
|
internal static Curve Create(Vector start, Vector end, Vector? center, bool clockwise)
|
||||||
|
{
|
||||||
|
if (center is not { } c)
|
||||||
|
return new Curve(start, end, null, 0, 0);
|
||||||
|
Validate(c);
|
||||||
|
var radius = start.DistanceTo(c);
|
||||||
|
if (radius <= Epsilon || !double.IsFinite(radius)
|
||||||
|
|| System.Math.Abs(radius - end.DistanceTo(c)) > Epsilon * System.Math.Max(1, radius))
|
||||||
|
throw new ArgumentException("Arc has zero or inconsistent radius.");
|
||||||
|
var a = System.Math.Atan2(start.Y - c.Y, start.X - c.X);
|
||||||
|
var b = System.Math.Atan2(end.Y - c.Y, end.X - c.X);
|
||||||
|
var sweep = start.DistanceTo(end) <= Epsilon ? TwoPi
|
||||||
|
: Normalize(clockwise ? a - b : b - a);
|
||||||
|
return new Curve(start, end, c, radius, clockwise ? -sweep : sweep);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double StartAngle => System.Math.Atan2(Start.Y - Center.Value.Y, Start.X - Center.Value.X);
|
||||||
|
private double Travel(double angle) => Normalize(Sweep < 0 ? StartAngle - angle : angle - StartAngle);
|
||||||
|
private bool OnArc(Vector point) => Travel(System.Math.Atan2(point.Y - Center.Value.Y,
|
||||||
|
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|
||||||
|
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
|
||||||
|
|
||||||
|
internal bool ContactAfterStart(Vector origin, Vector direction, double length)
|
||||||
|
{
|
||||||
|
if (Center is { } center)
|
||||||
|
{
|
||||||
|
// Intersect the actual circle, not an inscribed chord polygon: tangencies
|
||||||
|
// and short arcs must not vanish between tessellation vertices.
|
||||||
|
var relative = center - origin;
|
||||||
|
var projection = Dot(relative, direction);
|
||||||
|
var perpendicular = Cross(relative, direction);
|
||||||
|
var square = Radius * Radius - perpendicular * perpendicular;
|
||||||
|
if (square < -Epsilon * System.Math.Max(1, Radius * 2))
|
||||||
|
return false;
|
||||||
|
var offset = System.Math.Sqrt(System.Math.Max(0, square));
|
||||||
|
return Hit(projection - offset) || Hit(projection + offset);
|
||||||
|
|
||||||
|
bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon
|
||||||
|
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length));
|
||||||
|
}
|
||||||
|
var edge = End - Start;
|
||||||
|
var relativeStart = Start - origin;
|
||||||
|
var denominator = Cross(direction, edge);
|
||||||
|
if (System.Math.Abs(denominator) <= 1e-12 * System.Math.Max(1, Length))
|
||||||
|
{
|
||||||
|
if (System.Math.Abs(Cross(relativeStart, direction)) > Epsilon)
|
||||||
|
return false;
|
||||||
|
var a = Dot(relativeStart, direction);
|
||||||
|
var b = Dot(End - origin, direction);
|
||||||
|
var low = System.Math.Max(0, System.Math.Min(a, b));
|
||||||
|
var high = System.Math.Min(length, System.Math.Max(a, b));
|
||||||
|
return high > Epsilon && low <= high + Epsilon;
|
||||||
|
}
|
||||||
|
var distanceAlongRapid = Cross(relativeStart, edge) / denominator;
|
||||||
|
var fractionAlongEdge = Cross(relativeStart, direction) / denominator;
|
||||||
|
return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon
|
||||||
|
&& fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon)
|
||||||
|
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool CrossesRay(Vector point)
|
||||||
|
{
|
||||||
|
if (Center is not { } center)
|
||||||
|
return (Start.Y > point.Y) != (End.Y > point.Y)
|
||||||
|
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
|
||||||
|
|
||||||
|
// Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
|
||||||
|
// half-open endpoint rule as a polygon ray test, solving X on the native
|
||||||
|
// circle. This handles full circles, reversed arcs and shared vertices.
|
||||||
|
var breaks = new List<double> { 0, System.Math.Abs(Sweep) };
|
||||||
|
foreach (var angle in new[] { System.Math.PI / 2, 3 * System.Math.PI / 2 })
|
||||||
|
{
|
||||||
|
var travel = Travel(angle);
|
||||||
|
if (travel > 0 && travel < System.Math.Abs(Sweep))
|
||||||
|
breaks.Add(travel);
|
||||||
|
}
|
||||||
|
breaks.Sort();
|
||||||
|
var inside = false;
|
||||||
|
for (var i = 1; i < breaks.Count; i++)
|
||||||
|
{
|
||||||
|
var a = StartAngle + System.Math.Sign(Sweep) * breaks[i - 1];
|
||||||
|
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
|
||||||
|
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
|
||||||
|
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
|
||||||
|
if ((ya > point.Y) == (yb > point.Y))
|
||||||
|
continue;
|
||||||
|
var dy = point.Y - center.Y;
|
||||||
|
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
|
||||||
|
var x = center.X + (System.Math.Cos((a + b) / 2) >= 0 ? dx : -dx);
|
||||||
|
if (x > point.X)
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
public enum PostVerificationKind
|
||||||
|
{
|
||||||
|
Overlap,
|
||||||
|
MissingLeadIn,
|
||||||
|
RapidCrossing,
|
||||||
|
Incomplete
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
||||||
|
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber,
|
||||||
|
int? PartNumber, int? OtherPartNumber, string Message);
|
||||||
|
|
||||||
|
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
|
||||||
|
public sealed class PostVerificationReport
|
||||||
|
{
|
||||||
|
internal PostVerificationReport(IEnumerable<PostVerificationFinding> findings)
|
||||||
|
{
|
||||||
|
Findings = Array.AsReadOnly(findings.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<PostVerificationFinding> Findings { get; }
|
||||||
|
public bool HasWarnings => Findings.Count != 0;
|
||||||
|
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
|
||||||
|
|
||||||
|
public string ToDisplayText()
|
||||||
|
{
|
||||||
|
var text = new StringBuilder();
|
||||||
|
text.AppendLine("Pre-post verification");
|
||||||
|
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
|
||||||
|
Summary(PostVerificationKind.Overlap, "Overlap");
|
||||||
|
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
|
||||||
|
Summary(PostVerificationKind.RapidCrossing, "Rapid crossings");
|
||||||
|
foreach (var finding in Findings)
|
||||||
|
{
|
||||||
|
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
|
||||||
|
if (finding.PartNumber is { } part)
|
||||||
|
text.Append($", part {part}");
|
||||||
|
if (finding.OtherPartNumber is { } other)
|
||||||
|
text.Append($", other part {other}");
|
||||||
|
text.AppendLine($": {finding.Kind}: {finding.Message}");
|
||||||
|
}
|
||||||
|
text.AppendLine("This is not a physical safety certification. The check uses direct XY rapids " +
|
||||||
|
"in plate/program order; the post may change order, routing or retracts. Inspect the posted " +
|
||||||
|
"machine program and machine setup. Actual contour gaps are not proof of adequate retention.");
|
||||||
|
return text.ToString();
|
||||||
|
|
||||||
|
void Summary(PostVerificationKind kind, string label)
|
||||||
|
{
|
||||||
|
var count = Findings.Count(finding => finding.Kind == kind);
|
||||||
|
text.AppendLine($"{label}: {count} warning(s)" +
|
||||||
|
(incomplete ? "; verification incomplete — do not treat as clear." : "."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-12
@@ -1,12 +1,12 @@
|
|||||||
using OpenNest.Bending;
|
using System;
|
||||||
using OpenNest.CNC;
|
|
||||||
using OpenNest.Converters;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using OpenNest.Bending;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest
|
namespace OpenNest
|
||||||
{
|
{
|
||||||
@@ -40,14 +40,10 @@ namespace OpenNest
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Drawing()
|
public Drawing()
|
||||||
: this(string.Empty, new Program())
|
: this(string.Empty, new Program()) { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public Drawing(string name)
|
public Drawing(string name)
|
||||||
: this(name, new Program())
|
: this(name, new Program()) { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public Drawing(string name, Program pgm)
|
public Drawing(string name, Program pgm)
|
||||||
{
|
{
|
||||||
@@ -127,7 +123,9 @@ namespace OpenNest
|
|||||||
|
|
||||||
public void UpdateArea()
|
public void UpdateArea()
|
||||||
{
|
{
|
||||||
var geometry = ConvertProgram.ToGeometry(Program).Where(entity => entity.Layer != SpecialLayers.Rapid);
|
var geometry = ConvertProgram
|
||||||
|
.ToGeometry(Program)
|
||||||
|
.Where(entity => SpecialLayers.IsMaterial(entity.Layer));
|
||||||
var shapes = ShapeBuilder.GetShapes(geometry);
|
var shapes = ShapeBuilder.GetShapes(geometry);
|
||||||
|
|
||||||
if (shapes.Count == 0)
|
if (shapes.Count == 0)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
|
||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures drawing program text before an edit and rebuilds only the parts whose
|
||||||
|
/// drawing program changed. Drawing keys use reference identity because names are editable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DrawingProgramSnapshot
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Drawing, string> programs;
|
||||||
|
private readonly Func<Program, string> fingerprint;
|
||||||
|
|
||||||
|
private DrawingProgramSnapshot(
|
||||||
|
Dictionary<Drawing, string> programs,
|
||||||
|
Func<Program, string> fingerprint)
|
||||||
|
{
|
||||||
|
this.programs = programs;
|
||||||
|
this.fingerprint = fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Capture before handing drawings to an editor, including its load operation:
|
||||||
|
/// programs can be edited in place. The callback must include both the main
|
||||||
|
/// program text and its hole sub-programs, and must not mutate the program.
|
||||||
|
/// </summary>
|
||||||
|
public static DrawingProgramSnapshot Capture(
|
||||||
|
IEnumerable<Drawing> drawings,
|
||||||
|
Func<Program, string> fingerprint)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(drawings);
|
||||||
|
ArgumentNullException.ThrowIfNull(fingerprint);
|
||||||
|
var programs = new Dictionary<Drawing, string>(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
|
foreach (var drawing in drawings)
|
||||||
|
{
|
||||||
|
if (!drawing.IsCutOff && !programs.ContainsKey(drawing))
|
||||||
|
programs.Add(drawing, fingerprint(drawing.Program));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DrawingProgramSnapshot(programs, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Complete the captured edit by rebuilding changed drawings' parts across all plates.
|
||||||
|
/// Part.Update preserves the placement and clears obsolete lead-ins, tabs and locks.
|
||||||
|
/// Unchanged and uncaptured drawings' parts retain their program instances and state.
|
||||||
|
/// Returns the rebuilt parts so a UI can invalidate just their graphics.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<Part> UpdateChangedParts(IEnumerable<Plate> plates)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plates);
|
||||||
|
var changed = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
|
foreach (var entry in programs)
|
||||||
|
{
|
||||||
|
if (!entry.Key.IsCutOff
|
||||||
|
&& !string.Equals(entry.Value, fingerprint(entry.Key.Program), StringComparison.Ordinal))
|
||||||
|
changed.Add(entry.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = new List<Part>();
|
||||||
|
foreach (var plate in plates)
|
||||||
|
{
|
||||||
|
foreach (var part in plate.Parts)
|
||||||
|
{
|
||||||
|
if (!changed.Contains(part.BaseDrawing))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
part.Update();
|
||||||
|
updated.Add(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using OpenNest.Math;
|
using System;
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Geometry
|
namespace OpenNest.Geometry
|
||||||
{
|
{
|
||||||
@@ -12,16 +12,18 @@ namespace OpenNest.Geometry
|
|||||||
private Vector center;
|
private Vector center;
|
||||||
private bool reversed;
|
private bool reversed;
|
||||||
|
|
||||||
public Arc()
|
public Arc() { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public Arc(double x, double y, double r, double a1, double a2, bool reversed = false)
|
public Arc(double x, double y, double r, double a1, double a2, bool reversed = false)
|
||||||
: this(new Vector(x, y), r, a1, a2, reversed)
|
: this(new Vector(x, y), r, a1, a2, reversed) { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public Arc(Vector center, double radius, double startAngle, double endAngle, bool reversed = false)
|
public Arc(
|
||||||
|
Vector center,
|
||||||
|
double radius,
|
||||||
|
double startAngle,
|
||||||
|
double endAngle,
|
||||||
|
bool reversed = false
|
||||||
|
)
|
||||||
{
|
{
|
||||||
this.center = center;
|
this.center = center;
|
||||||
this.radius = radius;
|
this.radius = radius;
|
||||||
@@ -93,8 +95,7 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsFullCircle() =>
|
public bool IsFullCircle() => SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
|
||||||
SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Angle in radians between start and end angles.
|
/// Angle in radians between start and end angles.
|
||||||
@@ -130,10 +131,7 @@ namespace OpenNest.Geometry
|
|||||||
public RotationType Rotation
|
public RotationType Rotation
|
||||||
{
|
{
|
||||||
get { return IsReversed ? RotationType.CW : RotationType.CCW; }
|
get { return IsReversed ? RotationType.CW : RotationType.CCW; }
|
||||||
set
|
set { IsReversed = (value == RotationType.CW); }
|
||||||
{
|
|
||||||
IsReversed = (value == RotationType.CW);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -144,7 +142,8 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
return new Vector(
|
return new Vector(
|
||||||
Center.X + Radius * System.Math.Cos(StartAngle),
|
Center.X + Radius * System.Math.Cos(StartAngle),
|
||||||
Center.Y + Radius * System.Math.Sin(StartAngle));
|
Center.Y + Radius * System.Math.Sin(StartAngle)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -155,7 +154,8 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
return new Vector(
|
return new Vector(
|
||||||
Center.X + Radius * System.Math.Cos(EndAngle),
|
Center.X + Radius * System.Math.Cos(EndAngle),
|
||||||
Center.Y + Radius * System.Math.Sin(EndAngle));
|
Center.Y + Radius * System.Math.Sin(EndAngle)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -166,7 +166,8 @@ namespace OpenNest.Geometry
|
|||||||
var midAngle = StartAngle + (IsReversed ? -SweepAngle() / 2 : SweepAngle() / 2);
|
var midAngle = StartAngle + (IsReversed ? -SweepAngle() / 2 : SweepAngle() / 2);
|
||||||
return new Vector(
|
return new Vector(
|
||||||
Center.X + Radius * System.Math.Cos(midAngle),
|
Center.X + Radius * System.Math.Cos(midAngle),
|
||||||
Center.Y + Radius * System.Math.Sin(midAngle));
|
Center.Y + Radius * System.Math.Sin(midAngle)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -231,7 +232,10 @@ namespace OpenNest.Geometry
|
|||||||
return 1;
|
return 1;
|
||||||
|
|
||||||
var maxAngle = 2.0 * System.Math.Acos(1.0 - tolerance / Radius);
|
var maxAngle = 2.0 * System.Math.Acos(1.0 - tolerance / Radius);
|
||||||
return System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Abs(SweepAngle()) / maxAngle));
|
return System.Math.Max(
|
||||||
|
1,
|
||||||
|
(int)System.Math.Ceiling(System.Math.Abs(SweepAngle()) / maxAngle)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -242,11 +246,10 @@ namespace OpenNest.Geometry
|
|||||||
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
|
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
|
||||||
{
|
{
|
||||||
var points = new List<Vector>();
|
var points = new List<Vector>();
|
||||||
var stepAngle = reversed
|
var stepAngle = reversed ? -SweepAngle() / segments : SweepAngle() / segments;
|
||||||
? -SweepAngle() / segments
|
|
||||||
: SweepAngle() / segments;
|
|
||||||
|
|
||||||
var r = circumscribe && segments > 0
|
var r =
|
||||||
|
circumscribe && segments > 0
|
||||||
? Radius / System.Math.Cos(System.Math.Abs(stepAngle) / 2.0)
|
? Radius / System.Math.Cos(System.Math.Abs(stepAngle) / 2.0)
|
||||||
: Radius;
|
: Radius;
|
||||||
|
|
||||||
@@ -254,9 +257,12 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
var angle = stepAngle * i + StartAngle;
|
var angle = stepAngle * i + StartAngle;
|
||||||
|
|
||||||
points.Add(new Vector(
|
points.Add(
|
||||||
|
new Vector(
|
||||||
System.Math.Cos(angle) * r + Center.X,
|
System.Math.Cos(angle) * r + Center.X,
|
||||||
System.Math.Sin(angle) * r + Center.Y));
|
System.Math.Sin(angle) * r + Center.Y
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return points;
|
return points;
|
||||||
@@ -437,20 +443,24 @@ namespace OpenNest.Geometry
|
|||||||
boundingBox.Width = maxY - minY;
|
boundingBox.Width = maxY - minY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offsets the arc to the given side of its travel direction. The center lies to
|
||||||
|
/// the left of a CCW arc and to the right of a CW (reversed) one, so the arc grows
|
||||||
|
/// on the other side and shrinks toward its center. Returns null when it shrinks
|
||||||
|
/// to nothing.
|
||||||
|
/// </summary>
|
||||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||||
{
|
{
|
||||||
if (side == OffsetSide.Left && reversed)
|
var grows = (side == OffsetSide.Left) == reversed;
|
||||||
{
|
|
||||||
|
if (grows)
|
||||||
return new Arc(center, radius + distance, startAngle, endAngle, reversed);
|
return new Arc(center, radius + distance, startAngle, endAngle, reversed);
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (distance >= radius)
|
if (distance >= radius)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return new Arc(center, radius - distance, startAngle, endAngle, reversed);
|
return new Arc(center, radius - distance, startAngle, endAngle, reversed);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public override Entity OffsetEntity(double distance, Vector pt)
|
public override Entity OffsetEntity(double distance, Vector pt)
|
||||||
{
|
{
|
||||||
@@ -470,7 +480,8 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
return new Vector(
|
return new Vector(
|
||||||
System.Math.Cos(angle) * Radius + Center.X,
|
System.Math.Cos(angle) * Radius + Center.X,
|
||||||
System.Math.Sin(angle) * Radius + Center.Y);
|
System.Math.Sin(angle) * Radius + Center.Y
|
||||||
|
);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -500,7 +511,8 @@ namespace OpenNest.Geometry
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public override bool Intersects(Arc arc, out List<Vector> pts)
|
public override bool Intersects(Arc arc, out List<Vector> pts)
|
||||||
{
|
{
|
||||||
return Intersect.Intersects(this, arc, out pts); ;
|
return Intersect.Intersects(this, arc, out pts);
|
||||||
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ namespace OpenNest.Geometry
|
|||||||
/// the arc passes through both endpoints and departs P1 in the given direction.
|
/// the arc passes through both endpoints and departs P1 in the given direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static (Vector center, double radius, double deviation) FitWithStartTangent(
|
internal static (Vector center, double radius, double deviation) FitWithStartTangent(
|
||||||
List<Vector> points, Vector tangent)
|
List<Vector> points,
|
||||||
|
Vector tangent
|
||||||
|
)
|
||||||
{
|
{
|
||||||
if (points.Count < 3)
|
if (points.Count < 3)
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
@@ -57,14 +59,22 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fits a circular arc constrained to be tangent to the given directions at both
|
/// Fits a circular arc that passes exactly through both the first and last points
|
||||||
/// the first and last points. The center lies at the intersection of the normals
|
/// while matching the given endpoint tangents as closely as possible. For any
|
||||||
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
|
/// circle through two points, the tangents at those points make equal mirrored
|
||||||
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
|
/// angles with the chord, so the achievable inscribed angle is the average of the
|
||||||
/// deviation includes any endpoint gap at Pn.
|
/// two requested ones — when the requested tangents are consistent with a single
|
||||||
|
/// circular arc, both are matched exactly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
|
internal static (
|
||||||
List<Vector> points, Vector startTangent, Vector endTangent)
|
Vector center,
|
||||||
|
double radius,
|
||||||
|
double deviation
|
||||||
|
) FitThroughEndpointsWithTangents(
|
||||||
|
List<Vector> points,
|
||||||
|
Vector startTangent,
|
||||||
|
Vector endTangent
|
||||||
|
)
|
||||||
{
|
{
|
||||||
if (points.Count < 3)
|
if (points.Count < 3)
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
@@ -72,48 +82,51 @@ namespace OpenNest.Geometry
|
|||||||
var p1 = points[0];
|
var p1 = points[0];
|
||||||
var pn = points[^1];
|
var pn = points[^1];
|
||||||
|
|
||||||
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
|
|
||||||
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
|
|
||||||
if (stLen < 1e-10 || etLen < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
// Normal to start tangent at P1 (perpendicular)
|
|
||||||
var n1x = -startTangent.Y / stLen;
|
|
||||||
var n1y = startTangent.X / stLen;
|
|
||||||
|
|
||||||
// Normal to end tangent at Pn
|
|
||||||
var n2x = -endTangent.Y / etLen;
|
|
||||||
var n2y = endTangent.X / etLen;
|
|
||||||
|
|
||||||
// Solve: P1 + t1*N1 = Pn + t2*N2
|
|
||||||
var det = n1x * (-n2y) - (-n2x) * n1y;
|
|
||||||
if (System.Math.Abs(det) < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
var dx = pn.X - p1.X;
|
var dx = pn.X - p1.X;
|
||||||
var dy = pn.Y - p1.Y;
|
var dy = pn.Y - p1.Y;
|
||||||
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
|
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||||
|
if (chordLen < 1e-10)
|
||||||
var cx = p1.X + t1 * n1x;
|
|
||||||
var cy = p1.Y + t1 * n1y;
|
|
||||||
|
|
||||||
// Use radius from P1 (guarantees exact start tangent and passes through P1)
|
|
||||||
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
|
||||||
if (r1 < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
// Measure endpoint gap at Pn
|
var ux = dx / chordLen;
|
||||||
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
|
var uy = dy / chordLen;
|
||||||
var endpointDev = System.Math.Abs(r2 - r1);
|
|
||||||
|
|
||||||
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
|
// Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
|
||||||
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
|
var theta1 = SignedAngle(ux, uy, startTangent);
|
||||||
|
var theta2 = -SignedAngle(ux, uy, endTangent);
|
||||||
|
var theta = (theta1 + theta2) / 2;
|
||||||
|
|
||||||
|
// Nearly straight or degenerate (sweep would exceed ~356 degrees)
|
||||||
|
if (System.Math.Abs(theta) < 1e-3 || System.Math.Abs(theta) > System.Math.PI * 0.99)
|
||||||
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
|
var halfChord = chordLen / 2;
|
||||||
|
var radius = halfChord / System.Math.Abs(System.Math.Sin(theta));
|
||||||
|
var d = -halfChord / System.Math.Tan(theta);
|
||||||
|
|
||||||
|
var cx = (p1.X + pn.X) / 2 + d * -uy;
|
||||||
|
var cy = (p1.Y + pn.Y) / 2 + d * ux;
|
||||||
|
|
||||||
|
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double SignedAngle(double ux, double uy, Vector to)
|
||||||
|
{
|
||||||
|
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
|
||||||
|
if (len < 1e-10)
|
||||||
|
return 0;
|
||||||
|
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Computes the maximum radial deviation of interior points from a circle.
|
/// Computes the maximum radial deviation of interior points from a circle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius)
|
internal static double MaxRadialDeviation(
|
||||||
|
List<Vector> points,
|
||||||
|
double cx,
|
||||||
|
double cy,
|
||||||
|
double radius
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var maxDev = 0.0;
|
var maxDev = 0.0;
|
||||||
for (var i = 1; i < points.Count - 1; i++)
|
for (var i = 1; i < points.Count - 1; i++)
|
||||||
@@ -122,7 +135,8 @@ namespace OpenNest.Geometry
|
|||||||
var py = points[i].Y - cy;
|
var py = points[i].Y - cy;
|
||||||
var dist = System.Math.Sqrt(px * px + py * py);
|
var dist = System.Math.Sqrt(px * px + py * py);
|
||||||
var dev = System.Math.Abs(dist - radius);
|
var dev = System.Math.Abs(dist - radius);
|
||||||
if (dev > maxDev) maxDev = dev;
|
if (dev > maxDev)
|
||||||
|
maxDev = dev;
|
||||||
}
|
}
|
||||||
return maxDev;
|
return maxDev;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,14 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
foreach (var box in boxes)
|
foreach (var box in boxes)
|
||||||
{
|
{
|
||||||
if (box.Left < minX) minX = box.Left;
|
if (box.Left < minX)
|
||||||
if (box.Right > maxX) maxX = box.Right;
|
minX = box.Left;
|
||||||
if (box.Bottom < minY) minY = box.Bottom;
|
if (box.Right > maxX)
|
||||||
if (box.Top > maxY) maxY = box.Top;
|
maxX = box.Right;
|
||||||
|
if (box.Bottom < minY)
|
||||||
|
minY = box.Bottom;
|
||||||
|
if (box.Top > maxY)
|
||||||
|
maxY = box.Top;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Box(minX, minY, maxX - minX, maxY - minY);
|
return new Box(minX, minY, maxX - minX, maxY - minY);
|
||||||
@@ -41,11 +45,15 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
var vertex = pts[i];
|
var vertex = pts[i];
|
||||||
|
|
||||||
if (vertex.X < minX) minX = vertex.X;
|
if (vertex.X < minX)
|
||||||
else if (vertex.X > maxX) maxX = vertex.X;
|
minX = vertex.X;
|
||||||
|
else if (vertex.X > maxX)
|
||||||
|
maxX = vertex.X;
|
||||||
|
|
||||||
if (vertex.Y < minY) minY = vertex.Y;
|
if (vertex.Y < minY)
|
||||||
else if (vertex.Y > maxY) maxY = vertex.Y;
|
minY = vertex.Y;
|
||||||
|
else if (vertex.Y > maxY)
|
||||||
|
maxY = vertex.Y;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Box(minX, minY, maxX - minX, maxY - minY);
|
return new Box(minX, minY, maxX - minX, maxY - minY);
|
||||||
@@ -65,10 +73,14 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
foreach (var box in items)
|
foreach (var box in items)
|
||||||
{
|
{
|
||||||
if (box.Left < left) left = box.Left;
|
if (box.Left < left)
|
||||||
if (box.Right > right) right = box.Right;
|
left = box.Left;
|
||||||
if (box.Bottom < bottom) bottom = box.Bottom;
|
if (box.Right > right)
|
||||||
if (box.Top > top) top = box.Top;
|
right = box.Right;
|
||||||
|
if (box.Bottom < bottom)
|
||||||
|
bottom = box.Bottom;
|
||||||
|
if (box.Top > top)
|
||||||
|
top = box.Top;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Box(left, bottom, right - left, top - bottom);
|
return new Box(left, bottom, right - left, top - bottom);
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ namespace OpenNest.Geometry
|
|||||||
public static readonly Box Empty = new Box();
|
public static readonly Box Empty = new Box();
|
||||||
|
|
||||||
public Box()
|
public Box()
|
||||||
: this(0, 0, 0, 0)
|
: this(0, 0, 0, 0) { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public Box(double x, double y, double w, double h)
|
public Box(double x, double y, double w, double h)
|
||||||
{
|
{
|
||||||
@@ -117,10 +115,14 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
public bool Intersects(Box box)
|
public bool Intersects(Box box)
|
||||||
{
|
{
|
||||||
if (Left >= box.Right) return false;
|
if (Left >= box.Right)
|
||||||
if (Right <= box.Left) return false;
|
return false;
|
||||||
if (Top <= box.Bottom) return false;
|
if (Right <= box.Left)
|
||||||
if (Bottom >= box.Top) return false;
|
return false;
|
||||||
|
if (Top <= box.Bottom)
|
||||||
|
return false;
|
||||||
|
if (Bottom >= box.Top)
|
||||||
|
return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -146,18 +148,24 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
public bool Contains(Box box)
|
public bool Contains(Box box)
|
||||||
{
|
{
|
||||||
if (box.Top > Top) return false;
|
if (box.Top > Top)
|
||||||
if (box.Left < Left) return false;
|
return false;
|
||||||
if (box.Right > Right) return false;
|
if (box.Left < Left)
|
||||||
if (box.Bottom < Bottom) return false;
|
return false;
|
||||||
|
if (box.Right > Right)
|
||||||
|
return false;
|
||||||
|
if (box.Bottom < Bottom)
|
||||||
|
return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Contains(Vector pt)
|
public bool Contains(Vector pt)
|
||||||
{
|
{
|
||||||
return pt.X >= Left - Tolerance.Epsilon && pt.X <= Right + Tolerance.Epsilon
|
return pt.X >= Left - Tolerance.Epsilon
|
||||||
&& pt.Y >= Bottom - Tolerance.Epsilon && pt.Y <= Top + Tolerance.Epsilon;
|
&& pt.X <= Right + Tolerance.Epsilon
|
||||||
|
&& pt.Y >= Bottom - Tolerance.Epsilon
|
||||||
|
&& pt.Y <= Top + Tolerance.Epsilon;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsHorizontalTo(Box box)
|
public bool IsHorizontalTo(Box box)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using OpenNest.Math;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Generic;
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Geometry
|
namespace OpenNest.Geometry
|
||||||
{
|
{
|
||||||
@@ -8,14 +8,10 @@ namespace OpenNest.Geometry
|
|||||||
private Vector center;
|
private Vector center;
|
||||||
private double radius;
|
private double radius;
|
||||||
|
|
||||||
public Circle()
|
public Circle() { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public Circle(double x, double y, double radius)
|
public Circle(double x, double y, double radius)
|
||||||
: this(new Vector(x, y), radius)
|
: this(new Vector(x, y), radius) { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public Circle(Vector center, double radius)
|
public Circle(Vector center, double radius)
|
||||||
{
|
{
|
||||||
@@ -137,21 +133,22 @@ namespace OpenNest.Geometry
|
|||||||
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
|
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
|
||||||
{
|
{
|
||||||
var points = new List<Vector>();
|
var points = new List<Vector>();
|
||||||
var stepAngle = Rotation == RotationType.CW
|
var stepAngle =
|
||||||
? -Angle.TwoPI / segments
|
Rotation == RotationType.CW ? -Angle.TwoPI / segments : Angle.TwoPI / segments;
|
||||||
: Angle.TwoPI / segments;
|
|
||||||
|
|
||||||
var r = circumscribe && segments > 0
|
var r =
|
||||||
? Radius / System.Math.Cos(stepAngle / 2.0)
|
circumscribe && segments > 0 ? Radius / System.Math.Cos(stepAngle / 2.0) : Radius;
|
||||||
: Radius;
|
|
||||||
|
|
||||||
for (int i = 0; i <= segments; ++i)
|
for (int i = 0; i <= segments; ++i)
|
||||||
{
|
{
|
||||||
var angle = stepAngle * i;
|
var angle = stepAngle * i;
|
||||||
|
|
||||||
points.Add(new Vector(
|
points.Add(
|
||||||
|
new Vector(
|
||||||
System.Math.Cos(angle) * r + Center.X,
|
System.Math.Cos(angle) * r + Center.X,
|
||||||
System.Math.Sin(angle) * r + Center.Y));
|
System.Math.Sin(angle) * r + Center.Y
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return points;
|
return points;
|
||||||
@@ -276,17 +273,18 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||||
{
|
{
|
||||||
if (side == OffsetSide.Left && Rotation == RotationType.CCW)
|
// The center lies to the left of a CCW circle and to the right of a CW one.
|
||||||
|
var shrinks = (side == OffsetSide.Left) == (Rotation == RotationType.CCW);
|
||||||
|
|
||||||
|
if (shrinks)
|
||||||
{
|
{
|
||||||
return Radius <= distance ? null : new Circle(center, Radius - distance)
|
return Radius <= distance
|
||||||
{
|
? null
|
||||||
Layer = Layer,
|
: new Circle(center, Radius - distance) { Layer = Layer, Rotation = Rotation };
|
||||||
Rotation = Rotation
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return new Circle(center, Radius + distance) { Layer = Layer };
|
return new Circle(center, Radius + distance) { Layer = Layer, Rotation = Rotation };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,11 +292,9 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
if (ContainsPoint(pt))
|
if (ContainsPoint(pt))
|
||||||
{
|
{
|
||||||
return Radius <= distance ? null : new Circle(center, Radius - distance)
|
return Radius <= distance
|
||||||
{
|
? null
|
||||||
Layer = Layer,
|
: new Circle(center, Radius - distance) { Layer = Layer, Rotation = Rotation };
|
||||||
Rotation = Rotation
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -317,7 +313,8 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
return new Vector(
|
return new Vector(
|
||||||
System.Math.Cos(angle) * Radius + Center.X,
|
System.Math.Cos(angle) * Radius + Center.X,
|
||||||
System.Math.Sin(angle) * Radius + Center.Y);
|
System.Math.Sin(angle) * Radius + Center.Y
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -350,7 +347,9 @@ namespace OpenNest.Geometry
|
|||||||
public override bool Intersects(Circle circle)
|
public override bool Intersects(Circle circle)
|
||||||
{
|
{
|
||||||
var dist = Center.DistanceTo(circle.Center);
|
var dist = Center.DistanceTo(circle.Center);
|
||||||
return (dist < (Radius + circle.Radius) && dist > System.Math.Abs(Radius - circle.Radius));
|
return (
|
||||||
|
dist < (Radius + circle.Radius) && dist > System.Math.Abs(Radius - circle.Radius)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Geometry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Signed clearance between two closed polygons, plus the unit direction that
|
||||||
|
/// increases it by moving the first polygon.
|
||||||
|
/// </summary>
|
||||||
|
public struct ClearanceResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// > 0: minimum boundary distance. 0: touching. < 0: penetration depth
|
||||||
|
/// (the translation of <c>a</c> along <see cref="Direction"/> needed to end
|
||||||
|
/// contact).
|
||||||
|
/// </summary>
|
||||||
|
public double Distance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit direction for translating <c>a</c> away from <c>b</c>. For penetration
|
||||||
|
/// this is the minimum-translation direction. Never zero-length; degenerate
|
||||||
|
/// (coincident-centroid) penetration resolves to a deterministic axis.
|
||||||
|
/// </summary>
|
||||||
|
public Vector Direction;
|
||||||
|
|
||||||
|
public ClearanceResult(double distance, Vector direction)
|
||||||
|
{
|
||||||
|
Distance = distance;
|
||||||
|
Direction = direction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Omnidirectional clearance between two closed, lines-only polygons.
|
||||||
|
/// Complements <see cref="SpatialQuery.DirectionalDistance"/> (movement along a
|
||||||
|
/// fixed ray) with the all-directions minimum distance and separating direction,
|
||||||
|
/// and <see cref="Collision"/> (boolean overlap) with depth and direction.
|
||||||
|
/// <para>
|
||||||
|
/// Reference quality, not hot-loop quality: separation is a brute-force
|
||||||
|
/// segment-pair minimum with a bounding-box reject, penetration is a
|
||||||
|
/// separating-axis sweep over both polygons' edge normals. The overlap verdict
|
||||||
|
/// defers to <see cref="Collision.HasOverlap(Polygon, Polygon, List{Polygon}, List{Polygon})"/>
|
||||||
|
/// so callers that validate with Collision never see a disagreeing kernel.
|
||||||
|
/// Rings with holes are handled by the caller: pass every ring pair (a part's
|
||||||
|
/// material boundary is its outer ring plus its hole rings).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class Clearance
|
||||||
|
{
|
||||||
|
public static ClearanceResult Between(Polygon a, Polygon b)
|
||||||
|
{
|
||||||
|
var linesA = a.ToLines();
|
||||||
|
var linesB = b.ToLines();
|
||||||
|
|
||||||
|
if (linesA.Count == 0 || linesB.Count == 0)
|
||||||
|
return new ClearanceResult(0, new Vector(1, 0));
|
||||||
|
|
||||||
|
if (Collision.HasOverlap(a, b))
|
||||||
|
return Penetration(linesA, linesB);
|
||||||
|
|
||||||
|
return Separation(linesA, linesB);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-negative minimum boundary (edge-to-edge) distance between two rings
|
||||||
|
/// and the direction that translates <paramref name="a"/> away from
|
||||||
|
/// <paramref name="b"/> at the closest contact. Never tests overlap: a ring
|
||||||
|
/// contained in another (a part inside a cutout ring) still reports its true
|
||||||
|
/// gap. For signed material clearance use <see cref="Between"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static ClearanceResult BoundaryDistance(Polygon a, Polygon b)
|
||||||
|
{
|
||||||
|
var linesA = a.ToLines();
|
||||||
|
var linesB = b.ToLines();
|
||||||
|
|
||||||
|
if (linesA.Count == 0 || linesB.Count == 0)
|
||||||
|
return new ClearanceResult(0, new Vector(1, 0));
|
||||||
|
|
||||||
|
return Separation(linesA, linesB);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum boundary distance between two non-overlapping rings and the
|
||||||
|
/// direction that translates <paramref name="linesA"/> away from
|
||||||
|
/// <paramref name="linesB"/> at the closest contact.
|
||||||
|
/// </summary>
|
||||||
|
private static ClearanceResult Separation(List<Line> linesA, List<Line> linesB)
|
||||||
|
{
|
||||||
|
var minDist = double.MaxValue;
|
||||||
|
var pa = Vector.Zero;
|
||||||
|
var pb = Vector.Zero;
|
||||||
|
|
||||||
|
var boxes = new Box[linesB.Count];
|
||||||
|
for (var i = 0; i < linesB.Count; i++)
|
||||||
|
boxes[i] = SegmentBox(linesB[i]);
|
||||||
|
|
||||||
|
foreach (var la in linesA)
|
||||||
|
{
|
||||||
|
var boxA = SegmentBox(la);
|
||||||
|
|
||||||
|
for (var i = 0; i < linesB.Count; i++)
|
||||||
|
{
|
||||||
|
if (!BoxesWithin(boxA, boxes[i], minDist))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var d = SegmentDistance(la, linesB[i], out var qa, out var qb);
|
||||||
|
if (d < minDist)
|
||||||
|
{
|
||||||
|
minDist = d;
|
||||||
|
pa = qa;
|
||||||
|
pb = qb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dir = pa - pb;
|
||||||
|
var len = Magnitude(dir);
|
||||||
|
|
||||||
|
if (len <= Tolerance.Epsilon)
|
||||||
|
dir = CentroidAway(linesA, linesB);
|
||||||
|
else
|
||||||
|
dir = dir / len;
|
||||||
|
|
||||||
|
return new ClearanceResult(minDist, dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Penetration depth and minimum-translation direction along the separating-
|
||||||
|
/// axis candidates of both rings. Per candidate axis the true translation
|
||||||
|
/// depth is used (exit distance to the far side), so containment reports the
|
||||||
|
/// depth that actually ends contact, not the interval-intersection length.
|
||||||
|
/// Depth is reported as a negative clearance.
|
||||||
|
/// </summary>
|
||||||
|
private static ClearanceResult Penetration(List<Line> linesA, List<Line> linesB)
|
||||||
|
{
|
||||||
|
var ca = Centroid(linesA);
|
||||||
|
var cb = Centroid(linesB);
|
||||||
|
|
||||||
|
var bestDepth = double.MaxValue;
|
||||||
|
var bestDir = new Vector(1, 0);
|
||||||
|
|
||||||
|
var bestAxis = -1;
|
||||||
|
|
||||||
|
for (var axis = 0; axis < 2; axis++)
|
||||||
|
{
|
||||||
|
var lines = axis == 0 ? linesA : linesB;
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var edge = line.pt2 - line.pt1;
|
||||||
|
var n = new Vector(edge.Y, -edge.X);
|
||||||
|
var len = Magnitude(n);
|
||||||
|
if (len <= Tolerance.Epsilon)
|
||||||
|
continue;
|
||||||
|
n = n / len;
|
||||||
|
|
||||||
|
var (minA, maxA) = Project(linesA, n);
|
||||||
|
var (minB, maxB) = Project(linesB, n);
|
||||||
|
|
||||||
|
if (maxA <= minB || maxB <= minA)
|
||||||
|
continue; // separating axis found
|
||||||
|
|
||||||
|
// Depth pushing a away from b along ±n.
|
||||||
|
var forward = maxB - minA; // move a in +n until minA >= maxB
|
||||||
|
var backward = maxA - minB; // move a in -n until maxA <= minB
|
||||||
|
|
||||||
|
double depth;
|
||||||
|
Vector dir;
|
||||||
|
if (forward <= backward)
|
||||||
|
{
|
||||||
|
depth = forward;
|
||||||
|
dir = n;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
depth = backward;
|
||||||
|
dir = -n;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth < bestDepth - Tolerance.Epsilon || bestAxis < 0)
|
||||||
|
{
|
||||||
|
bestDepth = depth;
|
||||||
|
bestDir = dir;
|
||||||
|
bestAxis = axis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestAxis < 0)
|
||||||
|
{
|
||||||
|
// No candidate axis (degenerate rings): deterministic fallback.
|
||||||
|
var away = ca - cb;
|
||||||
|
var len = Magnitude(away);
|
||||||
|
bestDir = len > Tolerance.Epsilon ? away / len : new Vector(1, 0);
|
||||||
|
bestDepth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ClearanceResult(-bestDepth, bestDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector CentroidAway(List<Line> linesA, List<Line> linesB)
|
||||||
|
{
|
||||||
|
var away = Centroid(linesA) - Centroid(linesB);
|
||||||
|
var len = Magnitude(away);
|
||||||
|
return len > Tolerance.Epsilon ? away / len : new Vector(1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector Centroid(List<Line> lines)
|
||||||
|
{
|
||||||
|
var sum = Vector.Zero;
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
sum += line.pt1;
|
||||||
|
sum += line.pt2;
|
||||||
|
}
|
||||||
|
return sum / (2 * lines.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (double Min, double Max) Project(List<Line> lines, Vector n)
|
||||||
|
{
|
||||||
|
var min = double.MaxValue;
|
||||||
|
var max = double.MinValue;
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var d1 = line.pt1.DotProduct(n);
|
||||||
|
var d2 = line.pt2.DotProduct(n);
|
||||||
|
if (d1 < min)
|
||||||
|
min = d1;
|
||||||
|
if (d1 > max)
|
||||||
|
max = d1;
|
||||||
|
if (d2 < min)
|
||||||
|
min = d2;
|
||||||
|
if (d2 > max)
|
||||||
|
max = d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum distance between two segments with the closest points.
|
||||||
|
/// Non-parallel segments use the classic clamped closest-point solve;
|
||||||
|
/// (near-)parallel segments fall back to the four endpoint-to-segment
|
||||||
|
/// distances, which is where the minimum always lies.
|
||||||
|
/// </summary>
|
||||||
|
private static double SegmentDistance(Line a, Line b, out Vector pa, out Vector pb)
|
||||||
|
{
|
||||||
|
var p = a.pt1;
|
||||||
|
var r = a.pt2 - a.pt1;
|
||||||
|
var q = b.pt1;
|
||||||
|
var s = b.pt2 - b.pt1;
|
||||||
|
|
||||||
|
var rxr = r.DotProduct(r);
|
||||||
|
var sxs = s.DotProduct(s);
|
||||||
|
var rxs = r.DotProduct(s);
|
||||||
|
|
||||||
|
const double eps = 1e-12;
|
||||||
|
|
||||||
|
var denom = rxr * sxs - rxs * rxs;
|
||||||
|
if (denom > eps && rxr > eps && sxs > eps)
|
||||||
|
{
|
||||||
|
// Minimize |(p + r t) - (q + s u)|^2; setting both partials to
|
||||||
|
// zero and solving (Cramer) with d0 = p - q:
|
||||||
|
// t = ((r.s)(d0.s) - (d0.r)(s.s)) / (rr.ss - (r.s)^2)
|
||||||
|
// u = ((r.r)(d0.s) - (r.s)(d0.r)) / (rr.ss - (r.s)^2)
|
||||||
|
var d0 = p - q;
|
||||||
|
var d0r = d0.DotProduct(r);
|
||||||
|
var d0s = d0.DotProduct(s);
|
||||||
|
|
||||||
|
var t = Clamp((rxs * d0s - d0r * sxs) / denom, 0, 1);
|
||||||
|
var u = Clamp((rxs * t + d0s) / sxs, 0, 1); // nearest u on b for clamped t
|
||||||
|
t = Clamp((rxs * u - d0r) / rxr, 0, 1); // re-solve t for clamped u
|
||||||
|
|
||||||
|
pa = p + r * t;
|
||||||
|
pb = q + s * u;
|
||||||
|
return pa.DistanceTo(pb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Degenerate or parallel: the minimum is attained at an endpoint.
|
||||||
|
var bestPa = p;
|
||||||
|
var bestPb = q;
|
||||||
|
var best = double.MaxValue;
|
||||||
|
|
||||||
|
void Consider(Vector pt, Line seg, bool ptOnA)
|
||||||
|
{
|
||||||
|
var d = seg.pt2 - seg.pt1;
|
||||||
|
var len2 = d.DotProduct(d);
|
||||||
|
var u = len2 <= eps ? 0 : Clamp((pt - seg.pt1).DotProduct(d) / len2, 0, 1);
|
||||||
|
var on = seg.pt1 + d * u;
|
||||||
|
var dist = pt.DistanceTo(on);
|
||||||
|
if (dist < best)
|
||||||
|
{
|
||||||
|
best = dist;
|
||||||
|
bestPa = ptOnA ? pt : on;
|
||||||
|
bestPb = ptOnA ? on : pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Consider(p, b, true);
|
||||||
|
Consider(a.pt2, b, true);
|
||||||
|
Consider(q, a, false);
|
||||||
|
Consider(b.pt2, a, false);
|
||||||
|
|
||||||
|
pa = bestPa;
|
||||||
|
pb = bestPb;
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Clamp(double v, double lo, double hi) =>
|
||||||
|
v < lo ? lo : (v > hi ? hi : v);
|
||||||
|
|
||||||
|
private static double Magnitude(Vector v) => System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
|
||||||
|
|
||||||
|
private static Box SegmentBox(Line line)
|
||||||
|
{
|
||||||
|
return new Box(
|
||||||
|
System.Math.Min(line.pt1.X, line.pt2.X),
|
||||||
|
System.Math.Min(line.pt1.Y, line.pt2.Y),
|
||||||
|
System.Math.Abs(line.pt2.X - line.pt1.X),
|
||||||
|
System.Math.Abs(line.pt2.Y - line.pt1.Y)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool BoxesWithin(Box a, Box b, double distance)
|
||||||
|
{
|
||||||
|
return !(
|
||||||
|
a.Right + distance < b.Left
|
||||||
|
|| b.Right + distance < a.Left
|
||||||
|
|| a.Top + distance < b.Bottom
|
||||||
|
|| b.Top + distance < a.Bottom
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Clipper2Lib;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Geometry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Region offsetting through Clipper2, for CPU-side preparation only: work done
|
||||||
|
/// once per drawing, rotation or spacing whose output is cached and fed to hot
|
||||||
|
/// loops. Per-pair tests (<see cref="Collision"/>) stay hand-rolled so they can
|
||||||
|
/// be ported to a GPU kernel.
|
||||||
|
/// </summary>
|
||||||
|
public static class ClipperBridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Decimal places Clipper keeps (1e-4 in either inches or mm).
|
||||||
|
/// </summary>
|
||||||
|
public const int Precision = 4;
|
||||||
|
|
||||||
|
private const double MiterLimit = 2.0;
|
||||||
|
|
||||||
|
private const double ConservativeJoinFactor = 0.25;
|
||||||
|
|
||||||
|
private const double ValidationJoinFactor = 0.1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a polygon to a Clipper path, dropping the closing vertex and
|
||||||
|
/// orienting it positive (CCW) or negative (CW).
|
||||||
|
/// </summary>
|
||||||
|
public static PathD ToPath(Polygon polygon, bool positive)
|
||||||
|
{
|
||||||
|
var path = ToPath(polygon, new Vector());
|
||||||
|
|
||||||
|
if (path.Count >= 3 && Clipper.IsPositive(path) != positive)
|
||||||
|
path.Reverse();
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a polygon to a Clipper path with an optional offset, dropping the
|
||||||
|
/// closing vertex and keeping the polygon's own winding.
|
||||||
|
/// </summary>
|
||||||
|
public static PathD ToPath(Polygon polygon, Vector offset)
|
||||||
|
{
|
||||||
|
var verts = polygon.Vertices;
|
||||||
|
var n = verts.Count;
|
||||||
|
|
||||||
|
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
|
||||||
|
n--;
|
||||||
|
|
||||||
|
var path = new PathD(n);
|
||||||
|
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y));
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a Clipper path to a closed polygon with updated bounds.
|
||||||
|
/// </summary>
|
||||||
|
public static Polygon ToPolygon(PathD path)
|
||||||
|
{
|
||||||
|
var polygon = new Polygon();
|
||||||
|
|
||||||
|
foreach (var pt in path)
|
||||||
|
polygon.Vertices.Add(new Vector(pt.x, pt.y));
|
||||||
|
|
||||||
|
polygon.Close();
|
||||||
|
polygon.UpdateBounds();
|
||||||
|
return polygon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flattens a profile into a Clipper region: perimeter positive, cutouts negative.
|
||||||
|
/// </summary>
|
||||||
|
public static PathsD ToRegion(ShapeProfile profile, double tolerance, bool circumscribe)
|
||||||
|
{
|
||||||
|
var region = new PathsD(profile.Cutouts.Count + 1);
|
||||||
|
AddShape(region, profile.Perimeter, tolerance, circumscribe, positive: true);
|
||||||
|
|
||||||
|
// A cutout is flattened the opposite way: circumscribing it would shrink the
|
||||||
|
// material around it, so inscribe instead to keep the region conservative.
|
||||||
|
foreach (var cutout in profile.Cutouts)
|
||||||
|
AddShape(region, cutout, tolerance, !circumscribe, positive: false);
|
||||||
|
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offsets a part region outward by <paramref name="distance"/>: the perimeter
|
||||||
|
/// grows and the cutouts shrink. Features narrower than twice the distance
|
||||||
|
/// collapse, and cutouts that close up disappear. Joins are round, with chords
|
||||||
|
/// no more than <paramref name="tolerance"/> from the true arc.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="circumscribe">
|
||||||
|
/// When true, the result never under-estimates the offset: perimeter arcs are
|
||||||
|
/// flattened outside the true curve, cutout arcs inside it, and the inflation is
|
||||||
|
/// padded by the round-join chord error and Clipper's rounding.
|
||||||
|
/// </param>
|
||||||
|
public static OffsetRegion Offset(
|
||||||
|
ShapeProfile profile,
|
||||||
|
double distance,
|
||||||
|
double tolerance,
|
||||||
|
bool circumscribe = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var region = ToRegion(profile, tolerance, circumscribe);
|
||||||
|
return Offset(region, distance, tolerance, circumscribe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offsets a single closed shape outward, ignoring any cutouts. A perimeter that
|
||||||
|
/// curls back on itself (a C shape with a narrow mouth) can gain holes.
|
||||||
|
/// </summary>
|
||||||
|
public static OffsetRegion OffsetPerimeter(
|
||||||
|
Shape perimeter,
|
||||||
|
double distance,
|
||||||
|
double tolerance,
|
||||||
|
bool circumscribe = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var polygon = Flatten(perimeter, tolerance, circumscribe);
|
||||||
|
return OffsetPerimeter(polygon, distance, tolerance, circumscribe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offsets a closed polygon outward, whatever its winding.
|
||||||
|
/// </summary>
|
||||||
|
public static OffsetRegion OffsetPerimeter(
|
||||||
|
Polygon perimeter,
|
||||||
|
double distance,
|
||||||
|
double tolerance,
|
||||||
|
bool circumscribe = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var region = new PathsD(1);
|
||||||
|
AddPolygon(region, perimeter, positive: true);
|
||||||
|
return Offset(region, distance, tolerance, circumscribe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offsets an already-flattened region (outers positive, holes negative).
|
||||||
|
/// A distance of zero only unions the region, with no conservative padding.
|
||||||
|
/// </summary>
|
||||||
|
public static OffsetRegion Offset(
|
||||||
|
PathsD region,
|
||||||
|
double distance,
|
||||||
|
double tolerance,
|
||||||
|
bool circumscribe = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Round joins put their vertices on the true arc, so each chord sits inside
|
||||||
|
// it by up to the join tolerance. In conservative mode, joins use a finer
|
||||||
|
// tolerance and the inflation is padded by it (plus Clipper's rounding).
|
||||||
|
var delta = distance;
|
||||||
|
var joinTolerance = tolerance;
|
||||||
|
|
||||||
|
if (circumscribe && distance > 0)
|
||||||
|
{
|
||||||
|
joinTolerance = tolerance * ConservativeJoinFactor;
|
||||||
|
delta += joinTolerance + 0.5 * System.Math.Pow(10, -Precision);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inflate(region, delta, joinTolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offset for checking a finished layout against its spacing. Arcs are flattened
|
||||||
|
/// as in conservative mode (perimeter arcs circumscribed, cutout arcs inscribed),
|
||||||
|
/// but round joins use a tenth of the tolerance and nothing is padded, so a layout
|
||||||
|
/// exactly at the spacing passes. The only under-estimate is the join chord error
|
||||||
|
/// at convex corners, at most a tenth of <paramref name="tolerance"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static OffsetRegion OffsetForValidation(
|
||||||
|
ShapeProfile profile,
|
||||||
|
double distance,
|
||||||
|
double tolerance
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var region = ToRegion(profile, tolerance, circumscribe: true);
|
||||||
|
return Inflate(region, distance, tolerance * ValidationJoinFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OffsetRegion Inflate(PathsD region, double delta, double joinTolerance)
|
||||||
|
{
|
||||||
|
var inflated =
|
||||||
|
delta <= 0
|
||||||
|
? Union(region)
|
||||||
|
: Clipper.InflatePaths(
|
||||||
|
region,
|
||||||
|
delta,
|
||||||
|
JoinType.Round,
|
||||||
|
EndType.Polygon,
|
||||||
|
MiterLimit,
|
||||||
|
Precision,
|
||||||
|
joinTolerance
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = new OffsetRegion(new List<Polygon>(), new List<Polygon>());
|
||||||
|
|
||||||
|
foreach (var path in inflated)
|
||||||
|
{
|
||||||
|
if (path.Count < 3)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (Clipper.IsPositive(path))
|
||||||
|
result.Outers.Add(ToPolygon(path));
|
||||||
|
else
|
||||||
|
result.Holes.Add(ToPolygon(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Miter-offsets a closed polygon by <paramref name="delta"/> (positive grows it,
|
||||||
|
/// negative shrinks it). Returns the largest resulting polygon (CCW), or null
|
||||||
|
/// when the polygon collapses.
|
||||||
|
/// </summary>
|
||||||
|
public static Polygon OffsetMiter(Polygon polygon, double delta)
|
||||||
|
{
|
||||||
|
var path = ToPath(polygon, positive: true);
|
||||||
|
|
||||||
|
if (path.Count < 3)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var inflated = Clipper.InflatePaths(
|
||||||
|
new PathsD { path },
|
||||||
|
delta,
|
||||||
|
JoinType.Miter,
|
||||||
|
EndType.Polygon,
|
||||||
|
MiterLimit,
|
||||||
|
Precision
|
||||||
|
);
|
||||||
|
|
||||||
|
PathD largest = null;
|
||||||
|
var largestArea = 0.0;
|
||||||
|
|
||||||
|
foreach (var candidate in inflated)
|
||||||
|
{
|
||||||
|
var area = Clipper.Area(candidate);
|
||||||
|
|
||||||
|
if (area > largestArea)
|
||||||
|
{
|
||||||
|
largest = candidate;
|
||||||
|
largestArea = area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return largest == null ? null : ToPolygon(largest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flattens a closed shape to a polygon whose chords stay within
|
||||||
|
/// <paramref name="tolerance"/> of every arc. Inscribed, the vertices lie on the
|
||||||
|
/// arcs. Circumscribed, arc endpoints stay on the arc and the interior vertices sit
|
||||||
|
/// on tangent intersections, so the polygon never falls inside the curve and never
|
||||||
|
/// pokes past the straight edges an arc meets.
|
||||||
|
/// </summary>
|
||||||
|
public static Polygon Flatten(Shape shape, double tolerance, bool circumscribe)
|
||||||
|
{
|
||||||
|
var polygon = new Polygon();
|
||||||
|
|
||||||
|
foreach (var entity in shape.Entities)
|
||||||
|
{
|
||||||
|
switch (entity)
|
||||||
|
{
|
||||||
|
case Line line:
|
||||||
|
polygon.Vertices.Add(line.StartPoint);
|
||||||
|
polygon.Vertices.Add(line.EndPoint);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Arc arc:
|
||||||
|
AddArc(polygon.Vertices, arc, tolerance, circumscribe);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Circle circle:
|
||||||
|
AddCircle(polygon.Vertices, circle, tolerance, circumscribe);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
polygon.Close();
|
||||||
|
polygon.Cleanup();
|
||||||
|
polygon.UpdateBounds();
|
||||||
|
return polygon;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddArc(List<Vector> points, Arc arc, double tolerance, bool circumscribe)
|
||||||
|
{
|
||||||
|
if (!circumscribe)
|
||||||
|
{
|
||||||
|
points.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sweep = arc.SweepAngle();
|
||||||
|
var segments = CircumscribedSegments(arc.Radius, sweep, tolerance);
|
||||||
|
var step = (arc.IsReversed ? -sweep : sweep) / segments;
|
||||||
|
var r = arc.Radius / System.Math.Cos(System.Math.Abs(step) / 2);
|
||||||
|
|
||||||
|
points.Add(arc.StartPoint());
|
||||||
|
|
||||||
|
for (var i = 0; i < segments; i++)
|
||||||
|
{
|
||||||
|
var angle = arc.StartAngle + step * (i + 0.5);
|
||||||
|
points.Add(
|
||||||
|
new Vector(
|
||||||
|
arc.Center.X + r * System.Math.Cos(angle),
|
||||||
|
arc.Center.Y + r * System.Math.Sin(angle)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
points.Add(arc.EndPoint());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddCircle(
|
||||||
|
List<Vector> points,
|
||||||
|
Circle circle,
|
||||||
|
double tolerance,
|
||||||
|
bool circumscribe
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (!circumscribe)
|
||||||
|
{
|
||||||
|
points.AddRange(circle.ToPoints(circle.SegmentsForTolerance(tolerance)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var segments = CircumscribedSegments(circle.Radius, Angle.TwoPI, tolerance);
|
||||||
|
var step = Angle.TwoPI / segments;
|
||||||
|
var r = circle.Radius / System.Math.Cos(step / 2);
|
||||||
|
|
||||||
|
for (var i = 0; i < segments; i++)
|
||||||
|
{
|
||||||
|
points.Add(
|
||||||
|
new Vector(
|
||||||
|
circle.Center.X + r * System.Math.Cos(step * i),
|
||||||
|
circle.Center.Y + r * System.Math.Sin(step * i)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Segments for a circumscribed arc: a tangent-intersection vertex sits
|
||||||
|
/// radius / cos(step / 2) from the center, so keep that within the tolerance, and
|
||||||
|
/// keep each step at 90 degrees or less so the tangents meet close to the arc.
|
||||||
|
/// </summary>
|
||||||
|
private static int CircumscribedSegments(double radius, double sweep, double tolerance)
|
||||||
|
{
|
||||||
|
var maxHalfStep = System.Math.Acos(radius / (radius + tolerance));
|
||||||
|
var segments = (int)System.Math.Ceiling(System.Math.Abs(sweep) / (2 * maxHalfStep));
|
||||||
|
var quarters = (int)System.Math.Ceiling(System.Math.Abs(sweep) / Angle.HalfPI);
|
||||||
|
|
||||||
|
return System.Math.Max(1, System.Math.Max(segments, quarters));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PathsD Union(PathsD region)
|
||||||
|
{
|
||||||
|
var clipper = new ClipperD(Precision);
|
||||||
|
clipper.AddSubject(region);
|
||||||
|
|
||||||
|
var solution = new PathsD();
|
||||||
|
clipper.Execute(ClipType.Union, FillRule.NonZero, solution);
|
||||||
|
return solution;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddShape(
|
||||||
|
PathsD region,
|
||||||
|
Shape shape,
|
||||||
|
double tolerance,
|
||||||
|
bool circumscribe,
|
||||||
|
bool positive
|
||||||
|
)
|
||||||
|
{
|
||||||
|
AddPolygon(region, Flatten(shape, tolerance, circumscribe), positive);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddPolygon(PathsD region, Polygon polygon, bool positive)
|
||||||
|
{
|
||||||
|
if (polygon.Vertices.Count < 3)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var path = ToPath(polygon, positive);
|
||||||
|
|
||||||
|
if (path.Count >= 3)
|
||||||
|
region.Add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of <see cref="ClipperBridge.Offset(ShapeProfile, double, double, bool)"/>:
|
||||||
|
/// outer boundaries (CCW) and holes (CW), as closed polygons.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record OffsetRegion(List<Polygon> Outers, List<Polygon> Holes)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The outer boundary with the largest area, or null when the region is empty.
|
||||||
|
/// </summary>
|
||||||
|
public Polygon LargestOuter()
|
||||||
|
{
|
||||||
|
Polygon best = null;
|
||||||
|
var bestArea = 0.0;
|
||||||
|
|
||||||
|
foreach (var outer in Outers)
|
||||||
|
{
|
||||||
|
var area = outer.Area();
|
||||||
|
|
||||||
|
if (best == null || area > bestArea)
|
||||||
|
{
|
||||||
|
best = outer;
|
||||||
|
bestArea = area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+229
-142
@@ -1,12 +1,33 @@
|
|||||||
using OpenNest.Math;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Geometry
|
namespace OpenNest.Geometry
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Polygon overlap test with hole subtraction. This is the reference implementation
|
||||||
|
/// for a future GPU kernel, so it deliberately stays hand-rolled instead of using
|
||||||
|
/// Clipper (which is CPU-only and allocation-heavy; see <see cref="ClipperBridge"/>
|
||||||
|
/// for the CPU preparation that feeds it).
|
||||||
|
/// <para>
|
||||||
|
/// GPU-port contract. Per-polygon preparation, done once per drawing and rotation,
|
||||||
|
/// then cached and uploaded: the spacing offset (<see cref="ClipperBridge"/>),
|
||||||
|
/// triangulation (<see cref="ConvexDecomposition.Triangulate"/>) of the outline and
|
||||||
|
/// each hole, and the bounding box of every polygon and triangle. Per-pair work,
|
||||||
|
/// kernel-shaped (fixed-size, loop-only, no recursion): the bounding-box rejects,
|
||||||
|
/// Sutherland-Hodgman clipping of convex triangle pairs (<c>ClipConvex</c>), and
|
||||||
|
/// subtraction of hole triangles from the clipped regions (<c>SubtractTriangles</c>).
|
||||||
|
/// Inputs are closed, lines-only polygons; winding is normalized by triangulation.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
public static class Collision
|
public static class Collision
|
||||||
{
|
{
|
||||||
public static CollisionResult Check(Polygon a, Polygon b,
|
public static CollisionResult Check(
|
||||||
List<Polygon> holesA = null, List<Polygon> holesB = null)
|
Polygon a,
|
||||||
|
Polygon b,
|
||||||
|
List<Polygon> holesA = null,
|
||||||
|
List<Polygon> holesB = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
// Step 1: Bounding box pre-filter
|
// Step 1: Bounding box pre-filter
|
||||||
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
|
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
|
||||||
@@ -15,50 +36,61 @@ namespace OpenNest.Geometry
|
|||||||
// Step 2: Quick intersection test for crossing points
|
// Step 2: Quick intersection test for crossing points
|
||||||
var intersectionPoints = FindCrossingPoints(a, b);
|
var intersectionPoints = FindCrossingPoints(a, b);
|
||||||
|
|
||||||
// Step 3: Convex decomposition
|
// Steps 3-5: Convex decomposition, triangle-pair clipping, hole subtraction
|
||||||
var trisA = TriangulateWithBounds(a);
|
var regions = OverlapRegions(a, b, holesA, holesB);
|
||||||
var trisB = TriangulateWithBounds(b);
|
|
||||||
|
|
||||||
// Step 4: Clip all triangle pairs
|
|
||||||
var regions = new List<Polygon>();
|
|
||||||
|
|
||||||
foreach (var triA in trisA)
|
|
||||||
{
|
|
||||||
foreach (var triB in trisB)
|
|
||||||
{
|
|
||||||
if (!BoundingBoxesOverlap(triA.BoundingBox, triB.BoundingBox))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var clipped = ClipConvex(triA, triB);
|
|
||||||
if (clipped != null)
|
|
||||||
regions.Add(clipped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Hole subtraction
|
|
||||||
if (regions.Count > 0)
|
|
||||||
regions = SubtractHoles(regions, holesA, holesB);
|
|
||||||
|
|
||||||
if (regions.Count == 0)
|
|
||||||
return new CollisionResult(false, regions, intersectionPoints);
|
|
||||||
|
|
||||||
// Step 6: Build result
|
// Step 6: Build result
|
||||||
return new CollisionResult(true, regions, intersectionPoints);
|
return new CollisionResult(regions.Count > 0, regions, intersectionPoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool HasOverlap(Polygon a, Polygon b,
|
public static bool HasOverlap(
|
||||||
List<Polygon> holesA = null, List<Polygon> holesB = null)
|
Polygon a,
|
||||||
|
Polygon b,
|
||||||
|
List<Polygon> holesA = null,
|
||||||
|
List<Polygon> holesB = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
|
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Full check is needed: crossing points alone miss containment cases
|
// Clipping decides the verdict, including containment (one polygon entirely
|
||||||
// (one polygon entirely inside another has zero edge crossings).
|
// inside another has zero edge crossings). Crossing points never affect it,
|
||||||
return Check(a, b, holesA, holesB).Overlaps;
|
// so this overlap-only path skips them.
|
||||||
|
return OverlapRegions(a, b, holesA, holesB).Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<CollisionResult> CheckAll(List<Polygon> polygons,
|
/// <summary>
|
||||||
List<List<Polygon>> holes = null)
|
/// <see cref="HasOverlap(Polygon, Polygon, List{Polygon}, List{Polygon})"/> with the
|
||||||
|
/// per-polygon triangulation supplied by the caller. Triangulations are resolved lazily,
|
||||||
|
/// only after the bounding boxes overlap, and must come from <see cref="Triangulate"/> on
|
||||||
|
/// the same polygon; the verdict is then identical. The triangles are only read.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool HasOverlap(
|
||||||
|
Polygon a,
|
||||||
|
Func<List<Polygon>> trianglesA,
|
||||||
|
Polygon b,
|
||||||
|
Func<List<Polygon>> trianglesB,
|
||||||
|
List<Polygon> holesA = null,
|
||||||
|
List<Polygon> holesB = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return OverlapRegions(trianglesA(), trianglesB(), holesA, holesB).Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The triangulation <see cref="Check"/> and <see cref="HasOverlap(Polygon, Polygon, List{Polygon}, List{Polygon})"/>
|
||||||
|
/// use for <paramref name="polygon"/>: ear-clipped triangles with bounds updated. Callers
|
||||||
|
/// that reuse it must not mutate the polygon or the triangles.
|
||||||
|
/// </summary>
|
||||||
|
internal static List<Polygon> Triangulate(Polygon polygon) => TriangulateWithBounds(polygon);
|
||||||
|
|
||||||
|
public static List<CollisionResult> CheckAll(
|
||||||
|
List<Polygon> polygons,
|
||||||
|
List<List<Polygon>> holes = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var results = new List<CollisionResult>();
|
var results = new List<CollisionResult>();
|
||||||
|
|
||||||
@@ -78,8 +110,7 @@ namespace OpenNest.Geometry
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool HasAnyOverlap(List<Polygon> polygons,
|
public static bool HasAnyOverlap(List<Polygon> polygons, List<List<Polygon>> holes = null)
|
||||||
List<List<Polygon>> holes = null)
|
|
||||||
{
|
{
|
||||||
for (var i = 0; i < polygons.Count; i++)
|
for (var i = 0; i < polygons.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -96,18 +127,57 @@ namespace OpenNest.Geometry
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Positive-area overlap regions left after hole subtraction: the verdict shared by
|
||||||
|
/// <see cref="Check"/> and <see cref="HasOverlap"/>. Callers apply the polygon-level
|
||||||
|
/// bounding-box pre-filter first.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Polygon> OverlapRegions(
|
||||||
|
Polygon a,
|
||||||
|
Polygon b,
|
||||||
|
List<Polygon> holesA,
|
||||||
|
List<Polygon> holesB
|
||||||
|
) => OverlapRegions(TriangulateWithBounds(a), TriangulateWithBounds(b), holesA, holesB);
|
||||||
|
|
||||||
|
private static List<Polygon> OverlapRegions(
|
||||||
|
List<Polygon> trisA,
|
||||||
|
List<Polygon> trisB,
|
||||||
|
List<Polygon> holesA,
|
||||||
|
List<Polygon> holesB
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var regions = new List<Polygon>();
|
||||||
|
|
||||||
|
foreach (var triA in trisA)
|
||||||
|
{
|
||||||
|
foreach (var triB in trisB)
|
||||||
|
{
|
||||||
|
if (!BoundingBoxesOverlap(triA.BoundingBox, triB.BoundingBox))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var clipped = ClipConvex(triA, triB);
|
||||||
|
if (clipped != null)
|
||||||
|
regions.Add(clipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regions.Count > 0)
|
||||||
|
regions = SubtractHoles(regions, holesA, holesB);
|
||||||
|
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool BoundingBoxesOverlap(Box a, Box b)
|
private static bool BoundingBoxesOverlap(Box a, Box b)
|
||||||
{
|
{
|
||||||
var overlapX = System.Math.Min(a.Right, b.Right)
|
var overlapX = System.Math.Min(a.Right, b.Right) - System.Math.Max(a.Left, b.Left);
|
||||||
- System.Math.Max(a.Left, b.Left);
|
var overlapY = System.Math.Min(a.Top, b.Top) - System.Math.Max(a.Bottom, b.Bottom);
|
||||||
var overlapY = System.Math.Min(a.Top, b.Top)
|
|
||||||
- System.Math.Max(a.Bottom, b.Bottom);
|
|
||||||
|
|
||||||
return overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon;
|
return overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Vector> FindCrossingPoints(Polygon a, Polygon b)
|
private static List<Vector> FindCrossingPoints(Polygon a, Polygon b)
|
||||||
{
|
{
|
||||||
|
PerfCounters.CountCrossingPointScan();
|
||||||
if (!Intersect.Intersects(a, b, out var rawPts))
|
if (!Intersect.Intersects(a, b, out var rawPts))
|
||||||
return new List<Vector>();
|
return new List<Vector>();
|
||||||
|
|
||||||
@@ -149,6 +219,7 @@ namespace OpenNest.Geometry
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Polygon> TriangulateWithBounds(Polygon polygon)
|
private static List<Polygon> TriangulateWithBounds(Polygon polygon)
|
||||||
{
|
{
|
||||||
|
PerfCounters.CountPolygonTriangulation();
|
||||||
var tris = ConvexDecomposition.Triangulate(polygon);
|
var tris = ConvexDecomposition.Triangulate(polygon);
|
||||||
foreach (var tri in tris)
|
foreach (var tri in tris)
|
||||||
tri.UpdateBounds();
|
tri.UpdateBounds();
|
||||||
@@ -161,67 +232,14 @@ namespace OpenNest.Geometry
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static Polygon ClipConvex(Polygon subject, Polygon clip)
|
private static Polygon ClipConvex(Polygon subject, Polygon clip)
|
||||||
{
|
{
|
||||||
var output = new List<Vector>(subject.Vertices);
|
var output = OpenVertices(subject);
|
||||||
|
var clipVerts = OpenVertices(clip);
|
||||||
// Remove closing vertex if present
|
for (var i = 0; i < clipVerts.Count && output.Count >= 3; i++)
|
||||||
if (output.Count > 1 && output[0].X == output[output.Count - 1].X
|
|
||||||
&& output[0].Y == output[output.Count - 1].Y)
|
|
||||||
output.RemoveAt(output.Count - 1);
|
|
||||||
|
|
||||||
var clipVerts = new List<Vector>(clip.Vertices);
|
|
||||||
if (clipVerts.Count > 1 && clipVerts[0].X == clipVerts[clipVerts.Count - 1].X
|
|
||||||
&& clipVerts[0].Y == clipVerts[clipVerts.Count - 1].Y)
|
|
||||||
clipVerts.RemoveAt(clipVerts.Count - 1);
|
|
||||||
|
|
||||||
for (var i = 0; i < clipVerts.Count; i++)
|
|
||||||
{
|
{
|
||||||
if (output.Count == 0)
|
output = ClipHalfSpace(output, clipVerts[i], clipVerts[(i + 1) % clipVerts.Count], true);
|
||||||
return null;
|
|
||||||
|
|
||||||
var edgeStart = clipVerts[i];
|
|
||||||
var edgeEnd = clipVerts[(i + 1) % clipVerts.Count];
|
|
||||||
var input = output;
|
|
||||||
output = new List<Vector>();
|
|
||||||
|
|
||||||
for (var j = 0; j < input.Count; j++)
|
|
||||||
{
|
|
||||||
var current = input[j];
|
|
||||||
var next = input[(j + 1) % input.Count];
|
|
||||||
var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
|
|
||||||
var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
|
|
||||||
|
|
||||||
if (currentInside)
|
|
||||||
{
|
|
||||||
output.Add(current);
|
|
||||||
if (!nextInside)
|
|
||||||
{
|
|
||||||
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
|
|
||||||
if (ix.IsValid())
|
|
||||||
output.Add(ix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (nextInside)
|
|
||||||
{
|
|
||||||
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
|
|
||||||
if (ix.IsValid())
|
|
||||||
output.Add(ix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (output.Count < 3)
|
return PositiveAreaPolygon(output);
|
||||||
return null;
|
|
||||||
|
|
||||||
var result = new Polygon();
|
|
||||||
result.Vertices.AddRange(output);
|
|
||||||
result.Close();
|
|
||||||
result.UpdateBounds();
|
|
||||||
|
|
||||||
// Reject degenerate slivers
|
|
||||||
if (result.Area() < Tolerance.Epsilon)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -234,33 +252,20 @@ namespace OpenNest.Geometry
|
|||||||
- (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X);
|
- (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Intersection of lines (a1->a2) and (b1->b2). Returns Vector.Invalid if parallel.
|
|
||||||
/// </summary>
|
|
||||||
private static Vector LineIntersection(Vector a1, Vector a2, Vector b1, Vector b2)
|
|
||||||
{
|
|
||||||
var d1x = a2.X - a1.X;
|
|
||||||
var d1y = a2.Y - a1.Y;
|
|
||||||
var d2x = b2.X - b1.X;
|
|
||||||
var d2y = b2.Y - b1.Y;
|
|
||||||
var cross = d1x * d2y - d1y * d2x;
|
|
||||||
|
|
||||||
if (System.Math.Abs(cross) < Tolerance.Epsilon)
|
|
||||||
return Vector.Invalid;
|
|
||||||
|
|
||||||
var t = ((b1.X - a1.X) * d2y - (b1.Y - a1.Y) * d2x) / cross;
|
|
||||||
return new Vector(a1.X + t * d1x, a1.Y + t * d1y);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Subtracts holes from overlap regions.
|
/// Subtracts holes from overlap regions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Polygon> SubtractHoles(List<Polygon> regions,
|
private static List<Polygon> SubtractHoles(
|
||||||
List<Polygon> holesA, List<Polygon> holesB)
|
List<Polygon> regions,
|
||||||
|
List<Polygon> holesA,
|
||||||
|
List<Polygon> holesB
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var allHoles = new List<Polygon>();
|
var allHoles = new List<Polygon>();
|
||||||
if (holesA != null) allHoles.AddRange(holesA);
|
if (holesA != null)
|
||||||
if (holesB != null) allHoles.AddRange(holesB);
|
allHoles.AddRange(holesA);
|
||||||
|
if (holesB != null)
|
||||||
|
allHoles.AddRange(holesB);
|
||||||
|
|
||||||
if (allHoles.Count == 0)
|
if (allHoles.Count == 0)
|
||||||
return regions;
|
return regions;
|
||||||
@@ -286,8 +291,9 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Subtracts hole triangles from a region. Conservative: partial overlaps
|
/// Subtracts hole triangles from a convex region. At each edge, emit the outside
|
||||||
/// keep the full piece triangle (acceptable for visual shading).
|
/// portion and carry only the inside remainder to the next edge. The emitted
|
||||||
|
/// pieces are disjoint and convex, so no repeated triangulation is needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
|
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
|
||||||
{
|
{
|
||||||
@@ -295,36 +301,117 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
foreach (var holeTri in holeTris)
|
foreach (var holeTri in holeTris)
|
||||||
{
|
{
|
||||||
if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var next = new List<Polygon>();
|
var next = new List<Polygon>();
|
||||||
|
|
||||||
foreach (var piece in current)
|
foreach (var piece in current)
|
||||||
{
|
{
|
||||||
var pieceTris = TriangulateWithBounds(piece);
|
// Subtraction must also remove thin fragments created by clipping.
|
||||||
|
// The pair-level length tolerance would skip some of these even
|
||||||
|
// when their area is large enough to count as an overlap.
|
||||||
|
var a = piece.BoundingBox;
|
||||||
|
var b = holeTri.BoundingBox;
|
||||||
|
if (a.Right <= b.Left || b.Right <= a.Left || a.Top <= b.Bottom || b.Top <= a.Bottom)
|
||||||
|
{
|
||||||
|
next.Add(piece);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var pieceTri in pieceTris)
|
var remainder = OpenVertices(piece);
|
||||||
|
var holeVerts = OpenVertices(holeTri);
|
||||||
|
for (var i = 0; i < holeVerts.Count && remainder.Count >= 3; i++)
|
||||||
{
|
{
|
||||||
var inside = ClipConvex(pieceTri, holeTri);
|
var start = holeVerts[i];
|
||||||
if (inside == null)
|
var end = holeVerts[(i + 1) % holeVerts.Count];
|
||||||
{
|
var outside = PositiveAreaPolygon(ClipHalfSpace(remainder, start, end, false));
|
||||||
// No overlap with hole - keep
|
if (outside != null)
|
||||||
next.Add(pieceTri);
|
next.Add(outside);
|
||||||
}
|
remainder = ClipHalfSpace(remainder, start, end, true);
|
||||||
else if (inside.Area() < pieceTri.Area() - Tolerance.Epsilon)
|
|
||||||
{
|
|
||||||
// Partial overlap - keep the piece (conservative)
|
|
||||||
next.Add(pieceTri);
|
|
||||||
}
|
|
||||||
// else: fully inside hole - discard
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
current = next;
|
current = next;
|
||||||
|
if (current.Count == 0)
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clips an open vertex list against one half-space. Classification and
|
||||||
|
/// interpolation use the same signed cross products: intersections always
|
||||||
|
/// lie on the input segment. An epsilon-shifted inside test combined with
|
||||||
|
/// intersections on the unshifted line can extrapolate and create material.
|
||||||
|
/// Apply the area tolerance only to the resulting polygons, not to edge signs.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Vector> ClipHalfSpace(
|
||||||
|
List<Vector> vertices,
|
||||||
|
Vector edgeStart,
|
||||||
|
Vector edgeEnd,
|
||||||
|
bool inside
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var kept = new List<Vector>();
|
||||||
|
for (var i = 0; i < vertices.Count; i++)
|
||||||
|
{
|
||||||
|
var current = vertices[i];
|
||||||
|
var next = vertices[(i + 1) % vertices.Count];
|
||||||
|
var currentDistance = Cross(edgeStart, edgeEnd, current);
|
||||||
|
var nextDistance = Cross(edgeStart, edgeEnd, next);
|
||||||
|
if (inside ? currentDistance >= 0 : currentDistance <= 0)
|
||||||
|
AddDistinct(kept, current);
|
||||||
|
|
||||||
|
// Only strict opposite signs cross the line. Boundary endpoints
|
||||||
|
// are already kept, and near-parallel crossings need no cutoff.
|
||||||
|
if ((currentDistance < 0 && nextDistance > 0) || (currentDistance > 0 && nextDistance < 0))
|
||||||
|
{
|
||||||
|
var t = currentDistance / (currentDistance - nextDistance);
|
||||||
|
AddDistinct(kept, new Vector(
|
||||||
|
current.X + t * (next.X - current.X),
|
||||||
|
current.Y + t * (next.Y - current.Y)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kept.Count > 1 && SamePoint(kept[0], kept[kept.Count - 1]))
|
||||||
|
kept.RemoveAt(kept.Count - 1);
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SamePoint(Vector a, Vector b) => a.X == b.X && a.Y == b.Y;
|
||||||
|
|
||||||
|
private static void AddDistinct(List<Vector> vertices, Vector point)
|
||||||
|
{
|
||||||
|
if (vertices.Count == 0 || !SamePoint(vertices[vertices.Count - 1], point))
|
||||||
|
vertices.Add(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Vector> OpenVertices(Polygon polygon)
|
||||||
|
{
|
||||||
|
var vertices = new List<Vector>(polygon.Vertices);
|
||||||
|
if (vertices.Count > 1 && SamePoint(vertices[0], vertices[vertices.Count - 1]))
|
||||||
|
vertices.RemoveAt(vertices.Count - 1);
|
||||||
|
return vertices;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Polygon PositiveAreaPolygon(List<Vector> vertices)
|
||||||
|
{
|
||||||
|
if (vertices.Count < 3)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Measure relative to a vertex to avoid cancellation of world-coordinate
|
||||||
|
// products when a small clipped fragment is far from the origin.
|
||||||
|
var twiceArea = 0.0;
|
||||||
|
for (var i = 1; i + 1 < vertices.Count; i++)
|
||||||
|
twiceArea += Cross(vertices[0], vertices[i], vertices[i + 1]);
|
||||||
|
if (System.Math.Abs(twiceArea) <= 2 * Tolerance.Epsilon)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var polygon = new Polygon();
|
||||||
|
polygon.Vertices.AddRange(vertices);
|
||||||
|
// Polygon.Close uses fuzzy Vector equality; clipping needs an exact
|
||||||
|
// closing vertex even when the last edge is shorter than Epsilon.
|
||||||
|
polygon.Vertices.Add(vertices[0]);
|
||||||
|
polygon.UpdateBounds();
|
||||||
|
return polygon;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
public class CollisionResult
|
public class CollisionResult
|
||||||
{
|
{
|
||||||
public static readonly CollisionResult None = new(false, new List<Polygon>(), new List<Vector>());
|
public static readonly CollisionResult None = new(
|
||||||
|
false,
|
||||||
|
new List<Polygon>(),
|
||||||
|
new List<Vector>()
|
||||||
|
);
|
||||||
|
|
||||||
public CollisionResult(bool overlaps, List<Polygon> overlapRegions, List<Vector> intersectionPoints)
|
public CollisionResult(
|
||||||
|
bool overlaps,
|
||||||
|
List<Polygon> overlapRegions,
|
||||||
|
List<Vector> intersectionPoints
|
||||||
|
)
|
||||||
{
|
{
|
||||||
Overlaps = overlaps;
|
Overlaps = overlaps;
|
||||||
OverlapRegions = overlapRegions;
|
OverlapRegions = overlapRegions;
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ namespace OpenNest.Geometry
|
|||||||
var verts = new List<Vector>(polygon.Vertices);
|
var verts = new List<Vector>(polygon.Vertices);
|
||||||
|
|
||||||
// Remove closing vertex if polygon is closed.
|
// Remove closing vertex if polygon is closed.
|
||||||
if (verts.Count > 1 && verts[0].X == verts[verts.Count - 1].X
|
if (
|
||||||
&& verts[0].Y == verts[verts.Count - 1].Y)
|
verts.Count > 1
|
||||||
|
&& verts[0].X == verts[verts.Count - 1].X
|
||||||
|
&& verts[0].Y == verts[verts.Count - 1].Y
|
||||||
|
)
|
||||||
verts.RemoveAt(verts.Count - 1);
|
verts.RemoveAt(verts.Count - 1);
|
||||||
|
|
||||||
if (verts.Count < 3)
|
if (verts.Count < 3)
|
||||||
@@ -84,8 +87,14 @@ namespace OpenNest.Geometry
|
|||||||
/// Tests whether the vertex at curr forms an ear (a convex vertex whose
|
/// Tests whether the vertex at curr forms an ear (a convex vertex whose
|
||||||
/// triangle contains no other polygon vertices).
|
/// triangle contains no other polygon vertices).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool IsEar(Vector prev, Vector curr, Vector next,
|
private static bool IsEar(
|
||||||
List<Vector> verts, List<int> indices, int n)
|
Vector prev,
|
||||||
|
Vector curr,
|
||||||
|
Vector next,
|
||||||
|
List<Vector> verts,
|
||||||
|
List<int> indices,
|
||||||
|
int n
|
||||||
|
)
|
||||||
{
|
{
|
||||||
// Must be convex (CCW turn).
|
// Must be convex (CCW turn).
|
||||||
if (Cross(prev, curr, next) <= 0)
|
if (Cross(prev, curr, next) <= 0)
|
||||||
@@ -139,14 +148,12 @@ namespace OpenNest.Geometry
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static double SignedArea(List<Vector> verts)
|
private static double SignedArea(List<Vector> verts)
|
||||||
{
|
{
|
||||||
|
// World-coordinate products can erase the sign of a small polygon's area
|
||||||
|
// far from the origin, leaving CW outlines/holes untriangulated. Measure
|
||||||
|
// relative to a vertex, just as the clipping kernel measures its fragments.
|
||||||
var area = 0.0;
|
var area = 0.0;
|
||||||
|
for (var i = 1; i + 1 < verts.Count; i++)
|
||||||
for (var i = 0; i < verts.Count; i++)
|
area += Cross(verts[0], verts[i], verts[i + 1]);
|
||||||
{
|
|
||||||
var j = (i + 1) % verts.Count;
|
|
||||||
area += verts[i].X * verts[j].Y;
|
|
||||||
area -= verts[j].X * verts[i].Y;
|
|
||||||
}
|
|
||||||
|
|
||||||
return area * 0.5;
|
return area * 0.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
foreach (var p in sorted)
|
foreach (var p in sorted)
|
||||||
{
|
{
|
||||||
while (lower.Count >= 2 && Cross(lower[lower.Count - 2], lower[lower.Count - 1], p) <= 0)
|
while (
|
||||||
|
lower.Count >= 2
|
||||||
|
&& Cross(lower[lower.Count - 2], lower[lower.Count - 1], p) <= 0
|
||||||
|
)
|
||||||
lower.RemoveAt(lower.Count - 1);
|
lower.RemoveAt(lower.Count - 1);
|
||||||
|
|
||||||
lower.Add(p);
|
lower.Add(p);
|
||||||
@@ -32,7 +35,10 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
var p = sorted[i];
|
var p = sorted[i];
|
||||||
|
|
||||||
while (upper.Count >= 2 && Cross(upper[upper.Count - 2], upper[upper.Count - 1], p) <= 0)
|
while (
|
||||||
|
upper.Count >= 2
|
||||||
|
&& Cross(upper[upper.Count - 2], upper[upper.Count - 1], p) <= 0
|
||||||
|
)
|
||||||
upper.RemoveAt(upper.Count - 1);
|
upper.RemoveAt(upper.Count - 1);
|
||||||
|
|
||||||
upper.Add(p);
|
upper.Add(p);
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace OpenNest.Geometry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable flat-array polygon with a uniform edge grid, used as an outer-shell
|
||||||
|
/// clearance prefilter. Two closed polygons share positive area only when an edge pair
|
||||||
|
/// crosses/touches or one polygon's vertex lies strictly inside the other; neither
|
||||||
|
/// happening certifies the two closed regions (hence any materials inside them) are
|
||||||
|
/// clear. <see cref="Relate"/> returns Clear only in that certified case and Unknown
|
||||||
|
/// for uncertain contacts, so it can only ever skip the exact <see cref="Collision"/>
|
||||||
|
/// gate when the exact gate would also find no overlap - the exact gate triangulates
|
||||||
|
/// both polygons per call and dominates runtime on finely flattened arc geometry.
|
||||||
|
/// <para>
|
||||||
|
/// A <see cref="EdgeGridPolygonTemplate"/> holds the shared geometry; <see cref="Translated"/>
|
||||||
|
/// produces a placement in world coordinates in O(1) - translation leaves the grid and
|
||||||
|
/// all cell indices unchanged, only the predicate coordinates shift.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EdgeGridPolygon
|
||||||
|
{
|
||||||
|
/// <summary>Vertex-on-segment / collinearity tolerance for conservative touches.</summary>
|
||||||
|
private const double TouchEps = 1e-9;
|
||||||
|
|
||||||
|
private readonly EdgeGridPolygonTemplate _template;
|
||||||
|
|
||||||
|
/// <summary>Translation applied to the shared template geometry.</summary>
|
||||||
|
private readonly double Dx;
|
||||||
|
|
||||||
|
private readonly double Dy;
|
||||||
|
|
||||||
|
private EdgeGridPolygon(EdgeGridPolygonTemplate template, double dx, double dy)
|
||||||
|
{
|
||||||
|
_template = template;
|
||||||
|
Dx = dx;
|
||||||
|
Dy = dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double MinX => _template.MinX + Dx;
|
||||||
|
private double MinY => _template.MinY + Dy;
|
||||||
|
private double MaxX => _template.MaxX + Dx;
|
||||||
|
private double MaxY => _template.MaxY + Dy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds from a closed <see cref="Polygon"/> (last vertex may repeat the first).
|
||||||
|
/// Returns null when the polygon has no usable ring - callers treat that as
|
||||||
|
/// "no information" and fall through to the exact gate.
|
||||||
|
/// </summary>
|
||||||
|
public static EdgeGridPolygon? From(Polygon polygon)
|
||||||
|
{
|
||||||
|
var template = EdgeGridPolygonTemplate.Build(polygon);
|
||||||
|
return template == null ? null : new EdgeGridPolygon(template, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a placement sharing immutable geometry, with an added translation.</summary>
|
||||||
|
public EdgeGridPolygon Translated(double dx, double dy) => new(_template, Dx + dx, Dy + dy);
|
||||||
|
|
||||||
|
private double X(int i) => _template.X[i] + Dx;
|
||||||
|
private double Y(int i) => _template.Y[i] + Dy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Certifies disjoint filled perimeters. Any crossing, containment or uncertain
|
||||||
|
/// boundary contact returns Unknown and must defer to the exact collision test.
|
||||||
|
/// Holes need not be supplied: removing material cannot invalidate Clear.
|
||||||
|
/// </summary>
|
||||||
|
public static ShellRelation Relate(EdgeGridPolygon a, EdgeGridPolygon b)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
a.MaxX <= b.MinX
|
||||||
|
|| b.MaxX <= a.MinX
|
||||||
|
|| a.MaxY <= b.MinY
|
||||||
|
|| b.MaxY <= a.MinY
|
||||||
|
)
|
||||||
|
return ShellRelation.Clear; // disjoint bounding boxes
|
||||||
|
|
||||||
|
// One walk per direction reports the strongest edge relation: a transversal
|
||||||
|
// crossing shares a positive-area wedge (overlap); a mere touch shares zero
|
||||||
|
// area but may hide a crossing in near-degenerate coordinates (unknown).
|
||||||
|
var edge = EdgeRelation(a, b);
|
||||||
|
if (edge < 2)
|
||||||
|
{
|
||||||
|
var back = EdgeRelation(b, a);
|
||||||
|
if (back > edge)
|
||||||
|
edge = back;
|
||||||
|
}
|
||||||
|
if (edge == 2)
|
||||||
|
return ShellRelation.Unknown;
|
||||||
|
|
||||||
|
// Only fully disjoint boundaries can certify clearance. Point touches and
|
||||||
|
// collinear/near-degenerate contacts always defer to the reference test.
|
||||||
|
switch (edge)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
if (ContainsPointStrictly(a, b.X(0), b.Y(0)))
|
||||||
|
return ShellRelation.Unknown;
|
||||||
|
if (ContainsPointStrictly(b, a.X(0), a.Y(0)))
|
||||||
|
return ShellRelation.Unknown;
|
||||||
|
return ShellRelation.Clear;
|
||||||
|
default:
|
||||||
|
return ShellRelation.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies whether any edge of <paramref name="q"/> crosses or touches the boundary of
|
||||||
|
/// <paramref name="p"/>. Walks p's grid using each query edge's own bbox cells.
|
||||||
|
/// p's grid lives in p's LOCAL frame (the template's own coordinates), so the
|
||||||
|
/// query edge is converted by subtracting p's translation first.
|
||||||
|
/// </summary>
|
||||||
|
private static int EdgeRelation(EdgeGridPolygon p, EdgeGridPolygon q)
|
||||||
|
{
|
||||||
|
var t = p._template;
|
||||||
|
var n = t.Count;
|
||||||
|
Span<int> seen = n <= 1024 ? stackalloc int[n] : new int[n];
|
||||||
|
seen.Clear();
|
||||||
|
var head = t.Head;
|
||||||
|
var nodeEdge = t.NodeEdge;
|
||||||
|
var nodeNext = t.NodeNext;
|
||||||
|
var no = q._template.Count;
|
||||||
|
var strongest = 0;
|
||||||
|
|
||||||
|
for (var e = 0; e < no; e++)
|
||||||
|
{
|
||||||
|
// Stamp per QUERY edge: a grid edge may need testing against every query
|
||||||
|
// edge; the dedupe only collapses cells an individual query edge crosses
|
||||||
|
// more than once.
|
||||||
|
var stamp = e + 1;
|
||||||
|
var i2 = (e + 1) % no;
|
||||||
|
var p0x = q.X(e) - p.Dx;
|
||||||
|
var p0y = q.Y(e) - p.Dy;
|
||||||
|
var p1x = q.X(i2) - p.Dx;
|
||||||
|
var p1y = q.Y(i2) - p.Dy;
|
||||||
|
|
||||||
|
var c0 = ColLow(t, p0x, p1x);
|
||||||
|
if (c0 > ColHigh(t, p0x, p1x))
|
||||||
|
continue;
|
||||||
|
var c1 = ColHigh(t, p0x, p1x);
|
||||||
|
var r0 = RowLow(t, p0y, p1y);
|
||||||
|
if (r0 > RowHigh(t, p0y, p1y))
|
||||||
|
continue;
|
||||||
|
var r1 = RowHigh(t, p0y, p1y);
|
||||||
|
|
||||||
|
for (var r = r0; r <= r1; r++)
|
||||||
|
for (var c = c0; c <= c1; c++)
|
||||||
|
for (var nIdx = head[r * t.Cols + c]; nIdx >= 0; nIdx = nodeNext[nIdx])
|
||||||
|
{
|
||||||
|
var ea = nodeEdge[nIdx];
|
||||||
|
if (seen[ea] == stamp)
|
||||||
|
continue;
|
||||||
|
seen[ea] = stamp;
|
||||||
|
var a2 = (ea + 1) % n;
|
||||||
|
var relation = SegmentRelation(
|
||||||
|
t.X[ea], t.Y[ea], t.X[a2], t.Y[a2], p0x, p0y, p1x, p1y
|
||||||
|
);
|
||||||
|
if (relation == 2)
|
||||||
|
return 2; // transversal crossing
|
||||||
|
if (relation > strongest)
|
||||||
|
strongest = relation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strongest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Segment-pair relation: 2 = transversal crossing (strict sign flips on both
|
||||||
|
/// orientations - the regions share a positive-area wedge); 1 = a clean endpoint
|
||||||
|
/// touch (zero shared area by itself; callers decide via interior-vertex tests);
|
||||||
|
/// 3 = collinear or near-degenerate contact (a shared boundary segment can hide
|
||||||
|
/// either a same-side positive overlap or an opposite-side tangency, so it must
|
||||||
|
/// defer to the exact gate); 0 = disjoint.
|
||||||
|
/// </summary>
|
||||||
|
private static int SegmentRelation(
|
||||||
|
double ax, double ay, double bx, double by, double cx, double cy, double dx, double dy
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var rx = bx - ax;
|
||||||
|
var ry = by - ay;
|
||||||
|
var sx = dx - cx;
|
||||||
|
var sy = dy - cy;
|
||||||
|
var d1 = rx * (cy - ay) - ry * (cx - ax);
|
||||||
|
var d2 = rx * (dy - ay) - ry * (dx - ax);
|
||||||
|
var d3 = sx * (ay - cy) - sy * (ax - cx);
|
||||||
|
var d4 = sx * (by - cy) - sy * (bx - cx);
|
||||||
|
|
||||||
|
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)))
|
||||||
|
return 2; // proper crossing
|
||||||
|
|
||||||
|
// A near-zero orientation means the configuration is collinear or too close to
|
||||||
|
// classify; only exact-zero orientations get the clean point-touch verdict.
|
||||||
|
var scale = System.Math.Max(
|
||||||
|
1e-30,
|
||||||
|
System.Math.Max(System.Math.Abs(rx) + System.Math.Abs(ry), System.Math.Abs(sx) + System.Math.Abs(sy))
|
||||||
|
);
|
||||||
|
var eps = TouchEps * scale;
|
||||||
|
var nearDegenerate =
|
||||||
|
(System.Math.Abs(d1) <= eps && d1 != 0)
|
||||||
|
|| (System.Math.Abs(d2) <= eps && d2 != 0)
|
||||||
|
|| (System.Math.Abs(d3) <= eps && d3 != 0)
|
||||||
|
|| (System.Math.Abs(d4) <= eps && d4 != 0);
|
||||||
|
var exactDegenerate = d1 == 0 || d2 == 0 || d3 == 0 || d4 == 0;
|
||||||
|
|
||||||
|
var touch =
|
||||||
|
(d1 == 0 && PointOnSegment(cx, cy, ax, ay, bx, by))
|
||||||
|
|| (d2 == 0 && PointOnSegment(dx, dy, ax, ay, bx, by))
|
||||||
|
|| (d3 == 0 && PointOnSegment(ax, ay, cx, cy, dx, dy))
|
||||||
|
|| (d4 == 0 && PointOnSegment(bx, by, cx, cy, dx, dy));
|
||||||
|
|
||||||
|
if (nearDegenerate)
|
||||||
|
return 3;
|
||||||
|
if (exactDegenerate)
|
||||||
|
// Collinear: contact along a segment (or too close to tell) must defer to
|
||||||
|
// the exact gate; collinear but disjoint edges simply do not touch.
|
||||||
|
return touch ? 3 : 0;
|
||||||
|
if (touch)
|
||||||
|
return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PointOnSegment(
|
||||||
|
double px, double py, double ax, double ay, double bx, double by
|
||||||
|
) =>
|
||||||
|
System.Math.Min(ax, bx) - TouchEps <= px
|
||||||
|
&& px <= System.Math.Max(ax, bx) + TouchEps
|
||||||
|
&& System.Math.Min(ay, by) - TouchEps <= py
|
||||||
|
&& py <= System.Math.Max(ay, by) + TouchEps;
|
||||||
|
|
||||||
|
/// <summary>Strict ray-cast containment (boundary touches are excluded upstream).</summary>
|
||||||
|
private static bool ContainsPointStrictly(EdgeGridPolygon poly, double px, double py)
|
||||||
|
{
|
||||||
|
var t = poly._template;
|
||||||
|
var inside = false;
|
||||||
|
var n = t.Count;
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var j = (i + 1) % n;
|
||||||
|
var yi = poly.Y(i);
|
||||||
|
var yj = poly.Y(j);
|
||||||
|
if ((yi > py) != (yj > py))
|
||||||
|
{
|
||||||
|
var xAt = poly.X(i) + (py - yi) / (yj - yi) * (poly.X(j) - poly.X(i));
|
||||||
|
if (px < xAt)
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ColLow(EdgeGridPolygonTemplate t, double a, double b) =>
|
||||||
|
System.Math.Clamp((int)System.Math.Floor((System.Math.Min(a, b) - t.MinX) / t.CellSize), 0, t.Cols);
|
||||||
|
|
||||||
|
private static int ColHigh(EdgeGridPolygonTemplate t, double a, double b) =>
|
||||||
|
System.Math.Clamp((int)System.Math.Floor((System.Math.Max(a, b) - t.MinX) / t.CellSize), -1, t.Cols - 1);
|
||||||
|
|
||||||
|
private static int RowLow(EdgeGridPolygonTemplate t, double a, double b) =>
|
||||||
|
System.Math.Clamp((int)System.Math.Floor((System.Math.Min(a, b) - t.MinY) / t.CellSize), 0, t.Rows);
|
||||||
|
|
||||||
|
private static int RowHigh(EdgeGridPolygonTemplate t, double a, double b) =>
|
||||||
|
System.Math.Clamp((int)System.Math.Floor((System.Math.Max(a, b) - t.MinY) / t.CellSize), -1, t.Rows - 1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared, immutable grid geometry for <see cref="EdgeGridPolygon"/>; the grid is defined
|
||||||
|
/// relative to the shape's own local coordinates, so translated instances reuse it.
|
||||||
|
/// Per-query deduplication scratch is local, so placements may be queried concurrently.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class EdgeGridPolygonTemplate
|
||||||
|
{
|
||||||
|
internal readonly double[] X;
|
||||||
|
internal readonly double[] Y;
|
||||||
|
internal readonly int Count;
|
||||||
|
internal readonly double MinX;
|
||||||
|
internal readonly double MinY;
|
||||||
|
internal readonly double MaxX;
|
||||||
|
internal readonly double MaxY;
|
||||||
|
|
||||||
|
internal readonly double CellSize;
|
||||||
|
|
||||||
|
internal readonly int Cols;
|
||||||
|
internal readonly int Rows;
|
||||||
|
internal readonly int[] Head;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Grid nodes as parallel (edge, next) arrays: an edge spanning several cells gets
|
||||||
|
/// one node PER cell - a single next-per-edge chain would corrupt the other cells'
|
||||||
|
/// chains and silently drop edges from the walk.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly int[] NodeEdge;
|
||||||
|
|
||||||
|
internal readonly int[] NodeNext;
|
||||||
|
|
||||||
|
|
||||||
|
private EdgeGridPolygonTemplate(
|
||||||
|
double[] x,
|
||||||
|
double[] y,
|
||||||
|
int count,
|
||||||
|
double minX,
|
||||||
|
double minY,
|
||||||
|
double maxX,
|
||||||
|
double maxY
|
||||||
|
)
|
||||||
|
{
|
||||||
|
X = x;
|
||||||
|
Y = y;
|
||||||
|
Count = count;
|
||||||
|
MinX = minX;
|
||||||
|
MinY = minY;
|
||||||
|
MaxX = maxX;
|
||||||
|
MaxY = maxY;
|
||||||
|
|
||||||
|
var extentX = System.Math.Max(maxX - minX, 1e-9);
|
||||||
|
var extentY = System.Math.Max(maxY - minY, 1e-9);
|
||||||
|
CellSize = System.Math.Max(System.Math.Max(extentX, extentY) / 16.0, 1e-9);
|
||||||
|
Cols = System.Math.Clamp((int)System.Math.Ceiling(extentX / CellSize) + 1, 1, 48);
|
||||||
|
Rows = System.Math.Clamp((int)System.Math.Ceiling(extentY / CellSize) + 1, 1, 48);
|
||||||
|
Head = new int[Cols * Rows];
|
||||||
|
Array.Fill(Head, -1);
|
||||||
|
|
||||||
|
// Pass 1: count nodes; pass 2: fill (edge, next) node arrays.
|
||||||
|
var cellsPerEdge = new int[count];
|
||||||
|
var total = 0;
|
||||||
|
for (var e = 0; e < count; e++)
|
||||||
|
{
|
||||||
|
var i2 = (e + 1) % count;
|
||||||
|
var c0 = ClampCol(System.Math.Min(x[e], x[i2]) - minX);
|
||||||
|
var c1 = ClampCol(System.Math.Max(x[e], x[i2]) - minX);
|
||||||
|
var r0 = ClampRow(System.Math.Min(y[e], y[i2]) - minY);
|
||||||
|
var r1 = ClampRow(System.Math.Max(y[e], y[i2]) - minY);
|
||||||
|
cellsPerEdge[e] = (c1 - c0 + 1) * (r1 - r0 + 1);
|
||||||
|
total += cellsPerEdge[e];
|
||||||
|
}
|
||||||
|
NodeEdge = new int[total];
|
||||||
|
NodeNext = new int[total];
|
||||||
|
var node = 0;
|
||||||
|
for (var e = 0; e < count; e++)
|
||||||
|
{
|
||||||
|
var i2 = (e + 1) % count;
|
||||||
|
var c0 = ClampCol(System.Math.Min(x[e], x[i2]) - minX);
|
||||||
|
var c1 = ClampCol(System.Math.Max(x[e], x[i2]) - minX);
|
||||||
|
var r0 = ClampRow(System.Math.Min(y[e], y[i2]) - minY);
|
||||||
|
var r1 = ClampRow(System.Math.Max(y[e], y[i2]) - minY);
|
||||||
|
for (var r = r0; r <= r1; r++)
|
||||||
|
for (var c = c0; c <= c1; c++)
|
||||||
|
{
|
||||||
|
var cell = r * Cols + c;
|
||||||
|
NodeEdge[node] = e;
|
||||||
|
NodeNext[node] = Head[cell];
|
||||||
|
Head[cell] = node;
|
||||||
|
node++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int ClampCol(double dx) =>
|
||||||
|
System.Math.Clamp((int)System.Math.Floor(dx / CellSize), 0, Cols - 1);
|
||||||
|
|
||||||
|
private int ClampRow(double dy) =>
|
||||||
|
System.Math.Clamp((int)System.Math.Floor(dy / CellSize), 0, Rows - 1);
|
||||||
|
|
||||||
|
internal static EdgeGridPolygonTemplate? Build(Polygon polygon)
|
||||||
|
{
|
||||||
|
var vertices = polygon.Vertices;
|
||||||
|
var n = vertices.Count;
|
||||||
|
if (n >= 2 && vertices[0].X == vertices[n - 1].X && vertices[0].Y == vertices[n - 1].Y)
|
||||||
|
n--;
|
||||||
|
if (n < 3)
|
||||||
|
return null;
|
||||||
|
var xs = new double[n];
|
||||||
|
var ys = new double[n];
|
||||||
|
var minX = double.MaxValue;
|
||||||
|
var minY = double.MaxValue;
|
||||||
|
var maxX = double.MinValue;
|
||||||
|
var maxY = double.MinValue;
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var vx = vertices[i].X;
|
||||||
|
var vy = vertices[i].Y;
|
||||||
|
xs[i] = vx;
|
||||||
|
ys[i] = vy;
|
||||||
|
if (vx < minX)
|
||||||
|
minX = vx;
|
||||||
|
if (vx > maxX)
|
||||||
|
maxX = vx;
|
||||||
|
if (vy < minY)
|
||||||
|
minY = vy;
|
||||||
|
if (vy > maxY)
|
||||||
|
maxY = vy;
|
||||||
|
}
|
||||||
|
return new EdgeGridPolygonTemplate(xs, ys, n, minX, minY, maxX, maxY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Conservative result of an outer-perimeter prefilter.</summary>
|
||||||
|
public enum ShellRelation
|
||||||
|
{
|
||||||
|
/// <summary>Filled perimeters, and therefore their material, are disjoint.</summary>
|
||||||
|
Clear,
|
||||||
|
/// <summary>Run an exact collision test; the prefilter cannot certify clearance.</summary>
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user