mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 02:04:13 -04:00
Compare commits
403
Commits
f0fe79f0f1
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eff148c24e | ||
|
|
648ffbca65 | ||
|
|
4d94273a00 | ||
|
|
028adee046 | ||
|
|
3ddfff242d | ||
|
|
5fc563bbca | ||
|
|
785a861ab9 | ||
|
|
986ba3dee1 | ||
|
|
a73a85ebc0 | ||
|
|
8628c69098 | ||
|
|
c1c4b18f17 | ||
|
|
7e887921ea | ||
|
|
f50705c491 | ||
|
|
81f3c006ab | ||
|
|
b83518feac | ||
|
|
1826e6d5b5 | ||
|
|
fc300705a8 | ||
|
|
1c923976a8 | ||
|
|
f4d5cfe98e | ||
|
|
e7e4012974 | ||
|
|
9196d9157f | ||
|
|
7468483a10 | ||
|
|
432eb54633 | ||
|
|
64459f7bc8 | ||
|
|
7622d63556 | ||
|
|
414e9b4e75 | ||
|
|
76f73f7a87 | ||
|
|
6b7d0d7f90 | ||
|
|
47140cc59e | ||
|
|
fa3c5e348e | ||
|
|
d4544ea358 | ||
|
|
acd1f21293 | ||
|
|
2399cc3936 | ||
|
|
bd3f529ccd | ||
|
|
a791b74945 | ||
|
|
4e60cebf68 | ||
|
|
63f2a4b555 | ||
|
|
ed35b94e7f | ||
|
|
e75f68dbb6 | ||
|
|
b8f319d3e8 | ||
|
|
91aef95b1b | ||
|
|
9110cd62e0 | ||
|
|
8e8b1de25f | ||
|
|
1add37d3e0 | ||
|
|
71f41e23e4 | ||
|
|
b591528aa2 | ||
|
|
12206ed8fd | ||
|
|
bb43118451 | ||
|
|
89356371db | ||
|
|
5af44b251c | ||
|
|
0a1d021bf7 | ||
|
|
6b4078aeb2 | ||
|
|
18a61717fe | ||
|
|
bec4db21a8 | ||
|
|
bc061488ee | ||
|
|
ea1270e1e7 | ||
|
|
8c21c62ec3 | ||
|
|
1c0320d17e | ||
|
|
4e6419fd2c | ||
|
|
c21f687987 | ||
|
|
c1c8d5fc36 | ||
|
|
2419a9e2e3 | ||
|
|
b8f3d3bf0a | ||
|
|
bfe2e518e6 | ||
|
|
48367da820 | ||
|
|
65d320d2e5 | ||
|
|
1b422ef99a | ||
|
|
faf3c9bcc4 | ||
|
|
8590f21c9c | ||
|
|
1769b8907d | ||
|
|
187234f459 | ||
|
|
a0ac7e5756 | ||
|
|
9a4f73b924 | ||
|
|
248972c6e7 | ||
|
|
124d70afe7 | ||
|
|
81d3253580 | ||
|
|
0ac72fad70 | ||
|
|
b83aeb0e41 | ||
|
|
f4d1d45c81 | ||
|
|
ca153a1c20 | ||
|
|
03b3ed47b2 | ||
|
|
1b5a6bf6da | ||
|
|
3fc32e56e1 | ||
|
|
3a9bd08f39 | ||
|
|
4a55d6664c | ||
|
|
3a8419e6e9 | ||
|
|
f145826309 | ||
|
|
da0c54f280 | ||
|
|
9920aba6eb | ||
|
|
0446127e11 | ||
|
|
eda4a846f0 | ||
|
|
32db086384 | ||
|
|
5f67a51bf1 | ||
|
|
20f8b046de | ||
|
|
bb104a07bb | ||
|
|
ea36c47a30 | ||
|
|
b8afcc6fdd | ||
|
|
1d8c5fe274 | ||
|
|
e43a7c99d0 | ||
|
|
b9b475ab77 | ||
|
|
d37b38622f | ||
|
|
4bbd8454b5 | ||
|
|
f3bca26fb9 | ||
|
|
9c530ca7d2 | ||
|
|
ebc54f0653 | ||
|
|
7127884584 | ||
|
|
6f7652ee83 | ||
|
|
fef93cbfa5 | ||
|
|
cf615dcef8 | ||
|
|
e8d974a2db | ||
|
|
9f7580a486 | ||
|
|
fb7ef6fd0d | ||
|
|
e00aa113a0 | ||
|
|
d7204e8d07 | ||
|
|
1597ee9069 | ||
|
|
d1109aaa43 | ||
|
|
085edeea3b | ||
|
|
70d886e4cf | ||
|
|
018e4e3dd4 | ||
|
|
e71fcea73d | ||
|
|
6addad7b2e | ||
|
|
7451596047 | ||
|
|
15cca0efd5 | ||
|
|
4842b80ceb | ||
|
|
418f75916b | ||
|
|
a15c54fbaf | ||
|
|
67fb80e660 | ||
|
|
5f9e8814a7 | ||
|
|
3ba8f4e3b2 | ||
|
|
2a5ebf1e8a | ||
|
|
90587803be | ||
|
|
b7dc7dee13 | ||
|
|
39a15aee14 | ||
|
|
23d258b886 | ||
|
|
60d3915af2 | ||
|
|
c989ba6fd0 | ||
|
|
761cee7c1f | ||
|
|
c49c387af5 | ||
|
|
9e15d5dceb | ||
|
|
eace08f351 | ||
|
|
96267e760e | ||
|
|
646d4c3975 | ||
|
|
1fadf6c4d3 | ||
|
|
f53bead0a8 | ||
|
|
1ec79ae594 | ||
|
|
d428357c8b | ||
|
|
142c77a27f | ||
|
|
b39509a6a3 | ||
|
|
6d1e0cb896 | ||
|
|
5d8874a9e7 | ||
|
|
b17fe1a318 | ||
|
|
7999e3cdde | ||
|
|
14a7ce43a7 | ||
|
|
bd5db59e42 | ||
|
|
25755a8850 | ||
|
|
c7b4ba4457 | ||
|
|
d8b763da13 | ||
|
|
c01a348243 | ||
|
|
e886bcfa35 | ||
|
|
4fa0f636ce | ||
|
|
94e23e4b7a | ||
|
|
591d08c627 | ||
|
|
6b6928213d | ||
|
|
a26e663f01 | ||
|
|
694d4b28cc | ||
|
|
d14071692b | ||
|
|
a368ef0115 | ||
|
|
62fd42346e | ||
|
|
56ab3a5f30 | ||
|
|
7a52058b0f | ||
|
|
7ce53008f1 | ||
|
|
4b6e131cec | ||
|
|
2e700df8ed | ||
|
|
c6f8a06dc7 | ||
|
|
96bcf6ded1 | ||
|
|
e9c312b9fb | ||
|
|
a854e34a3d | ||
|
|
3b9f5ca5fa | ||
|
|
0209a24008 | ||
|
|
8450ceee40 | ||
|
|
c55e375cf3 | ||
|
|
5e9eaf8a4c | ||
|
|
dbcf7dea65 | ||
|
|
98870e78a7 | ||
|
|
c9bb32470c | ||
|
|
e84d7f0e72 | ||
|
|
6426a98f82 | ||
|
|
23ffc14f3f | ||
|
|
b2deed6c5a | ||
|
|
1cc95c8115 | ||
|
|
038886a757 | ||
|
|
8664656658 | ||
|
|
d7fd2f8468 | ||
|
|
5bf3c5f9aa | ||
|
|
984d31a391 | ||
|
|
4f46fa15ea | ||
|
|
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 | ||
|
|
9b69c67572 | ||
|
|
aa88eee484 | ||
|
|
e0e3b96bed | ||
|
|
424ff15ebc | ||
|
|
9888fe6083 | ||
|
|
a2dcfc7484 | ||
|
|
ae704478af | ||
|
|
ecca71e185 | ||
|
|
a085339ba9 |
No files matched your search
@@ -0,0 +1,17 @@
|
||||
**
|
||||
!OpenNest.Server/
|
||||
!OpenNest.Server/**
|
||||
!OpenNest.Data/
|
||||
!OpenNest.Data/**
|
||||
!OpenNest.Core/
|
||||
!OpenNest.Core/**
|
||||
**/bin/**
|
||||
**/obj/**
|
||||
**/data/**
|
||||
**/*.db
|
||||
**/*.db-*
|
||||
**/.env*
|
||||
**/*.env
|
||||
**/*.env.*
|
||||
**/*.user
|
||||
**/Properties/launchSettings.json
|
||||
@@ -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,112 @@
|
||||
name: Cross-platform tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: Unit tests (${{ matrix.project }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
project:
|
||||
- OpenNest.Tests
|
||||
- OpenNest.Engine.Tests
|
||||
- OpenNest.IO.Tests
|
||||
- OpenNest.Server.Tests
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: Run ${{ matrix.project }}
|
||||
run: dotnet test "${{ matrix.project }}/${{ matrix.project }}.csproj"
|
||||
|
||||
synthetic-nests:
|
||||
name: Six synthetic Irregular nests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: Test fail-closed final CI check
|
||||
run: python3 scripts/test_ci_results.py -v
|
||||
- name: Check six synthetic Irregular nests
|
||||
timeout-minutes: 6
|
||||
run: |
|
||||
python3 scripts/test_check_synthetic_nests.py -v
|
||||
python3 scripts/check-synthetic-nests.py --parallel 2
|
||||
|
||||
# Preserve the existing check name for required-status consumers.
|
||||
tests:
|
||||
name: tests
|
||||
needs: [unit-tests, synthetic-nests]
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Require every test job to succeed
|
||||
env:
|
||||
UNIT_TEST_RESULT: ${{ needs.unit-tests.result }}
|
||||
SYNTHETIC_RESULT: ${{ needs.synthetic-nests.result }}
|
||||
run: bash scripts/check-ci-results.sh "$UNIT_TEST_RESULT" "$SYNTHETIC_RESULT"
|
||||
|
||||
windows-desktop:
|
||||
# WinForms tests need a Windows desktop runtime; the Linux job can only
|
||||
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop.
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 20
|
||||
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: Run Windows desktop and front-end tests
|
||||
# A test silent for 5 minutes is killed and named in the log, with a
|
||||
# mini dump in TestResults, instead of holding the runner until the
|
||||
# job timeout.
|
||||
run: |
|
||||
$failed = @()
|
||||
foreach ($project in @('OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||
dotnet test "$project/$project.csproj" -c Release --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||
if ($LASTEXITCODE -ne 0) { $failed += $project }
|
||||
}
|
||||
if ($failed.Count -gt 0) { throw "Failed: $($failed -join ', ')" }
|
||||
- name: Test acceptance evidence checker
|
||||
run: python scripts/test_windows_acceptance.py -v
|
||||
- name: Verify named Windows acceptance coverage
|
||||
if: always()
|
||||
run: python scripts/windows_acceptance.py --results TestResults
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: windows-desktop-test-results
|
||||
path: TestResults/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,287 @@
|
||||
name: Server image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'OpenNest.Server/**'
|
||||
- 'OpenNest.Data/**'
|
||||
- 'OpenNest.Core/**'
|
||||
- 'OpenNest.Server.Tests/**'
|
||||
- 'scripts/Server.Tests/**'
|
||||
- 'scripts/Test-ServerContainer.sh'
|
||||
- 'scripts/test_server_container_cleanup.py'
|
||||
- 'scripts/server_image_release.py'
|
||||
- 'scripts/test_server_image_release.py'
|
||||
- '.dockerignore'
|
||||
- 'compose.server.yaml'
|
||||
- '.github/workflows/server-image.yml'
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'OpenNest.Server/**'
|
||||
- 'OpenNest.Data/**'
|
||||
- 'OpenNest.Core/**'
|
||||
- 'OpenNest.Server.Tests/**'
|
||||
- 'scripts/Server.Tests/**'
|
||||
- 'scripts/Test-ServerContainer.sh'
|
||||
- 'scripts/test_server_container_cleanup.py'
|
||||
- 'scripts/server_image_release.py'
|
||||
- 'scripts/test_server_image_release.py'
|
||||
- '.dockerignore'
|
||||
- 'compose.server.yaml'
|
||||
- '.github/workflows/server-image.yml'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Approved existing vX.Y.Z tag to publish (dispatch from master only)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
# No tag-push trigger. Windows tag builds produce candidates, not publications.
|
||||
jobs:
|
||||
validate:
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
VERSION: '0.0.0'
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
LOCAL_IMAGE: opennest-server:ci
|
||||
steps:
|
||||
- name: Isolate logs, safe metadata and ephemeral auth
|
||||
run: |
|
||||
printf 'METADATA_DIR=%s/server-image-metadata\nRESULTS=%s/server-image-results\nDOCKER_CONFIG=%s/server-image-auth\n' \
|
||||
"$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||
env:
|
||||
DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet8
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: Test fail-closed release and cleanup helpers
|
||||
run: |
|
||||
python3 -m unittest discover -s scripts -p test_server_image_release.py -v
|
||||
python3 -m unittest discover -s scripts -p test_server_container_cleanup.py -v
|
||||
- name: Build and test Server
|
||||
run: |
|
||||
dotnet build OpenNest.Server/OpenNest.Server.csproj -c Release
|
||||
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release --logger 'trx;LogFileName=server.trx' --results-directory "$RESULTS/tests"
|
||||
- name: Resolve base image digests
|
||||
id: bases
|
||||
run: python3 scripts/server_image_release.py bases
|
||||
- name: Build exact local linux/amd64 image (never cached)
|
||||
id: build
|
||||
env:
|
||||
SDK_IMAGE: ${{ steps.bases.outputs.sdk_image }}
|
||||
RUNTIME_IMAGE: ${{ steps.bases.outputs.runtime_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RESULTS"
|
||||
docker build --pull --no-cache --platform linux/amd64 --provenance=false --sbom=false -f OpenNest.Server/Dockerfile \
|
||||
--build-arg VERSION="$VERSION" --build-arg SOURCE_REVISION="$SOURCE_SHA" \
|
||||
--build-arg SDK_IMAGE="$SDK_IMAGE" --build-arg RUNTIME_IMAGE="$RUNTIME_IMAGE" \
|
||||
-t "$LOCAL_IMAGE" . 2>&1 | tee "$RESULTS/build.log"
|
||||
python3 scripts/server_image_release.py local
|
||||
- name: Freeze the inspected image identity for smoke and publication
|
||||
env:
|
||||
IMAGE_ID: ${{ steps.build.outputs.image_id }}
|
||||
run: printf 'LOCAL_IMAGE=%s\n' "$IMAGE_ID" >> "$GITHUB_ENV"
|
||||
- name: Real-client persistence, backup/restore and runtime smoke
|
||||
run: scripts/Test-ServerContainer.sh --image "$LOCAL_IMAGE" --results "$RESULTS/smoke"
|
||||
- name: Retain logs and safe provenance only
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: server-image-validation-${{ github.run_id }}
|
||||
path: |
|
||||
${{ runner.temp }}/server-image-results/**/*.log
|
||||
${{ runner.temp }}/server-image-results/tests/*.trx
|
||||
${{ runner.temp }}/server-image-metadata/bases.json
|
||||
${{ runner.temp }}/server-image-metadata/local.json
|
||||
retention-days: 14
|
||||
|
||||
publish:
|
||||
if: >-
|
||||
github.repository == 'ajisaacs/OpenNest' &&
|
||||
((github.event_name == 'release' && github.event.release.prerelease == false) ||
|
||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
concurrency:
|
||||
group: opennest-server-ghcr-publish
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
LOCAL_IMAGE: opennest-server:release
|
||||
outputs:
|
||||
source_sha: ${{ steps.source.outputs.source_sha }}
|
||||
version: ${{ steps.source.outputs.version }}
|
||||
manifest_digest: ${{ steps.readback.outputs.manifest_digest }}
|
||||
config_digest: ${{ steps.readback.outputs.config_digest }}
|
||||
steps:
|
||||
- name: Isolate logs, safe metadata and ephemeral auth
|
||||
run: |
|
||||
printf 'METADATA_DIR=%s/server-image-metadata\nRESULTS=%s/server-image-results\nDOCKER_CONFIG=%s/server-image-auth\n' \
|
||||
"$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: master
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Resolve approved tag, release event commit and master ancestry
|
||||
id: source
|
||||
env:
|
||||
TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
run: python3 scripts/server_image_release.py source
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ steps.source.outputs.source_sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Use immutable source/version for every remaining gate
|
||||
env:
|
||||
VERSION: ${{ steps.source.outputs.version }}
|
||||
SOURCE_SHA: ${{ steps.source.outputs.source_sha }}
|
||||
run: |
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
printf 'VERSION=%s\nSOURCE_SHA=%s\n' "$VERSION" "$SOURCE_SHA" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||
env:
|
||||
DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet8
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: Test fail-closed release and cleanup helpers
|
||||
run: |
|
||||
python3 -m unittest discover -s scripts -p test_server_image_release.py -v
|
||||
python3 -m unittest discover -s scripts -p test_server_container_cleanup.py -v
|
||||
- name: Build and test exact Server source
|
||||
run: |
|
||||
dotnet build OpenNest.Server/OpenNest.Server.csproj -c Release
|
||||
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release --logger 'trx;LogFileName=server.trx' --results-directory "$RESULTS/tests"
|
||||
- name: Resolve base image digests
|
||||
id: bases
|
||||
run: python3 scripts/server_image_release.py bases
|
||||
- name: Build exact local linux/amd64 image (never cached)
|
||||
id: build
|
||||
env:
|
||||
SDK_IMAGE: ${{ steps.bases.outputs.sdk_image }}
|
||||
RUNTIME_IMAGE: ${{ steps.bases.outputs.runtime_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RESULTS"
|
||||
docker build --pull --no-cache --platform linux/amd64 --provenance=false --sbom=false -f OpenNest.Server/Dockerfile \
|
||||
--build-arg VERSION="$VERSION" --build-arg SOURCE_REVISION="$SOURCE_SHA" \
|
||||
--build-arg SDK_IMAGE="$SDK_IMAGE" --build-arg RUNTIME_IMAGE="$RUNTIME_IMAGE" \
|
||||
-t "$LOCAL_IMAGE" . 2>&1 | tee "$RESULTS/build.log"
|
||||
python3 scripts/server_image_release.py local
|
||||
- name: Freeze the inspected image identity for smoke and publication
|
||||
env:
|
||||
IMAGE_ID: ${{ steps.build.outputs.image_id }}
|
||||
run: printf 'LOCAL_IMAGE=%s\n' "$IMAGE_ID" >> "$GITHUB_ENV"
|
||||
- name: Smoke the same local image before any registry write
|
||||
run: scripts/Test-ServerContainer.sh --image "$LOCAL_IMAGE" --results "$RESULTS/smoke"
|
||||
- name: Refuse unknown visibility, wrong association, and both existing tags
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: python3 scripts/server_image_release.py preflight
|
||||
- name: Login and publish only the two immutable tags (no rebuild)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -m 700 -p "$DOCKER_CONFIG"
|
||||
printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
|
||||
image=ghcr.io/ajisaacs/opennest-server
|
||||
docker tag "$LOCAL_IMAGE" "$image:$VERSION"
|
||||
docker tag "$LOCAL_IMAGE" "$image:sha-$SOURCE_SHA"
|
||||
docker push "$image:$VERSION"
|
||||
docker push "$image:sha-$SOURCE_SHA"
|
||||
- name: Exact registry readback of both tags and smoked image config
|
||||
id: readback
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: python3 scripts/server_image_release.py readback
|
||||
- name: Remove ephemeral Docker credentials
|
||||
if: always()
|
||||
run: rm -rf -- "$DOCKER_CONFIG"
|
||||
- name: Retain logs and safe provenance only (not a verification verdict)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: server-image-publication-${{ github.run_id }}
|
||||
path: |
|
||||
${{ runner.temp }}/server-image-results/**/*.log
|
||||
${{ runner.temp }}/server-image-results/tests/*.trx
|
||||
${{ runner.temp }}/server-image-metadata/source.json
|
||||
${{ runner.temp }}/server-image-metadata/bases.json
|
||||
${{ runner.temp }}/server-image-metadata/local.json
|
||||
${{ runner.temp }}/server-image-metadata/preflight.json
|
||||
${{ runner.temp }}/server-image-metadata/registry.json
|
||||
retention-days: 14
|
||||
|
||||
verify-published:
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
env:
|
||||
VERSION: ${{ needs.publish.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.publish.outputs.source_sha }}
|
||||
MANIFEST_DIGEST: ${{ needs.publish.outputs.manifest_digest }}
|
||||
CONFIG_DIGEST: ${{ needs.publish.outputs.config_digest }}
|
||||
steps:
|
||||
- name: Isolate logs, safe metadata and ephemeral auth
|
||||
run: |
|
||||
printf 'METADATA_DIR=%s/server-image-metadata\nRESULTS=%s/server-image-results\nDOCKER_CONFIG=%s/server-image-auth\n' \
|
||||
"$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ needs.publish.outputs.source_sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||
env:
|
||||
DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet8
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: Pull returned digest on a clean runner
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python3 -c 'import os,sys; sys.path.insert(0,"scripts"); from server_image_release import sha256,commit,version; sha256(os.environ["MANIFEST_DIGEST"]); sha256(os.environ["CONFIG_DIGEST"]); commit(os.environ["SOURCE_SHA"]); version("v"+os.environ["VERSION"])'
|
||||
mkdir -m 700 -p "$DOCKER_CONFIG"
|
||||
printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
|
||||
image="ghcr.io/ajisaacs/opennest-server@$MANIFEST_DIGEST"
|
||||
docker pull --platform linux/amd64 "$image"
|
||||
printf 'LOCAL_IMAGE=%s\n' "$image" >> "$GITHUB_ENV"
|
||||
- name: Remove ephemeral Docker credentials
|
||||
if: always()
|
||||
run: rm -rf -- "$DOCKER_CONFIG"
|
||||
- name: Verify pulled digest, config, platform and provenance
|
||||
run: python3 scripts/server_image_release.py pulled
|
||||
- name: Real-client persistence smoke of pulled digest
|
||||
run: scripts/Test-ServerContainer.sh --image "$LOCAL_IMAGE" --results "$RESULTS/smoke"
|
||||
- name: Mark verified only after pulled-image smoke passes
|
||||
run: printf 'Verified private server image `%s` from `%s`.\n' "$LOCAL_IMAGE" "$SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Retain logs and safe pulled-image provenance only
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: server-image-pulled-verification-${{ github.run_id }}
|
||||
path: |
|
||||
${{ runner.temp }}/server-image-results/**/*.log
|
||||
${{ runner.temp }}/server-image-metadata/pulled.json
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,109 @@
|
||||
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
|
||||
# The suites have roughly doubled since the v0.3.0 run (7.5 min); the hang
|
||||
# guard below names a stuck test long before this cap.
|
||||
timeout-minutes: 45
|
||||
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: Install pinned Poppler for PDF report tests
|
||||
run: |
|
||||
$zip = Join-Path $env:RUNNER_TEMP 'poppler-26.09.0.zip'
|
||||
Invoke-WebRequest 'https://github.com/oschwartz10612/poppler-windows/releases/download/v26.09.0-0/Release-26.09.0-0.zip' -OutFile $zip
|
||||
$actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne '7a6f256a0ddf7536182246a5733331bf4677cbcc34f4663774947ad34556c8d0') { throw 'Poppler archive hash mismatch.' }
|
||||
$destination = Join-Path $env:RUNNER_TEMP 'poppler-release-tests'
|
||||
Expand-Archive $zip -DestinationPath $destination
|
||||
$bin = Join-Path $destination 'poppler-26.09.0/Library/bin'
|
||||
$tool = Join-Path $bin 'pdftotext.exe'
|
||||
if (-not (Test-Path $tool)) { throw 'Pinned pdftotext.exe is missing.' }
|
||||
& $tool -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Pinned pdftotext.exe cannot start.' }
|
||||
$bin >> $env:GITHUB_PATH
|
||||
- name: Run all test projects on Windows
|
||||
# A test silent for 5 minutes is killed and named in the log, with a
|
||||
# mini dump in TestResults, instead of holding the runner until the
|
||||
# job timeout.
|
||||
run: |
|
||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||
dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
||||
}
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --blame-hang-timeout 5m --blame-hang-dump-type mini --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/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
+10
-1
@@ -11,6 +11,7 @@
|
||||
*.userprefs
|
||||
|
||||
# Build results
|
||||
/artifacts/
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
@@ -206,13 +207,21 @@ FakesAssemblies/
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Claude Code
|
||||
# Local agent state and temporary planning/progress documents
|
||||
.claude/
|
||||
/.hermes/plans/
|
||||
/.hermes/progress/
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
/docs/*-plan.md
|
||||
/docs/*-progress.md
|
||||
|
||||
# Launch settings
|
||||
**/Properties/launchSettings.json
|
||||
|
||||
# Local test config (contains user-specific paths to proprietary test assets)
|
||||
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": {
|
||||
"opennest": {
|
||||
"command": "cmd",
|
||||
"args": ["/c", "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/run.cmd"]
|
||||
"command": "${USERPROFILE}/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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
|
||||
# Use Release for routine runs; use Debug explicitly for DEBUG-only work counters.
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
|
||||
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release
|
||||
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj -c Release
|
||||
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
|
||||
|
||||
# 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. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
|
||||
|
||||
## 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,135 +1 @@
|
||||
# CLAUDE.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 `NestEngineBase` implementations against each other on real `.nest` files. 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.
|
||||
- `BenchmarkRunner` gives each (job, engine) pair a fresh `Plate`/`NestItem` list (`BenchmarkJob.CreatePlate()`/`CreateItems()`) so engines can't see each other's mutated state, then calls the engine's `Nest()` and times it.
|
||||
- `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 or throwing 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 the smaller used-bounding-box (`Report`'s ranking rule) — a more compact layout leaves a bigger usable remnant.
|
||||
- `--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.
|
||||
@AGENTS.md
|
||||
@@ -6,17 +6,23 @@ namespace OpenNest.Api;
|
||||
public class NestRequest
|
||||
{
|
||||
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Explicit available physical stock. Null keeps the legacy unlimited SheetSize fallback;
|
||||
/// an empty list deliberately means no stock is available.
|
||||
/// </summary>
|
||||
public IReadOnlyList<NestRequestPlate> Plates { get; init; }
|
||||
public Size SheetSize { get; init; } = new(60, 120);
|
||||
|
||||
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
|
||||
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 double Thickness { get; init; } = 0.06;
|
||||
public double Spacing { get; init; } = 0.1;
|
||||
|
||||
/// <summary>Legacy compatibility setting; Auto maps to the Default whole-job strategy.</summary>
|
||||
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
|
||||
public CutParameters Cutting { get; init; } = CutParameters.Default;
|
||||
|
||||
@@ -7,6 +7,7 @@ public class NestRequestPlate
|
||||
{
|
||||
public string Id { get; init; }
|
||||
public Size Size { get; init; }
|
||||
|
||||
/// <summary>Available physical sheets; null means unlimited.</summary>
|
||||
public int? Quantity { get; init; }
|
||||
public double PartSpacing { get; init; }
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.IO;
|
||||
|
||||
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>
|
||||
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 const int CurrentSchemaVersion = 2;
|
||||
public const int CurrentSchemaVersion = 3;
|
||||
|
||||
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
public int SheetCount { get; init; }
|
||||
|
||||
/// <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 TimeSpan CutTime { get; init; }
|
||||
public TimeSpan Elapsed { get; init; }
|
||||
|
||||
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
|
||||
public NestJobStatus? Status { 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<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
@@ -43,7 +52,7 @@ public class NestResponse
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
IncludeFields = true, // Required for OpenNest.Geometry.Size and Spacing public fields.
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public async Task SaveAsync(string path)
|
||||
@@ -61,19 +70,29 @@ public class NestResponse
|
||||
var responseEntry = zip.CreateEntry("response.json");
|
||||
await using (var stream = responseEntry.Open())
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, new NestResponseArchiveDto
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
SheetCount = SheetCount,
|
||||
Utilization = Utilization,
|
||||
CutTimeTicks = CutTime.Ticks,
|
||||
ElapsedTicks = Elapsed.Ticks,
|
||||
Status = Status,
|
||||
StopReason = StopReason,
|
||||
Fulfillment = Fulfillment is null ? [] : new List<NestPartFulfillment>(Fulfillment),
|
||||
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
||||
PlateStockMappings = PlateStockMappings is null ? [] : new List<NestPlateStockMapping>(PlateStockMappings)
|
||||
}, JsonOptions);
|
||||
await JsonSerializer.SerializeAsync(
|
||||
stream,
|
||||
new NestResponseArchiveDto
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
SheetCount = SheetCount,
|
||||
Utilization = Utilization,
|
||||
CutTimeTicks = CutTime.Ticks,
|
||||
ElapsedTicks = Elapsed.Ticks,
|
||||
Status = Status,
|
||||
StopReason = StopReason,
|
||||
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),
|
||||
PlateStockMappings = PlateStockMappings is null
|
||||
? []
|
||||
: new List<NestPlateStockMapping>(PlateStockMappings),
|
||||
},
|
||||
JsonOptions
|
||||
);
|
||||
}
|
||||
|
||||
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 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");
|
||||
NestRequest request;
|
||||
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");
|
||||
}
|
||||
|
||||
var responseEntry = zip.GetEntry("response.json")
|
||||
var responseEntry =
|
||||
zip.GetEntry("response.json")
|
||||
?? throw new InvalidOperationException("Missing response.json in .nestquote file");
|
||||
NestResponseArchiveDto archive;
|
||||
var hasSchemaVersion = false;
|
||||
@@ -110,16 +132,19 @@ public class NestResponse
|
||||
{
|
||||
var root = document.RootElement;
|
||||
hasSchemaVersion = root.TryGetProperty("schemaVersion", out _);
|
||||
hasStatusMetadata = root.TryGetProperty("status", out _) ||
|
||||
root.TryGetProperty("stopReason", out _) ||
|
||||
root.TryGetProperty("fulfillment", out _) ||
|
||||
root.TryGetProperty("stockUsage", out _) ||
|
||||
root.TryGetProperty("plateStockMappings", out _);
|
||||
archive = root.Deserialize<NestResponseArchiveDto>(JsonOptions)
|
||||
hasStatusMetadata =
|
||||
root.TryGetProperty("status", out _)
|
||||
|| root.TryGetProperty("stopReason", out _)
|
||||
|| root.TryGetProperty("fulfillment", out _)
|
||||
|| root.TryGetProperty("stockUsage", out _)
|
||||
|| root.TryGetProperty("plateStockMappings", out _);
|
||||
archive =
|
||||
root.Deserialize<NestResponseArchiveDto>(JsonOptions)
|
||||
?? 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");
|
||||
Nest nest;
|
||||
using (var nestMs = new MemoryStream())
|
||||
@@ -141,11 +166,13 @@ public class NestResponse
|
||||
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
|
||||
Status = hasStatusMetadata ? archive.Status : null,
|
||||
StopReason = hasStatusMetadata ? archive.StopReason : null,
|
||||
ValidationStatus = archive.ValidationStatus,
|
||||
Violations = archive.Violations ?? [],
|
||||
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
||||
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
||||
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
||||
Nest = nest,
|
||||
Request = request
|
||||
Request = request,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +185,8 @@ public class NestResponse
|
||||
public long ElapsedTicks { get; init; }
|
||||
public NestJobStatus? Status { 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<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
|
||||
+144
-55
@@ -5,6 +5,8 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
@@ -16,90 +18,156 @@ public static class NestRunner
|
||||
public static Task<NestResponse> RunAsync(
|
||||
NestRequest request,
|
||||
IProgress<NestProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
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)
|
||||
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var parts = IdentifyParts(requestParts);
|
||||
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)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
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))
|
||||
{
|
||||
try
|
||||
{
|
||||
drawing = CadImporter.ImportDrawing(part.Request.DxfPath,
|
||||
new CadImportOptions { Quantity = part.Request.Quantity });
|
||||
drawing = CadImporter.ImportDrawing(
|
||||
part.Request.DxfPath,
|
||||
new CadImportOptions { Quantity = part.Request.Quantity }
|
||||
);
|
||||
}
|
||||
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)
|
||||
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);
|
||||
}
|
||||
|
||||
ConfigureDrawingForRequirement(drawing, part.Request);
|
||||
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity));
|
||||
// Each requirement keeps its own identity/constraints even when paths are shared.
|
||||
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),
|
||||
new NestJobOptions(ResolvePlacementStrategy(request)));
|
||||
var stock = CreateStock(request);
|
||||
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
|
||||
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.
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
var nest = materialized.Nest;
|
||||
nest.Thickness = request.Thickness;
|
||||
nest.Material = new Material(request.Material);
|
||||
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
|
||||
// Invalid but representable proposals retain every pose and carry explicit validation status.
|
||||
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
|
||||
foreach (var item in items)
|
||||
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 cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
||||
sw.Stop();
|
||||
|
||||
return Task.FromResult(new NestResponse
|
||||
{
|
||||
SheetCount = nest.Plates.Count,
|
||||
Utilization = CalculateUtilization(nest),
|
||||
CutTime = cutTime,
|
||||
Elapsed = sw.Elapsed,
|
||||
Status = result.Status,
|
||||
StopReason = result.StopReason,
|
||||
Fulfillment = result.Fulfillment
|
||||
.Select(value => new NestPartFulfillment(value.PartId, value.Requested, value.Placed, value.Unplaced))
|
||||
.ToArray(),
|
||||
StockUsage = result.StockUsage
|
||||
.Select(value => new NestStockUsage(value.StockId, value.Used, value.Remaining))
|
||||
.ToArray(),
|
||||
PlateStockMappings = result.Plates
|
||||
.Select(value => new NestPlateStockMapping(value.PlateIndex, value.StockId))
|
||||
.ToArray(),
|
||||
Nest = nest,
|
||||
Request = request
|
||||
});
|
||||
return Task.FromResult(
|
||||
new NestResponse
|
||||
{
|
||||
SheetCount = nest.Plates.Count,
|
||||
Utilization = CalculateUtilization(nest),
|
||||
CutTime = cutTime,
|
||||
Elapsed = sw.Elapsed,
|
||||
Status = complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||
StopReason = stopReason,
|
||||
ValidationStatus = !result.CanKeep ? NestValidationStatus.Unrepresentable
|
||||
: result.IsValid ? NestValidationStatus.Valid : NestValidationStatus.Invalid,
|
||||
Violations = result.Violations,
|
||||
Fulfillment = fulfillment,
|
||||
StockUsage = usage,
|
||||
PlateStockMappings = result.Plates.Select((value, index) =>
|
||||
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
|
||||
Nest = nest,
|
||||
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 ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
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}";
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
|
||||
@@ -117,8 +185,12 @@ public static class NestRunner
|
||||
{
|
||||
return
|
||||
[
|
||||
new NestPlateStock(LegacyStockId, request.SheetSize, quantity: null,
|
||||
partSpacing: request.Spacing)
|
||||
new NestPlateStock(
|
||||
LegacyStockId,
|
||||
request.SheetSize,
|
||||
quantity: null,
|
||||
partSpacing: request.Spacing
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -126,9 +198,20 @@ public static class NestRunner
|
||||
foreach (var plate in request.Plates)
|
||||
{
|
||||
if (plate is null)
|
||||
throw new ArgumentException("Request plates must not contain null entries.", nameof(request));
|
||||
stock.Add(new NestPlateStock(plate.Id, plate.Size, plate.Quantity, plate.PartSpacing,
|
||||
plate.EdgeSpacing, plate.Quadrant));
|
||||
throw new ArgumentException(
|
||||
"Request plates must not contain null entries.",
|
||||
nameof(request)
|
||||
);
|
||||
stock.Add(
|
||||
new NestPlateStock(
|
||||
plate.Id,
|
||||
plate.Size,
|
||||
plate.Quantity,
|
||||
plate.PartSpacing,
|
||||
plate.EdgeSpacing,
|
||||
plate.Quadrant
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return stock;
|
||||
@@ -147,25 +230,31 @@ public static class NestRunner
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePlacementStrategy(NestRequest request) => request.PlacementStrategy ?? request.Strategy switch
|
||||
{
|
||||
NestStrategy.Auto => "Default",
|
||||
_ => throw new NotSupportedException($"Unknown legacy nesting strategy: {request.Strategy}.")
|
||||
};
|
||||
private static string ResolvePlacementStrategy(NestRequest request) =>
|
||||
request.PlacementStrategy
|
||||
?? request.Strategy switch
|
||||
{
|
||||
NestStrategy.Auto => "Default",
|
||||
_ => throw new NotSupportedException(
|
||||
$"Unknown legacy nesting strategy: {request.Strategy}."
|
||||
),
|
||||
};
|
||||
|
||||
private static double CalculateUtilization(Nest nest)
|
||||
{
|
||||
var sheetArea = nest.Plates.Sum(plate => plate.Area());
|
||||
if (sheetArea == 0) return 0;
|
||||
var placedArea = nest.Plates.Sum(plate => plate.Parts
|
||||
.Where(part => !part.BaseDrawing.IsCutOff)
|
||||
.Sum(part => part.BaseDrawing.Area));
|
||||
if (sheetArea == 0)
|
||||
return 0;
|
||||
var placedArea = nest.Plates.Sum(plate =>
|
||||
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area)
|
||||
);
|
||||
return placedArea / sheetArea;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public enum NestStrategy { Auto }
|
||||
public enum NestStrategy
|
||||
{
|
||||
Auto,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Api</RootNamespace>
|
||||
<AssemblyName>OpenNest.Api</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
@@ -34,57 +36,59 @@ namespace OpenNest.Benchmark
|
||||
public Spacing EdgeSpacing { get; init; }
|
||||
public double PartSpacing { 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 string Name => Path.GetFileNameWithoutExtension(SourceFile);
|
||||
|
||||
public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity);
|
||||
|
||||
/// <summary>
|
||||
/// A blank plate carrying only the job's spacing/quadrant template.
|
||||
/// MultiPlateNester.CreatePlate copies these settings onto whichever
|
||||
/// size it ultimately picks; its Size is only the fallback used when
|
||||
/// nothing in the candidate pool fits, so it's set to the largest
|
||||
/// candidate rather than an arbitrary one.
|
||||
/// </summary>
|
||||
public Plate CreateTemplatePlate()
|
||||
{
|
||||
var fallbackSize = CandidateSizes
|
||||
.OrderByDescending(s => s.Width * s.Length)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new Plate(fallbackSize)
|
||||
{
|
||||
EdgeSpacing = EdgeSpacing,
|
||||
PartSpacing = PartSpacing,
|
||||
Quadrant = Quadrant,
|
||||
};
|
||||
}
|
||||
/// <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>
|
||||
/// The candidate sizes as PlateOptions for MultiPlateNester.CreatePlate.
|
||||
/// Cost is area-proportional since no real per-size material pricing is
|
||||
/// available here - this only affects which size is preferred when more
|
||||
/// than one candidate fits, favoring the smaller/cheaper sheet.
|
||||
/// Builds the whole-job request this job represents: one NestJobPart per
|
||||
/// requested drawing, and one NestPlateStock per candidate sheet size
|
||||
/// (unlimited quantity - the engine under test decides how many of each
|
||||
/// size it actually uses, and how demand splits across plates). The
|
||||
/// engine owns its own multi-plate/size strategy; this harness no
|
||||
/// longer picks plate sizes on the engine's behalf.
|
||||
/// </summary>
|
||||
public List<PlateOption> BuildPlateOptions()
|
||||
public NestJob BuildNestJob(
|
||||
int maxPlates,
|
||||
double? salvageRate = null,
|
||||
double? minimumSalvageDimension = null
|
||||
)
|
||||
{
|
||||
return CandidateSizes
|
||||
.Select(s => new PlateOption { Width = s.Width, Length = s.Length, Cost = s.Width * s.Length })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public List<NestItem> CreateItems()
|
||||
{
|
||||
return Requests.Select(r => new NestItem
|
||||
{
|
||||
Drawing = r.Drawing,
|
||||
Quantity = r.Quantity,
|
||||
Priority = r.Priority,
|
||||
StepAngle = r.StepAngle,
|
||||
RotationStart = r.RotationStart,
|
||||
RotationEnd = r.RotationEnd,
|
||||
}).ToList();
|
||||
var parts = Requests.Select(r =>
|
||||
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
|
||||
));
|
||||
return new NestJob(
|
||||
parts,
|
||||
stock,
|
||||
new NestJobOptions(
|
||||
"Fill",
|
||||
maxPlates,
|
||||
salvageRate ?? SalvageRate,
|
||||
minimumSalvageDimension ?? 0
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,151 +1,397 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs every candidate engine against every job. A job may need several
|
||||
/// plates to place everything it asks for; this drives that loop itself,
|
||||
/// since NestEngineBase.Nest() fills exactly one already-sized plate and
|
||||
/// has no say in picking its own size. For each plate the loop needs, the
|
||||
/// smallest candidate size that fits the largest still-unplaced drawing is
|
||||
/// chosen via the codebase's own MultiPlateNester.CreatePlate, then the
|
||||
/// engine's Nest() fills that plate with whatever of the remaining items
|
||||
/// fit. This is applied identically to every engine, so no engine gets to
|
||||
/// (or has to) implement sheet-size selection itself.
|
||||
/// Runs every candidate engine against every job. Each engine is a full
|
||||
/// INestingEngine: it owns its own plate/size selection and multi-plate
|
||||
/// strategy for the whole job, rather than being handed one already-sized
|
||||
/// plate at a time by this harness. A per-run timeout guards against a
|
||||
/// runaway or hanging engine — cooperative cancellation, so it reliably
|
||||
/// stops engines built on NestJobRunner (all four built-ins) but can't
|
||||
/// forcibly interrupt an engine that never checks its token.
|
||||
/// </summary>
|
||||
public static class BenchmarkRunner
|
||||
{
|
||||
/// <summary>Safety cap so a degenerate engine (placing almost nothing
|
||||
/// per plate) can't loop indefinitely.</summary>
|
||||
/// <summary>Physical-sheet cap passed to every job's NestJobOptions.MaxPlates.</summary>
|
||||
private const int MaxPlates = 40;
|
||||
|
||||
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestEngineInfo> engines)
|
||||
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
||||
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
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);
|
||||
|
||||
foreach (var job in jobs)
|
||||
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
|
||||
.ToList();
|
||||
var results = new JobResult[pairs.Count];
|
||||
var options = new ParallelOptions
|
||||
{
|
||||
foreach (var engineInfo in engines)
|
||||
{
|
||||
results.Add(RunOne(job, engineInfo));
|
||||
}
|
||||
}
|
||||
MaxDegreeOfParallelism = System.Math.Max(1, maxParallelism),
|
||||
};
|
||||
|
||||
return results;
|
||||
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++)
|
||||
{
|
||||
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 RunOne(BenchmarkJob job, NestEngineInfo engineInfo)
|
||||
private static JobResult RunBaseline(
|
||||
BenchmarkJob job,
|
||||
double? salvageRate,
|
||||
double? minimumSalvageDimension
|
||||
)
|
||||
{
|
||||
var template = job.CreateTemplatePlate();
|
||||
var options = job.BuildPlateOptions();
|
||||
var remaining = job.CreateItems();
|
||||
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 = new List<(Plate Plate, List<Part> Parts)>();
|
||||
string engineError = null;
|
||||
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}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static JobResult RunOne(
|
||||
BenchmarkJob job,
|
||||
NestingEngineInfo engineInfo,
|
||||
double? salvageRate,
|
||||
double? minimumSalvageDimension,
|
||||
string outputDirectory,
|
||||
System.IO.TextWriter progressLog
|
||||
)
|
||||
{
|
||||
var requested = job.TotalRequestedQuantity;
|
||||
var log = progressLog == null
|
||||
? null
|
||||
: new JobProgressLog(progressLog, $"{job.Name}/{engineInfo.Name}");
|
||||
log?.Started();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
while (remaining.Any(i => i.Quantity > 0) && plateRuns.Count < MaxPlates)
|
||||
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
|
||||
var engine = engineInfo.Factory();
|
||||
using var cts = new CancellationTokenSource(SolveTimeout);
|
||||
var jobResult = engine.Solve(nestJob, log, cts.Token);
|
||||
log?.Finished(jobResult, sw.ElapsedMilliseconds);
|
||||
|
||||
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
|
||||
var plateRuns = materialized
|
||||
.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList()))
|
||||
.ToList();
|
||||
|
||||
var requirements = job.Requests.ToDictionary<
|
||||
DrawingRequest,
|
||||
Drawing,
|
||||
(string Name, int Quantity)
|
||||
>(
|
||||
r => materialized.DrawingsByPartId[r.Drawing.Id.ToString()],
|
||||
r => (r.Drawing.Name, r.Quantity),
|
||||
ReferenceEqualityComparer.Instance
|
||||
);
|
||||
|
||||
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 placedArea = validation.Valid
|
||||
? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area))
|
||||
: 0;
|
||||
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
|
||||
.GroupBy(pr => pr.Plate.Size.ToString(1))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
if (validation.Valid && outputDirectory != null)
|
||||
{
|
||||
var largest = remaining
|
||||
.Where(i => i.Quantity > 0)
|
||||
.OrderByDescending(i => BoundsArea(i))
|
||||
.First();
|
||||
|
||||
var plate = MultiPlateNester.CreatePlate(template, options, largest.Drawing.Program.BoundingBox());
|
||||
var engine = engineInfo.Factory(plate);
|
||||
var itemsClone = CloneItems(remaining);
|
||||
|
||||
var parts = engine.Nest(itemsClone, null, CancellationToken.None) ?? new List<Part>();
|
||||
|
||||
if (parts.Count == 0)
|
||||
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))
|
||||
{
|
||||
// Not even the largest available candidate size could fit
|
||||
// the current largest remaining part - stop here rather
|
||||
// than loop forever; whatever's left is reported unplaced.
|
||||
break;
|
||||
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;
|
||||
}
|
||||
|
||||
plateRuns.Add((plate, parts));
|
||||
|
||||
foreach (var item in remaining)
|
||||
else
|
||||
{
|
||||
var placed = parts.Count(p => p.BaseDrawing.Id == item.Drawing.Id);
|
||||
|
||||
if (placed > 0)
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
engineError = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
sw.Stop();
|
||||
|
||||
if (engineError != null)
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = validation.Valid,
|
||||
Violations = validation.Violations,
|
||||
PartsPlaced = totalPlaced,
|
||||
PartsRequested = requested,
|
||||
PlacedArea = placedArea,
|
||||
PlateArea = plateArea,
|
||||
NetSheetArea = netSheetArea,
|
||||
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||
PlatesUsed = plateRuns.Count,
|
||||
SizeBreakdown = sizeBreakdown,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
sw.Stop();
|
||||
log?.Failed("timed out", sw.ElapsedMilliseconds);
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = false,
|
||||
PartsRequested = requested,
|
||||
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
Error = engineError,
|
||||
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
|
||||
};
|
||||
}
|
||||
|
||||
var validation = NestValidator.Validate(plateRuns, job);
|
||||
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
|
||||
var placedArea = validation.Valid ? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area)) : 0;
|
||||
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
|
||||
|
||||
var sizeBreakdown = plateRuns
|
||||
.GroupBy(pr => pr.Plate.Size.ToString(1))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
return new JobResult
|
||||
catch (Exception ex)
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = validation.Valid,
|
||||
Violations = validation.Violations,
|
||||
PartsPlaced = totalPlaced,
|
||||
PartsRequested = requested,
|
||||
PlacedArea = placedArea,
|
||||
PlateArea = plateArea,
|
||||
PlatesUsed = plateRuns.Count,
|
||||
SizeBreakdown = sizeBreakdown,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
private static double BoundsArea(NestItem item)
|
||||
{
|
||||
var bb = item.Drawing.Program.BoundingBox();
|
||||
return bb.Width * bb.Length;
|
||||
}
|
||||
|
||||
private static List<NestItem> CloneItems(List<NestItem> items)
|
||||
{
|
||||
return items.Select(i => new NestItem
|
||||
{
|
||||
Drawing = i.Drawing,
|
||||
Quantity = i.Quantity,
|
||||
Priority = i.Priority,
|
||||
StepAngle = i.StepAngle,
|
||||
RotationStart = i.RotationStart,
|
||||
RotationEnd = i.RotationEnd,
|
||||
}).ToList();
|
||||
sw.Stop();
|
||||
log?.Failed($"{ex.GetType().Name}: {ex.Message}", sw.ElapsedMilliseconds);
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = false,
|
||||
PartsRequested = requested,
|
||||
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
-29
@@ -1,9 +1,9 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
@@ -17,14 +17,24 @@ namespace OpenNest.Benchmark
|
||||
/// </summary>
|
||||
public static class JobLoader
|
||||
{
|
||||
public static List<BenchmarkJob> Load(string inputPath, IReadOnlyList<Size> sheetSizeOverrides = null,
|
||||
double? partSpacingOverride = null)
|
||||
public static List<BenchmarkJob> Load(
|
||||
string inputPath,
|
||||
IReadOnlyList<Size> sheetSizeOverrides = null,
|
||||
double? partSpacingOverride = null
|
||||
)
|
||||
{
|
||||
var files = ResolveFiles(inputPath);
|
||||
var jobs = new List<BenchmarkJob>();
|
||||
|
||||
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;
|
||||
|
||||
try
|
||||
@@ -33,7 +43,9 @@ namespace OpenNest.Benchmark
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -41,34 +53,72 @@ namespace OpenNest.Benchmark
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var template = ResolvePlateTemplate(nest);
|
||||
var sizes = sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
|
||||
? sheetSizeOverrides.ToList()
|
||||
: ResolveSheetSizes(nest);
|
||||
var sizes =
|
||||
sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
|
||||
? sheetSizeOverrides.ToList()
|
||||
: ResolveSheetSizes(nest);
|
||||
|
||||
jobs.Add(new BenchmarkJob
|
||||
{
|
||||
SourceFile = file,
|
||||
CandidateSizes = sizes,
|
||||
EdgeSpacing = template.EdgeSpacing,
|
||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||
Quadrant = template.Quadrant,
|
||||
Requests = requests,
|
||||
});
|
||||
jobs.Add(
|
||||
new BenchmarkJob
|
||||
{
|
||||
SourceFile = file,
|
||||
CandidateSizes = sizes,
|
||||
EdgeSpacing = template.EdgeSpacing,
|
||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||
Quadrant = template.Quadrant,
|
||||
SalvageRate = nest.SalvageRate,
|
||||
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
|
||||
Requests = requests,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
.ToList();
|
||||
}
|
||||
@@ -92,21 +142,54 @@ namespace OpenNest.Benchmark
|
||||
|
||||
var constraints = drawing.Constraints;
|
||||
|
||||
requests.Add(new DrawingRequest
|
||||
{
|
||||
Drawing = drawing,
|
||||
Quantity = qty,
|
||||
Priority = drawing.Priority,
|
||||
StepAngle = constraints?.StepAngle ?? 0,
|
||||
RotationStart = constraints?.StartAngle ?? 0,
|
||||
RotationEnd = constraints?.EndAngle ?? 0,
|
||||
});
|
||||
requests.Add(
|
||||
new DrawingRequest
|
||||
{
|
||||
Drawing = drawing,
|
||||
Quantity = qty,
|
||||
Priority = drawing.Priority,
|
||||
StepAngle = constraints?.StepAngle ?? 0,
|
||||
RotationStart = constraints?.StartAngle ?? 0,
|
||||
RotationEnd = constraints?.EndAngle ?? 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -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
|
||||
/// plates (PlatesUsed, SizeBreakdown), since the engine may need more than
|
||||
/// one sheet - possibly of different sizes - to place everything asked of
|
||||
/// it. An invalid or crashed run always scores zero utilization, per the
|
||||
/// benchmark rules.
|
||||
/// it. An invalid or crashed run places nothing as far as scoring is
|
||||
/// concerned: it earns no area and pays the unplaced penalty on every
|
||||
/// requested part.
|
||||
/// </summary>
|
||||
public class JobResult
|
||||
{
|
||||
@@ -20,6 +21,17 @@ namespace OpenNest.Benchmark
|
||||
public int PartsRequested { get; init; }
|
||||
public double PlacedArea { 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 Dictionary<string, int> SizeBreakdown { get; init; } = new();
|
||||
public long ElapsedMs { get; init; }
|
||||
@@ -31,5 +43,21 @@ namespace OpenNest.Benchmark
|
||||
/// total placed drawing area over total plate area, matching
|
||||
/// Plate.Utilization()'s per-plate definition summed across the job.</summary>
|
||||
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,183 +1,30 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
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 List<string> Violations { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Compatibility wrapper over the shared layout validation contract.</summary>
|
||||
public static class NestValidator
|
||||
{
|
||||
/// <summary>Checks materialized plates using drawing-reference requirement identity.</summary>
|
||||
public static ValidationResult Validate(
|
||||
List<(Plate Plate, List<Part> Parts)> plateRuns,
|
||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements)
|
||||
{
|
||||
public bool Valid => Violations.Count == 0;
|
||||
public List<string> Violations { get; } = new();
|
||||
var result = new ValidationResult();
|
||||
result.Violations.AddRange(NestLayoutCheck.Validate(plateRuns, requirements));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a (possibly multi-plate) placed layout against the benchmark
|
||||
/// 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
|
||||
/// all plates combined, no drawing may have more parts placed than
|
||||
/// requested (the quantity limit is a property of the whole order, not of
|
||||
/// 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
|
||||
{
|
||||
public static ValidationResult Validate(List<(Plate Plate, List<Part> Parts)> plateRuns, BenchmarkJob job)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList();
|
||||
|
||||
if (allParts.Count == 0)
|
||||
return result;
|
||||
|
||||
ValidateQuantities(allParts, job, result);
|
||||
|
||||
foreach (var (plate, parts) in plateRuns)
|
||||
{
|
||||
if (parts.Count == 0)
|
||||
continue;
|
||||
|
||||
ValidateBounds(parts, plate, result);
|
||||
ValidateAreaBudget(parts, plate, result);
|
||||
ValidateSpacing(parts, plate.PartSpacing, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ValidateQuantities(List<Part> parts, BenchmarkJob job, ValidationResult result)
|
||||
{
|
||||
var allowed = job.Requests.ToDictionary(r => r.Drawing.Id, r => r.Quantity);
|
||||
var placedCounts = parts
|
||||
.GroupBy(p => p.BaseDrawing.Id)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
foreach (var (drawingId, placed) in placedCounts)
|
||||
{
|
||||
if (!allowed.TryGetValue(drawingId, out var max))
|
||||
{
|
||||
result.Violations.Add($"Placed drawing id={drawingId} which was not requested for this job");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (placed > max)
|
||||
{
|
||||
var name = parts.First(p => p.BaseDrawing.Id == drawingId).BaseDrawing.Name;
|
||||
result.Violations.Add($"'{name}': placed {placed} across all plates but only {max} were requested");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateBounds(List<Part> parts, Plate plate, 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(
|
||||
$"'{part.BaseDrawing.Name}' 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, 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(
|
||||
$"'{parts[i].BaseDrawing.Name}' and '{parts[j].BaseDrawing.Name}' are closer than the required spacing ({spacing:F3})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
/// <summary>Appends offered-stock, finite-stock and rotation-policy violations.</summary>
|
||||
public static void ValidateAgainstJob(NestJob job, NestJobResult jobResult,
|
||||
IReadOnlyDictionary<string, string> displayNames, ValidationResult result) =>
|
||||
NestLayoutCheck.ValidateAgainstJob(job, jobResult, displayNames, result.Violations);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Benchmark</RootNamespace>
|
||||
<AssemblyName>OpenNest.Benchmark</AssemblyName>
|
||||
<Nullable>disable</Nullable>
|
||||
|
||||
+207
-30
@@ -1,9 +1,11 @@
|
||||
using OpenNest;
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest;
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
return BenchmarkConsole.Run(args);
|
||||
|
||||
@@ -36,22 +38,36 @@ static class BenchmarkConsole
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var engines = NestEngineRegistry.AvailableEngines;
|
||||
var enginesDir = Path.Combine(AppContext.BaseDirectory, "Engines");
|
||||
NestingEngineRegistry.LoadPlugins(enginesDir);
|
||||
|
||||
var engines = NestingEngineRegistry.AvailableEngines;
|
||||
|
||||
if (options.EngineNames.Count > 0)
|
||||
{
|
||||
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();
|
||||
|
||||
if (engines.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("None of the requested engines are registered. Available: " +
|
||||
string.Join(", ", NestEngineRegistry.AvailableEngines.Select(e => e.Name)));
|
||||
Console.Error.WriteLine(
|
||||
"None of the requested engines are registered. Available: "
|
||||
+ string.Join(
|
||||
", ",
|
||||
NestingEngineRegistry.AvailableEngines.Select(e => e.Name)
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -61,12 +77,55 @@ static class BenchmarkConsole
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
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))}");
|
||||
|
||||
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.PrintSummary(results);
|
||||
@@ -94,12 +153,18 @@ static class BenchmarkConsole
|
||||
break;
|
||||
|
||||
case "--spacing" when i + 1 < args.Length:
|
||||
o.PartSpacing = double.Parse(args[++i]);
|
||||
o.PartSpacing = double.Parse(
|
||||
args[++i],
|
||||
System.Globalization.CultureInfo.InvariantCulture
|
||||
);
|
||||
break;
|
||||
|
||||
case "--engines" when i + 1 < args.Length:
|
||||
o.EngineNames = args[++i]
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Split(
|
||||
',',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
|
||||
)
|
||||
.ToList();
|
||||
break;
|
||||
|
||||
@@ -107,6 +172,35 @@ static class BenchmarkConsole
|
||||
o.CsvPath = args[++i];
|
||||
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":
|
||||
PrintUsage();
|
||||
return null;
|
||||
@@ -125,39 +219,117 @@ static class BenchmarkConsole
|
||||
{
|
||||
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);
|
||||
else
|
||||
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
|
||||
}
|
||||
|
||||
return sizes;
|
||||
return sizes.Distinct().ToList();
|
||||
}
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine("OpenNest.Benchmark - compare registered 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("For each .nest file, every drawing with quantity > 0 is nested (mixed together),");
|
||||
Console.Error.WriteLine("once per registered engine. This is a full nest, not a single fixed-size plate:");
|
||||
Console.Error.WriteLine("as many plates as needed are created, one at a time, each sized by picking the");
|
||||
Console.Error.WriteLine("smallest candidate sheet size that fits the largest still-unplaced drawing -");
|
||||
Console.Error.WriteLine("applied identically to every engine, since Nest() itself has no say over its");
|
||||
Console.Error.WriteLine("own plate's size. Scoring: aggregate material utilization across every plate");
|
||||
Console.Error.WriteLine("used, then (if everything requested was placed) fewer plates as the tie-break.");
|
||||
Console.Error.WriteLine("An invalid layout (out of bounds, overlapping, or over-quantity) scores zero.");
|
||||
Console.Error.WriteLine(
|
||||
"For each .nest file, every drawing with quantity > 0 is nested (mixed together),"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"once per registered INestingEngine. Each engine is handed the full job - every"
|
||||
);
|
||||
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(
|
||||
"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("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("Options:");
|
||||
Console.Error.WriteLine(" --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest");
|
||||
Console.Error.WriteLine(" (default: the distinct sizes already in each file)");
|
||||
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(
|
||||
" --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" (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");
|
||||
}
|
||||
|
||||
@@ -168,5 +340,10 @@ static class BenchmarkConsole
|
||||
public double? PartSpacing;
|
||||
public List<string> EngineNames = new();
|
||||
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>
|
||||
/// Console + CSV reporting for benchmark results. Ranking rule per job:
|
||||
/// valid beats invalid; higher aggregate utilization wins; if utilization
|
||||
/// ties and both engines fully placed every requested part, fewer plates
|
||||
/// used wins (the multi-plate analogue of "smaller remnant" - both are
|
||||
/// proxies for wasting less material). Ties beyond that are a shared win.
|
||||
/// valid beats invalid; placing every requested part beats not; then lower
|
||||
/// JobResult.Cost wins - salvage-credited sheet area consumed plus a
|
||||
/// largest-sheet penalty per unplaced part, so dropping awkward parts can
|
||||
/// 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>
|
||||
public static class Report
|
||||
{
|
||||
@@ -28,19 +30,28 @@ namespace OpenNest.Benchmark
|
||||
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
|
||||
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)
|
||||
{
|
||||
var isWinner = best != null && Compare(r, best) == 0 && r.Valid;
|
||||
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 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));
|
||||
|
||||
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),
|
||||
Crashed = g.Count(r => r.Crashed),
|
||||
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),
|
||||
TotalTimeMs = g.Sum(r => r.ElapsedMs),
|
||||
})
|
||||
.OrderByDescending(e => e.TotalUtilization)
|
||||
.OrderBy(e => e.TotalCost)
|
||||
.ToList();
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
|
||||
sb.AppendLine(string.Join(",",
|
||||
Csv(r.JobName), Csv(r.EngineName), r.Valid, r.Crashed, r.FullyPlaced,
|
||||
r.PartsPlaced, r.PartsRequested,
|
||||
r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
|
||||
r.PlatesUsed, Csv(SizeSummary(r.SizeBreakdown)),
|
||||
r.ElapsedMs, Csv(notes)));
|
||||
sb.AppendLine(
|
||||
string.Join(
|
||||
",",
|
||||
Csv(r.JobName),
|
||||
Csv(r.EngineName),
|
||||
r.Valid,
|
||||
r.Crashed,
|
||||
r.FullyPlaced,
|
||||
r.PartsPlaced,
|
||||
r.PartsRequested,
|
||||
r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
|
||||
r.NetUtilization.ToString("F4", CultureInfo.InvariantCulture),
|
||||
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());
|
||||
@@ -134,9 +174,10 @@ namespace OpenNest.Benchmark
|
||||
return wins;
|
||||
}
|
||||
|
||||
/// <summary>Lower sorts first (better). Valid beats invalid, then higher
|
||||
/// aggregate utilization, then (if both fully placed) fewer plates used.</summary>
|
||||
private static int Compare(JobResult a, JobResult b)
|
||||
/// <summary>Lower sorts first (better). Valid beats invalid, complete beats
|
||||
/// incomplete, then lower cost (relative tolerance, since costs are areas
|
||||
/// in whatever units the job uses), then fewer plates.</summary>
|
||||
public static int Compare(JobResult a, JobResult b)
|
||||
{
|
||||
if (a.Valid != b.Valid)
|
||||
return a.Valid ? -1 : 1;
|
||||
@@ -144,12 +185,16 @@ namespace OpenNest.Benchmark
|
||||
if (!a.Valid)
|
||||
return 0;
|
||||
|
||||
var utilDiff = b.Utilization - a.Utilization;
|
||||
if (a.FullyPlaced != b.FullyPlaced)
|
||||
return a.FullyPlaced ? -1 : 1;
|
||||
|
||||
if (System.Math.Abs(utilDiff) > Epsilon)
|
||||
return utilDiff > 0 ? 1 : -1;
|
||||
var costDiff = a.Cost - b.Cost;
|
||||
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 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Console</RootNamespace>
|
||||
<AssemblyName>OpenNest.Console</AssemblyName>
|
||||
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
|
||||
|
||||
+383
-89
@@ -1,13 +1,19 @@
|
||||
using OpenNest;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
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);
|
||||
|
||||
@@ -15,17 +21,88 @@ static class NestConsole
|
||||
{
|
||||
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);
|
||||
|
||||
if (options == null)
|
||||
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)
|
||||
{
|
||||
ListPostProcessors(options);
|
||||
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)
|
||||
{
|
||||
// ResolveName also accepts renamed engines' old names (for example Opus55NestingEngine).
|
||||
if (NestingEngineRegistry.ResolveName(options.Engine) == null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
PrintUsage();
|
||||
@@ -48,6 +125,12 @@ static class NestConsole
|
||||
|
||||
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);
|
||||
ApplyOverrides(plate, options);
|
||||
|
||||
@@ -58,18 +141,21 @@ static class NestConsole
|
||||
|
||||
var existingCount = plate.Parts.Count;
|
||||
|
||||
if (!options.KeepParts)
|
||||
if (!options.AutoNest && !options.KeepParts)
|
||||
plate.Parts.Clear();
|
||||
|
||||
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);
|
||||
|
||||
PrintResults(success, plate, elapsed);
|
||||
Save(nest, options);
|
||||
PostProcess(nest, options);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!SaveAndPost(nest, options))
|
||||
return 1;
|
||||
|
||||
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
|
||||
}
|
||||
@@ -82,6 +168,26 @@ static class NestConsole
|
||||
{
|
||||
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:
|
||||
o.DrawingName = args[++i];
|
||||
break;
|
||||
@@ -113,15 +219,21 @@ static class NestConsole
|
||||
case "--template" when i + 1 < args.Length:
|
||||
o.TemplateFile = args[++i];
|
||||
break;
|
||||
case "--allow-invalid":
|
||||
o.AllowInvalid = true;
|
||||
break;
|
||||
case "--autonest":
|
||||
o.AutoNest = true;
|
||||
break;
|
||||
case "--engine" when i + 1 < args.Length:
|
||||
NestEngineRegistry.ActiveEngineName = args[++i];
|
||||
o.Engine = args[++i];
|
||||
break;
|
||||
case "--post" when i + 1 < args.Length:
|
||||
o.PostName = args[++i];
|
||||
break;
|
||||
case "--acknowledge-post-risks":
|
||||
o.AcknowledgePostRisks = true;
|
||||
break;
|
||||
case "--post-output" when i + 1 < args.Length:
|
||||
o.PostOutput = args[++i];
|
||||
break;
|
||||
@@ -149,10 +261,14 @@ static class NestConsole
|
||||
{
|
||||
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
||||
var dxfFiles = options.InputFiles.Where(f =>
|
||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
|
||||
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
var dxfFiles = options
|
||||
.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 (nestFile != null)
|
||||
@@ -167,13 +283,15 @@ static class NestConsole
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
foreach (var dxf in dxfFiles)
|
||||
{
|
||||
var drawing = ImportDxf(dxf);
|
||||
var drawing = ImportDxf(dxf, options);
|
||||
|
||||
if (drawing == null)
|
||||
return null;
|
||||
@@ -194,7 +312,9 @@ static class NestConsole
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -204,7 +324,7 @@ static class NestConsole
|
||||
|
||||
foreach (var dxf in dxfFiles)
|
||||
{
|
||||
var drawing = ImportDxf(dxf);
|
||||
var drawing = ImportDxf(dxf, options);
|
||||
|
||||
if (drawing == null)
|
||||
return null;
|
||||
@@ -216,11 +336,28 @@ static class NestConsole
|
||||
return newNest;
|
||||
}
|
||||
|
||||
static Drawing ImportDxf(string path)
|
||||
static Drawing ImportDxf(string path, Options options)
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -256,7 +393,8 @@ static class NestConsole
|
||||
// Only apply size override when it wasn't already used to create the plate.
|
||||
var hasDxfOnly = !options.InputFiles.Any(f =>
|
||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
|
||||
if (options.PlateSize.HasValue && !hasDxfOnly)
|
||||
plate.Size = options.PlateSize.Value;
|
||||
@@ -264,36 +402,61 @@ static class NestConsole
|
||||
|
||||
static Drawing ResolveDrawing(Nest nest, Options options)
|
||||
{
|
||||
var drawing = options.DrawingName != null
|
||||
? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName)
|
||||
: nest.Drawings.FirstOrDefault();
|
||||
var drawing =
|
||||
options.DrawingName != null
|
||||
? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName)
|
||||
: nest.Drawings.FirstOrDefault();
|
||||
|
||||
if (drawing != null)
|
||||
return drawing;
|
||||
|
||||
Console.Error.WriteLine(options.DrawingName != null
|
||||
? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}"
|
||||
: "Error: nest file contains no drawings");
|
||||
Console.Error.WriteLine(
|
||||
options.DrawingName != null
|
||||
? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}"
|
||||
: "Error: nest file contains no drawings"
|
||||
);
|
||||
|
||||
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}");
|
||||
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($"Drawing: {drawing.Name}");
|
||||
Console.WriteLine(options.KeepParts
|
||||
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}"
|
||||
);
|
||||
var existingPartsMessage = options.KeepParts
|
||||
? $"Keeping {existingCount} existing parts"
|
||||
: $"Cleared {existingCount} existing parts");
|
||||
Console.WriteLine("---");
|
||||
: options.AutoNest
|
||||
? $"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();
|
||||
bool success;
|
||||
var accepted = true;
|
||||
|
||||
if (options.AutoNest)
|
||||
{
|
||||
@@ -310,22 +473,96 @@ static class NestConsole
|
||||
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 nestParts = engine.Nest(nestItems, null, CancellationToken.None);
|
||||
plate.Parts.AddRange(nestParts);
|
||||
success = nestParts.Count > 0;
|
||||
var outcome = AutoNestJob(plate, nestItems, options.Engine, options.AllowInvalid, token);
|
||||
accepted = outcome.accepted;
|
||||
success = outcome.committed > 0;
|
||||
}
|
||||
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 };
|
||||
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();
|
||||
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)
|
||||
@@ -334,19 +571,25 @@ static class NestConsole
|
||||
return 0;
|
||||
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
||||
Console.WriteLine(hasOverlaps
|
||||
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
||||
: "Overlap check: PASS");
|
||||
Console.WriteLine(
|
||||
hasOverlaps
|
||||
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
||||
: "Overlap check: PASS"
|
||||
);
|
||||
|
||||
return overlapPts.Count;
|
||||
}
|
||||
|
||||
static void PrintResults(bool success, Plate plate, long elapsedMs)
|
||||
{
|
||||
Console.WriteLine($"Result: {(success ? "success" : "failed")}");
|
||||
Console.WriteLine($"Parts placed: {plate.Parts.Count}");
|
||||
Console.WriteLine($"Utilization: {plate.Utilization():P1}");
|
||||
Console.WriteLine($"Time: {elapsedMs}ms");
|
||||
Console.WriteLine(
|
||||
$"""
|
||||
Result: {(success ? "success" : "failed")}
|
||||
Parts placed: {plate.Parts.Count}
|
||||
Utilization: {plate.Utilization():P1}
|
||||
Time: {elapsedMs}ms
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
static void Save(Nest nest, Options options)
|
||||
@@ -354,22 +597,23 @@ static class NestConsole
|
||||
if (options.NoSave)
|
||||
return;
|
||||
|
||||
var firstInput = options.InputFiles[0];
|
||||
var outputFile = options.OutputFile ?? Path.Combine(
|
||||
Path.GetDirectoryName(firstInput),
|
||||
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}");
|
||||
var outputFile = NestOutputPath(options);
|
||||
|
||||
new NestWriter(nest).Write(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)
|
||||
{
|
||||
if (options.PostsDir != null)
|
||||
return options.PostsDir;
|
||||
|
||||
var exePath = Assembly.GetEntryAssembly()?.Location
|
||||
?? typeof(NestConsole).Assembly.Location;
|
||||
var exePath =
|
||||
Assembly.GetEntryAssembly()?.Location ?? typeof(NestConsole).Assembly.Location;
|
||||
return Path.Combine(Path.GetDirectoryName(exePath), "Posts");
|
||||
}
|
||||
|
||||
@@ -388,7 +632,11 @@ static class NestConsole
|
||||
|
||||
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;
|
||||
|
||||
if (Activator.CreateInstance(type) is IPostProcessor processor)
|
||||
@@ -397,7 +645,9 @@ static class NestConsole
|
||||
}
|
||||
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 +671,40 @@ static class NestConsole
|
||||
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)
|
||||
return;
|
||||
{
|
||||
Save(nest, options);
|
||||
return true;
|
||||
}
|
||||
|
||||
var postsDir = ResolvePostsDir(options);
|
||||
var processors = LoadPostProcessors(postsDir);
|
||||
var post = processors.FirstOrDefault(p =>
|
||||
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase));
|
||||
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
|
||||
if (post == null)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found");
|
||||
|
||||
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
|
||||
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;
|
||||
@@ -450,42 +714,67 @@ static class NestConsole
|
||||
var firstInput = options.InputFiles[0];
|
||||
outputFile = Path.Combine(
|
||||
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);
|
||||
Console.WriteLine($"Post: {post.Name} -> {outputFile}");
|
||||
foreach (var file in outputFiles)
|
||||
Console.WriteLine($"Post: {post.Name} -> {file}");
|
||||
return true;
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Arguments:");
|
||||
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf/.dwg drawing files");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Modes:");
|
||||
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
|
||||
Console.Error.WriteLine(" <part.dxf> --size WxL Import DXF, create plate, and fill");
|
||||
Console.Error.WriteLine(" <nest.nest> <part.dxf> Load nest and add imported DXF drawings");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Options:");
|
||||
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)");
|
||||
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)");
|
||||
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
|
||||
Console.Error.WriteLine(" --spacing <value> Override part spacing");
|
||||
Console.Error.WriteLine(" --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode");
|
||||
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.nest)");
|
||||
Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)");
|
||||
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
|
||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
||||
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
||||
Console.Error.WriteLine(" --no-save Skip saving output file");
|
||||
Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
|
||||
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)");
|
||||
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
|
||||
Console.Error.WriteLine(" --list-posts List available post processors and exit");
|
||||
Console.Error.WriteLine(" -h, --help Show this help");
|
||||
Console.Error.WriteLine(
|
||||
"""
|
||||
Usage: OpenNest.Console <input-files...> [options]
|
||||
|
||||
Arguments:
|
||||
input-files One or more .nest nest files or .dxf/.dwg drawing files
|
||||
|
||||
Modes:
|
||||
<nest.nest> Load nest and fill (existing behavior)
|
||||
<part.dxf> --size WxL Import DXF, create plate, and fill
|
||||
<nest.nest> <part.dxf> Load nest and add imported DXF drawings
|
||||
|
||||
Options:
|
||||
--repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm
|
||||
--cad-units inches|mm Explicit source coordinate units required for bend repair
|
||||
--drawing <name> Drawing name to fill with (default: first drawing)
|
||||
--plate <index> Plate index to fill (default: 0)
|
||||
--quantity <n> Max parts to place (default: 0 = unlimited)
|
||||
--spacing <value> Override part spacing
|
||||
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
|
||||
--output <path> Output nest file path (default: <input>-result.nest)
|
||||
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
|
||||
--autonest Validated whole-job nesting onto one sheet; replaces only after acceptance
|
||||
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
|
||||
--engine <name> With --autonest: jobs engine (default: Default; also Irregular, Rectangles, Fill, ...).
|
||||
Without --autonest: fill strategy (Fill (default), Strip, Vertical Remnant, Horizontal Remnant)
|
||||
--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
|
||||
@@ -501,10 +790,15 @@ static class NestConsole
|
||||
public bool NoSave;
|
||||
public bool KeepParts;
|
||||
public bool AutoNest;
|
||||
public bool AllowInvalid;
|
||||
public string Engine = "Default";
|
||||
public string TemplateFile;
|
||||
public string PostName;
|
||||
public bool AcknowledgePostRisks;
|
||||
public string PostOutput;
|
||||
public string PostsDir;
|
||||
public bool ListPosts;
|
||||
public double? RepairBendsMillimeters;
|
||||
public BendRepairUnits CadUnits;
|
||||
}
|
||||
}
|
||||
+35
-12
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
@@ -7,7 +7,10 @@ namespace OpenNest
|
||||
{
|
||||
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)
|
||||
@@ -17,7 +20,10 @@ namespace OpenNest
|
||||
|
||||
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)
|
||||
@@ -27,7 +33,10 @@ namespace OpenNest
|
||||
|
||||
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)
|
||||
@@ -37,7 +46,10 @@ namespace OpenNest
|
||||
|
||||
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)
|
||||
@@ -67,7 +79,10 @@ namespace OpenNest
|
||||
|
||||
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)
|
||||
@@ -107,7 +122,10 @@ namespace OpenNest
|
||||
|
||||
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)
|
||||
@@ -137,14 +155,19 @@ namespace OpenNest
|
||||
return;
|
||||
|
||||
var list = new List<Part>(parts);
|
||||
list.Sort((p1, p2) => horizontal
|
||||
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)
|
||||
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y));
|
||||
list.Sort(
|
||||
(p1, p2) =>
|
||||
horizontal
|
||||
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)
|
||||
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y)
|
||||
);
|
||||
|
||||
var lastIndex = list.Count - 1;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
namespace OpenNest
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum AlignType
|
||||
{
|
||||
@@ -10,6 +9,6 @@ namespace OpenNest
|
||||
Horizontally,
|
||||
Vertically,
|
||||
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.Drawing;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Bending
|
||||
{
|
||||
@@ -10,7 +10,7 @@ namespace OpenNest.Bending
|
||||
public static readonly Layer EtchLayer = new Layer("ETCH")
|
||||
{
|
||||
Color = Color.Green,
|
||||
IsVisible = true
|
||||
IsVisible = true,
|
||||
};
|
||||
|
||||
private const double DefaultEtchLength = 1.0;
|
||||
@@ -32,9 +32,8 @@ namespace OpenNest.Bending
|
||||
|
||||
public double Length => StartPoint.DistanceTo(EndPoint);
|
||||
|
||||
public double AngleRadians => Angle.HasValue
|
||||
? OpenNest.Math.Angle.ToRadians(Angle.Value)
|
||||
: 0;
|
||||
public double AngleRadians =>
|
||||
Angle.HasValue ? OpenNest.Math.Angle.ToRadians(Angle.Value) : 0;
|
||||
|
||||
public Line ToLine() => new Line(StartPoint, EndPoint);
|
||||
|
||||
@@ -66,7 +65,9 @@ namespace OpenNest.Bending
|
||||
var dx = System.Math.Cos(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));
|
||||
}
|
||||
|
||||
@@ -79,7 +80,8 @@ namespace OpenNest.Bending
|
||||
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
|
||||
{
|
||||
entities.RemoveAll(e => e.Tag == BendEtchTag);
|
||||
if (bends == null) return;
|
||||
if (bends == null)
|
||||
return;
|
||||
|
||||
foreach (var bend in bends)
|
||||
entities.AddRange(bend.GetEtchEntities());
|
||||
@@ -87,7 +89,12 @@ namespace OpenNest.Bending
|
||||
|
||||
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()
|
||||
|
||||
@@ -4,6 +4,6 @@ namespace OpenNest.Bending
|
||||
{
|
||||
Unknown,
|
||||
Up,
|
||||
Down
|
||||
Down,
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,22 @@ namespace OpenNest.CNC
|
||||
{
|
||||
public class ArcMove : Motion
|
||||
{
|
||||
public ArcMove()
|
||||
{
|
||||
}
|
||||
public ArcMove() { }
|
||||
|
||||
public ArcMove(double x, double y, double i, double j, RotationType rotation = RotationType.CCW)
|
||||
: this(new Vector(x, y), new Vector(i, j), rotation)
|
||||
{
|
||||
}
|
||||
public ArcMove(
|
||||
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;
|
||||
CenterPoint = centerPoint;
|
||||
@@ -68,7 +74,8 @@ namespace OpenNest.CNC
|
||||
{
|
||||
Layer = Layer,
|
||||
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 j = CenterPoint.Y.ToString(dp);
|
||||
|
||||
return Rotation == RotationType.CW ?
|
||||
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);
|
||||
return Rotation == RotationType.CW
|
||||
? 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
namespace OpenNest.CNC
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public enum CodeType
|
||||
{
|
||||
@@ -9,6 +8,6 @@ namespace OpenNest.CNC
|
||||
RapidMove,
|
||||
SetFeedrate,
|
||||
SetKerf,
|
||||
SubProgramCall
|
||||
SubProgramCall,
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@
|
||||
{
|
||||
public class Comment : ICode
|
||||
{
|
||||
public Comment()
|
||||
{
|
||||
}
|
||||
public Comment() { }
|
||||
|
||||
public Comment(string value)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Automatic start preference, ordered: a lower value is more preferred.</summary>
|
||||
internal enum AutomaticEntryKind
|
||||
{
|
||||
/// <summary>A convex turn of the contour's own travel, classified exactly as emission classifies it.</summary>
|
||||
ConvexCorner = 0,
|
||||
|
||||
/// <summary>The midpoint of a straight edge.</summary>
|
||||
StraightMidpoint = 1,
|
||||
|
||||
/// <summary>A tangent line/arc joint. A collinear line/line split is not a joint and never appears.</summary>
|
||||
TangentJoint = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Tier 3 (fallback): a point on a straight edge meeting a convex corner, back from the
|
||||
/// corner by about twice the applicable lead-in length, strictly inside the edge.
|
||||
/// </summary>
|
||||
NearCorner = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Tier 3 (fallback): the exact native closest point facing the caller's look-ahead
|
||||
/// position (pass the arrival there when there is no next cut). Never a reflex/cusp vertex.
|
||||
/// </summary>
|
||||
TargetFacing = 4,
|
||||
|
||||
/// <summary>Tier 3 (fallback): the native midpoint of an arc entity.</summary>
|
||||
ArcMidpoint = 5,
|
||||
|
||||
/// <summary>Tier 3 (fallback): one of the eight compass points of a whole circle.</summary>
|
||||
CircleCompass = 6,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One automatic start candidate: the owned contour choice plus its preference kind and a
|
||||
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
|
||||
/// owner; nothing here exposes or mutates the underlying shape.
|
||||
/// </summary>
|
||||
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
|
||||
/// equal points rank together regardless of the entity that produced them.
|
||||
/// </summary>
|
||||
internal (long X, long Y) GeometryKey => (Quantize(Choice.Point.X), Quantize(Choice.Point.Y));
|
||||
|
||||
private static long Quantize(double value) =>
|
||||
(long)System.Math.Round(value / PostVerificationGeometry.Epsilon);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Pure deterministic ordering of the automatic entry catalogue toward the next cut:
|
||||
/// circles rank by outgoing distance; other contours use facing sides, tier and travel,
|
||||
/// then a stable geometric key. It adds no
|
||||
/// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility
|
||||
/// filtering and the bounded selection belong to S07/S08, the wiring to S09.
|
||||
/// </summary>
|
||||
internal static class ContourEntryRanking
|
||||
{
|
||||
/// <summary>
|
||||
/// Orders <paramref name="candidates"/> for one contour in local coordinates. With a
|
||||
/// <paramref name="target"/> (the next cut's look-ahead point): the number of matched
|
||||
/// facing sides of the candidate bounding rectangle descending (a corner on both facing
|
||||
/// sides is ideal), then rank tier ascending, then arrival->entry + entry->target
|
||||
/// ascending, then the stable geometric key. With no target (the last part): tier first,
|
||||
/// then distance to <paramref name="arrival"/> — never toward the plate origin. The
|
||||
/// input list is returned untouched as a new list; entity order is never meaningful.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<ContourEntryCandidate> RankTowardNextCut(
|
||||
this IReadOnlyList<ContourEntryCandidate> candidates,
|
||||
Vector? target = null,
|
||||
Vector? arrival = null)
|
||||
{
|
||||
if (target.HasValue) PostVerificationGeometry.Validate(target.Value);
|
||||
if (arrival.HasValue) PostVerificationGeometry.Validate(arrival.Value);
|
||||
if (candidates.Count == 0)
|
||||
return new List<ContourEntryCandidate>();
|
||||
|
||||
// Whole circles have no corners. A diagonal compass point is equally near two
|
||||
// bounding-box sides, but must not gain the two-side bonus of a real corner.
|
||||
// Rank outgoing travel first so arrival cannot pull the start away from the next
|
||||
// cut. Retain every compass/polar alternative for feasibility and rapid checks;
|
||||
// configured angle rounding remains the emitter's responsibility.
|
||||
if (target is { } next && candidates.Any(c => c.Kind == AutomaticEntryKind.CircleCompass)
|
||||
&& candidates.All(c => c.Kind is AutomaticEntryKind.CircleCompass or AutomaticEntryKind.TargetFacing))
|
||||
return candidates.OrderBy(c => c.Choice.Point.DistanceTo(next))
|
||||
.ThenBy(c => arrival is { } from ? c.Choice.Point.DistanceTo(from) : 0.0)
|
||||
.ThenBy(c => c.GeometryKey.X)
|
||||
.ThenBy(c => c.GeometryKey.Y)
|
||||
.ToList();
|
||||
|
||||
// Candidate bounding rectangle in the contour's local coordinates; every candidate
|
||||
// lies on the contour, so distances to the four side lines order side proximity.
|
||||
var minX = double.PositiveInfinity;
|
||||
var minY = double.PositiveInfinity;
|
||||
var maxX = double.NegativeInfinity;
|
||||
var maxY = double.NegativeInfinity;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var p = candidate.Choice.Point;
|
||||
if (p.X < minX) minX = p.X;
|
||||
if (p.X > maxX) maxX = p.X;
|
||||
if (p.Y < minY) minY = p.Y;
|
||||
if (p.Y > maxY) maxY = p.Y;
|
||||
}
|
||||
|
||||
// Facing sides from the target relative to the centre, matching the source plan:
|
||||
// horizontal right when the target is right of centre else left; vertical likewise.
|
||||
var centreX = minX + (maxX - minX) * 0.5;
|
||||
var centreY = minY + (maxY - minY) * 0.5;
|
||||
var facingRight = target != null && target.Value.X > centreX;
|
||||
var facingTop = target != null && target.Value.Y > centreY;
|
||||
|
||||
return candidates
|
||||
.Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival)))
|
||||
.OrderByDescending(x => x.Score.Facing)
|
||||
.ThenBy(x => x.Score.Tier)
|
||||
.ThenBy(x => x.Score.Travel)
|
||||
.ThenBy(x => x.Candidate.GeometryKey.X)
|
||||
.ThenBy(x => x.Candidate.GeometryKey.Y)
|
||||
.Select(x => x.Candidate)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ranking tier — coarser than the preference kind: outside corners first, then
|
||||
/// straight midpoints and tangent joints as peers, then every fallback kind.
|
||||
/// </summary>
|
||||
internal static int RankTier(this AutomaticEntryKind kind) => kind switch
|
||||
{
|
||||
AutomaticEntryKind.ConvexCorner => 0,
|
||||
AutomaticEntryKind.StraightMidpoint or AutomaticEntryKind.TangentJoint => 1,
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
private static (int Facing, int Tier, double Travel) Score(
|
||||
ContourEntryCandidate candidate,
|
||||
double minX,
|
||||
double minY,
|
||||
double maxX,
|
||||
double maxY,
|
||||
bool facingRight,
|
||||
bool facingTop,
|
||||
Vector? target,
|
||||
Vector? arrival)
|
||||
{
|
||||
var p = candidate.Choice.Point;
|
||||
var facing = 0;
|
||||
if (target != null)
|
||||
{
|
||||
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
|
||||
// sides within tolerance); count how many of them are facing sides.
|
||||
var left = p.X - minX;
|
||||
var right = maxX - p.X;
|
||||
var bottom = p.Y - minY;
|
||||
var top = maxY - p.Y;
|
||||
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
|
||||
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
}
|
||||
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
|
||||
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
|
||||
return (facing, candidate.Kind.RankTier(), travel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
using Curve = OpenNest.Diagnostics.PostVerificationGeometry.Curve;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Independently constructed expected geometry, frozen before final replay.
|
||||
/// It is not a search verdict or the payload returned to the caller.</summary>
|
||||
internal sealed class SelectedContourProgram
|
||||
{
|
||||
private readonly PreparedContours owner;
|
||||
private readonly ContourChoice[] choices;
|
||||
internal OwnedExecution Expected { get; }
|
||||
internal double TabSize { get; }
|
||||
|
||||
internal SelectedContourProgram(PreparedContours owner, IReadOnlyList<ContourChoice> choices,
|
||||
OwnedExecution expected, double tabSize)
|
||||
{
|
||||
this.owner = owner;
|
||||
this.choices = choices.ToArray();
|
||||
Expected = expected;
|
||||
TabSize = tabSize;
|
||||
}
|
||||
|
||||
internal bool Matches(PreparedContours prepared, IReadOnlyList<ContourChoice> selected)
|
||||
{
|
||||
if (!ReferenceEquals(owner, prepared) || !choices.SequenceEqual(selected)) return false;
|
||||
prepared.ValidateCompleteChoices(selected);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Directed native boundary accounting and selected-emission correspondence.
|
||||
/// No tessellation, topology inference, live source reads or final emission.</summary>
|
||||
internal static class ContourProgramVerifier
|
||||
{
|
||||
private const double Epsilon = PostVerificationGeometry.Epsilon;
|
||||
|
||||
internal static bool Verify(OwnedExecution actual, LeadMaterialSnapshot material,
|
||||
SelectedContourProgram selected, CancellationToken token)
|
||||
{
|
||||
var budget = 1000000;
|
||||
if (material == null || !material.IsComplete) return false;
|
||||
if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false;
|
||||
var visited = new HashSet<int>();
|
||||
foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (run.Curves.Count == 0) return false;
|
||||
var first = run.Curves[0];
|
||||
var ringIndex = -1;
|
||||
var entityIndex = -1;
|
||||
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
|
||||
for (var e = 0; e < material.Rings[r].Length; e++)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
var curve = material.Rings[r][e];
|
||||
if (curve.SameDirection(first) && curve.Contains(first.Start)
|
||||
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
||||
{
|
||||
ringIndex = r;
|
||||
entityIndex = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ringIndex < 0 || !visited.Add(ringIndex)) return false;
|
||||
var ring = material.Rings[ringIndex];
|
||||
var nominalLength = ring.Sum(c => c.Length);
|
||||
var cutLength = run.Curves.Sum(c => c.Length);
|
||||
var missing = nominalLength - cutLength;
|
||||
if (missing < -Epsilon) return false; // Never a second circuit/retrace.
|
||||
if (missing > Epsilon)
|
||||
{
|
||||
// Fixed legacy payloads have no certified selected tab/entry metadata.
|
||||
// A merely open contour or a caller's current tab switch proves nothing.
|
||||
if (selected == null || ringIndex != 0 || selected.TabSize <= 0
|
||||
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > Epsilon)
|
||||
return false;
|
||||
// SameEmission has already bound this exact terminal gap to the owned
|
||||
// settings' independently emitted, rounded/clamped selected geometry.
|
||||
}
|
||||
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
||||
true, token, ref budget)) return false;
|
||||
}
|
||||
return visited.Count == material.Rings.Count;
|
||||
}
|
||||
|
||||
private static bool SameEmission(OwnedExecution actual, OwnedExecution expected,
|
||||
CancellationToken token, ref int budget)
|
||||
{
|
||||
var left = Runs(actual);
|
||||
var right = Runs(expected);
|
||||
if (left.Count != right.Count) return false;
|
||||
for (var i = 0; i < left.Count; i++)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
var a = left[i];
|
||||
var b = right[i];
|
||||
if (a.Kind != b.Kind || a.Rapid != b.Rapid) return false;
|
||||
if (a.Rapid)
|
||||
{
|
||||
// Arrival is supplied anew by replay, not captured expected geometry.
|
||||
if (a.End.DistanceTo(b.End) > Epsilon) return false;
|
||||
}
|
||||
else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon
|
||||
|| !Follows(a.Curves, b.Curves, 0, 0, false, token, ref budget)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Consume directed native arc-length from both streams. Subdivision, merged
|
||||
// collinear moves and a cyclic ring's reindexing do not change coverage.
|
||||
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
|
||||
int index, double offset, bool cyclic, CancellationToken token, ref int budget)
|
||||
{
|
||||
foreach (var curve in actual)
|
||||
{
|
||||
var consumed = 0.0;
|
||||
while (consumed < curve.Length - Epsilon)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
if (index >= expected.Count)
|
||||
{
|
||||
if (!cyclic) return false;
|
||||
index = 0;
|
||||
}
|
||||
var nominal = expected[index];
|
||||
var remaining = nominal.Length - offset;
|
||||
if (remaining <= 0)
|
||||
{
|
||||
index++;
|
||||
offset = 0;
|
||||
continue;
|
||||
}
|
||||
var length = System.Math.Min(remaining, curve.Length - consumed);
|
||||
if (!nominal.SameDirection(curve)
|
||||
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > Epsilon
|
||||
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > Epsilon)
|
||||
return false;
|
||||
consumed += length;
|
||||
offset += length;
|
||||
if (offset >= nominal.Length - Epsilon)
|
||||
{
|
||||
index++;
|
||||
offset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cyclic || index == expected.Count || index == expected.Count - 1
|
||||
&& expected[index].Length - offset <= Epsilon;
|
||||
}
|
||||
|
||||
private static List<Run> Runs(OwnedExecution execution)
|
||||
{
|
||||
var runs = new List<Run>();
|
||||
foreach (var motion in execution.Motions)
|
||||
{
|
||||
var kind = motion.Layer is LayerType.Cut or LayerType.Display ? LayerType.Cut : motion.Layer;
|
||||
if (motion.Rapid)
|
||||
{
|
||||
runs.Add(new Run(true, kind, motion.End));
|
||||
continue;
|
||||
}
|
||||
if (motion.Length <= Epsilon) continue; // Zero travel contributes no coverage.
|
||||
if (runs.Count == 0 || runs[^1].Rapid || runs[^1].Kind != kind)
|
||||
runs.Add(new Run(false, kind, motion.End));
|
||||
runs[^1].Curves.Add(motion.Curve);
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
private static void Query(CancellationToken token, ref int budget)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0) throw new NotSupportedException("Replay contour accounting exceeds the native query limit.");
|
||||
}
|
||||
|
||||
private sealed record Run(bool Rapid, LayerType Kind, Vector End)
|
||||
{
|
||||
internal List<Curve> Curves { get; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>An owned planned program for one unlocked part; ownership passes to the part on Apply.</summary>
|
||||
internal sealed class PlannedPartProgram
|
||||
{
|
||||
public PlannedPartProgram(Part part, Program program, CuttingParameters parameters)
|
||||
{
|
||||
Part = part;
|
||||
Program = program;
|
||||
Parameters = parameters;
|
||||
}
|
||||
|
||||
public Part Part { get; }
|
||||
public Program Program { get; }
|
||||
public CuttingParameters Parameters { get; }
|
||||
}
|
||||
|
||||
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
|
||||
internal sealed class PlateCuttingPlan
|
||||
{
|
||||
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
|
||||
IEnumerable<PlannedPartProgram> programs = null)
|
||||
{
|
||||
Expected = expected;
|
||||
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
|
||||
Programs = Array.AsReadOnly((programs ?? []).ToArray());
|
||||
}
|
||||
|
||||
public PlateCuttingState Expected { get; }
|
||||
public IReadOnlyList<Part> Order { get; }
|
||||
public IReadOnlyList<PlannedPartProgram> Programs { get; }
|
||||
}
|
||||
|
||||
public enum CuttingCommitStatus
|
||||
{
|
||||
Applied,
|
||||
Stale,
|
||||
InvalidInput,
|
||||
Cancelled,
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applied means every plate in scope holds its new state. RefreshErrors are observer failures
|
||||
/// raised after that consistent state was published; they are not a rollback. Every other
|
||||
/// status leaves every plate exactly as it was.
|
||||
/// </summary>
|
||||
public sealed class CuttingCommitResult
|
||||
{
|
||||
internal CuttingCommitResult(CuttingCommitStatus status, string message = null, Plate plate = null,
|
||||
Exception error = null, IEnumerable<Exception> refreshErrors = null)
|
||||
{
|
||||
Status = status;
|
||||
Message = message;
|
||||
Plate = plate;
|
||||
Error = error;
|
||||
RefreshErrors = Array.AsReadOnly((refreshErrors ?? []).ToArray());
|
||||
}
|
||||
|
||||
public CuttingCommitStatus Status { get; }
|
||||
public string Message { get; }
|
||||
public Plate Plate { get; }
|
||||
public Exception Error { get; }
|
||||
public IReadOnlyList<Exception> RefreshErrors { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs verified cutting plans for a whole scope at once. Nothing is searched, emitted,
|
||||
/// rotated or regenerated here: inputs are validated and checked for freshness, bounds are
|
||||
/// staged, then order and programs are installed synchronously and published once per plate.
|
||||
/// Internal: it checks root program references only, so payloads must be owned copies of
|
||||
/// independently replayed proposals. CuttingPlanService.Apply is the public entry point.
|
||||
/// </summary>
|
||||
internal static class CuttingPlanCommit
|
||||
{
|
||||
internal static CuttingCommitResult Apply(IEnumerable<PlateCuttingPlan> plans, CancellationToken token = default) =>
|
||||
Apply(plans, token, null);
|
||||
|
||||
// beforeInstall is a test seam that runs inside the install boundary, before each program.
|
||||
internal static CuttingCommitResult Apply(IEnumerable<PlateCuttingPlan> plans, CancellationToken token,
|
||||
Action<Plate, Part> beforeInstall)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
||||
var scope = plans?.ToArray();
|
||||
if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
|
||||
return Invalid(null, "A nonempty set of captured plate plans is required.");
|
||||
var plates = new HashSet<Plate>(ReferenceEqualityComparer.Instance);
|
||||
foreach (var plan in scope)
|
||||
if (!plates.Add(plan.Expected.Plate))
|
||||
return Invalid(plan.Expected.Plate, "A plate appears more than once in the commit scope.");
|
||||
|
||||
// Freshness for the whole scope before any validation that reads live geometry.
|
||||
foreach (var plan in scope)
|
||||
{
|
||||
string difference;
|
||||
try
|
||||
{
|
||||
difference = plan.Expected.Difference(token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
||||
}
|
||||
if (difference != null)
|
||||
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
|
||||
}
|
||||
|
||||
var staged = new List<Staged>(scope.Length);
|
||||
var targets = new HashSet<Part>(ReferenceEqualityComparer.Instance);
|
||||
var members = new HashSet<Part>(ReferenceEqualityComparer.Instance);
|
||||
var installed = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
var live = new HashSet<Program>(scope.SelectMany(p => p.Expected.Order).Select(p => p.Program),
|
||||
ReferenceEqualityComparer.Instance);
|
||||
foreach (var plan in scope)
|
||||
{
|
||||
var plate = plan.Expected.Plate;
|
||||
Part[] order;
|
||||
try
|
||||
{
|
||||
order = plate.Parts.ValidateReorder(plan.Order);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||
{
|
||||
return Invalid(plate, "The planned order is not exactly the plate's current parts.");
|
||||
}
|
||||
// A part on two plates would let one plate's install change the other's verified program.
|
||||
if (order.Any(part => !members.Add(part)))
|
||||
return Invalid(plate, "A part appears on more than one plate in the commit scope.");
|
||||
var onPlate = new HashSet<Part>(order, ReferenceEqualityComparer.Instance);
|
||||
var programs = new List<(Part, Program, Box, CuttingParameters)>();
|
||||
foreach (var planned in plan.Programs)
|
||||
{
|
||||
if (planned?.Part == null || !onPlate.Contains(planned.Part) || !targets.Add(planned.Part))
|
||||
return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
|
||||
if (planned.Part.LeadInsLocked)
|
||||
return Invalid(plate, "A locked part's program is retained exactly and cannot be replaced.");
|
||||
if (planned.Program == null || planned.Parameters == null || live.Contains(planned.Program)
|
||||
|| !installed.Add(planned.Program))
|
||||
return Invalid(plate, "Planned programs and settings must be present, owned and unshared.");
|
||||
if (!planned.Program.Codes.Any(code => code is Motion
|
||||
|| code is SubProgramCall call && call.Program?.Codes.Any(sub => sub is Motion) == true))
|
||||
return Invalid(plate, "A planned program has no motion.");
|
||||
Box bounds;
|
||||
try
|
||||
{
|
||||
bounds = planned.Program.BoundingBox();
|
||||
bounds.Offset(planned.Part.Location);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
|
||||
or NotSupportedException or ArithmeticException)
|
||||
{
|
||||
return Invalid(plate, "A planned program's bounds cannot be computed.");
|
||||
}
|
||||
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
|
||||
}
|
||||
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
|
||||
staged.Add(new(plate, order, programs, reordered || programs.Count != 0));
|
||||
}
|
||||
|
||||
// Last cancellation point. The boundary below has no await, search or geometry work.
|
||||
if (token.IsCancellationRequested)
|
||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
||||
|
||||
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States)>();
|
||||
try
|
||||
{
|
||||
foreach (var item in staged)
|
||||
{
|
||||
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
|
||||
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray()));
|
||||
item.Plate.Parts.SetOrder(item.Order);
|
||||
foreach (var (part, program, bounds, parameters) in item.Programs)
|
||||
{
|
||||
beforeInstall?.Invoke(item.Plate, part);
|
||||
part.InstallPlannedProgram(program, bounds, parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
for (var i = undo.Count - 1; i >= 0; i--)
|
||||
{
|
||||
foreach (var (part, state) in undo[i].States)
|
||||
part.RestoreCuttingState(state);
|
||||
undo[i].Plate.Parts.SetOrder(undo[i].Order);
|
||||
}
|
||||
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
|
||||
undo.Count == 0 ? null : undo[^1].Plate, ex);
|
||||
}
|
||||
|
||||
// Publish only after the whole scope is consistent.
|
||||
var errors = new List<Exception>();
|
||||
foreach (var item in staged.Where(s => s.Changed))
|
||||
item.Plate.Parts.RaiseItemsReordered(errors);
|
||||
return new(CuttingCommitStatus.Applied, errors.Count == 0 ? null
|
||||
: "Applied; one or more views failed to refresh.", refreshErrors: errors);
|
||||
}
|
||||
|
||||
private static CuttingCommitResult Invalid(Plate plate, string message) =>
|
||||
new(CuttingCommitStatus.InvalidInput, message, plate);
|
||||
|
||||
private sealed record Staged(Plate Plate, Part[] Order,
|
||||
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs, bool Changed);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Guarded read of stable placed programs; never clones or mutates their graphs.</summary>
|
||||
public static class ExecutionMotionReader
|
||||
{
|
||||
public static OwnedExecution Read(Program program, Vector origin, Vector? previous,
|
||||
CancellationToken token)
|
||||
=> ReadCore(program, origin, previous, token, false);
|
||||
|
||||
/// <summary>Read only exact built-in runtime types and defined program modes.
|
||||
/// Validate the original graph before cloning: Clone can erase unsupported subclasses.
|
||||
/// Inputs must remain stable during this bounded, cancellation-aware read.</summary>
|
||||
public static OwnedExecution ReadSupported(Program program, Vector origin, Vector? previous,
|
||||
CancellationToken token = default)
|
||||
=> ReadCore(program, origin, previous, token, true);
|
||||
|
||||
private static OwnedExecution ReadCore(Program program, Vector origin, Vector? previous,
|
||||
CancellationToken token, bool requireSupportedTypes)
|
||||
{
|
||||
var moves = new List<ExecutionMotion>();
|
||||
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
var budget = 1000000;
|
||||
Walk(program, origin, previous);
|
||||
return new OwnedExecution(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.");
|
||||
if (requireSupportedTypes && (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode)))
|
||||
throw new NotSupportedException("Unsupported program runtime type or mode.");
|
||||
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 (requireSupportedTypes)
|
||||
{
|
||||
var type = code.GetType();
|
||||
if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove)
|
||||
&& type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf))
|
||||
throw new NotSupportedException("Unsupported instruction runtime type.");
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Owned nominal material: one simple perimeter minus disjoint simple holes.</summary>
|
||||
public sealed class LeadMaterialSnapshot
|
||||
{
|
||||
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, string reason)
|
||||
{
|
||||
Rings = rings;
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
public bool IsComplete => Reason == null;
|
||||
public string Reason { get; }
|
||||
internal IReadOnlyList<PostVerificationGeometry.Curve[]> Rings { get; }
|
||||
|
||||
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
|
||||
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
|
||||
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var execution = ExecutionMotionReader.ReadSupported(cleanProgram, location, null, token);
|
||||
var rings = new List<PostVerificationGeometry.Curve[]>();
|
||||
var chain = new List<PostVerificationGeometry.Curve>();
|
||||
foreach (var motion in execution.Motions)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (motion.Rapid || motion.Layer is LayerType.Scribe or LayerType.Leadin or LayerType.Leadout)
|
||||
{
|
||||
Finish();
|
||||
continue;
|
||||
}
|
||||
if (motion.Layer is not (LayerType.Cut or LayerType.Display))
|
||||
throw new ArgumentException("Material capture contains an unsupported layer.");
|
||||
var curve = motion.Curve;
|
||||
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Material contains a degenerate motion.");
|
||||
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Material contour is discontinuous.");
|
||||
chain.Add(curve);
|
||||
if (PostVerificationGeometry.Closed(chain))
|
||||
Finish();
|
||||
}
|
||||
Finish();
|
||||
if (rings.Count == 0)
|
||||
throw new ArgumentException("Material has no closed contour.");
|
||||
var budget = 1000000;
|
||||
// Certify simple rings and mutually disjoint boundaries before containment.
|
||||
for (var r = 0; r < rings.Count; r++)
|
||||
for (var s = r; s < rings.Count; s++)
|
||||
for (var i = 0; i < rings[r].Length; i++)
|
||||
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Material validation exceeds the native query limit.");
|
||||
var a = rings[r][i];
|
||||
var b = rings[s][j];
|
||||
var contacts = a.Contacts(b, out var overlap);
|
||||
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
||||
if (overlap || contacts.Any(p => !adjacent
|
||||
|| !(p.DistanceTo(a.End) <= PostVerificationGeometry.Epsilon
|
||||
&& p.DistanceTo(b.Start) <= PostVerificationGeometry.Epsilon)
|
||||
&& !(p.DistanceTo(a.Start) <= PostVerificationGeometry.Epsilon
|
||||
&& p.DistanceTo(b.End) <= PostVerificationGeometry.Epsilon)))
|
||||
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
||||
}
|
||||
var outer = -1;
|
||||
for (var i = 0; i < rings.Count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token)))
|
||||
{
|
||||
if (outer >= 0)
|
||||
throw new ArgumentException("Material perimeter is ambiguous.");
|
||||
outer = i;
|
||||
}
|
||||
}
|
||||
if (outer < 0)
|
||||
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
|
||||
var holes = rings.Where((_, i) => i != outer).ToArray();
|
||||
for (var i = 0; i < holes.Length; i++)
|
||||
for (var j = i + 1; j < holes.Length; j++)
|
||||
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
|
||||
throw new ArgumentException("Nested cutouts are not supported material.");
|
||||
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(), null);
|
||||
|
||||
void Finish()
|
||||
{
|
||||
if (chain.Count == 0)
|
||||
return;
|
||||
if (!PostVerificationGeometry.Closed(chain))
|
||||
throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly.");
|
||||
rings.Add(chain.ToArray());
|
||||
chain.Clear();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
return new LeadMaterialSnapshot(Array.Empty<PostVerificationGeometry.Curve[]>(), ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token)
|
||||
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token));
|
||||
|
||||
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token)
|
||||
{
|
||||
var inside = false;
|
||||
foreach (var curve in ring)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.CrossesRay(point))
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>A complete unsafe result is distinct from an incomplete/unsupported check.</summary>
|
||||
public sealed record LeadPathValidationResult(bool IsComplete, bool IsClear, string Reason);
|
||||
|
||||
/// <summary>Certifies actual emitted native lead paths against owned nominal material.</summary>
|
||||
public static class LeadPathValidator
|
||||
{
|
||||
public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
|
||||
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (execution == null || target == null || otherMaterials == null)
|
||||
return new(false, false, "Missing execution or material snapshots.");
|
||||
if (!target.IsComplete || otherMaterials.Any(m => m == null || !m.IsComplete))
|
||||
return new(false, false, "Material snapshot is incomplete.");
|
||||
try
|
||||
{
|
||||
var budget = 1000000;
|
||||
for (var i = 0; i < execution.Motions.Count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var move = execution.Motions[i];
|
||||
if (move.Layer is not (LayerType.Leadin or LayerType.Leadout))
|
||||
continue;
|
||||
if (move.Rapid || move.Start is not { } start || move.Curve == null
|
||||
|| !double.IsFinite(move.Length) || move.Length <= PostVerificationGeometry.Epsilon
|
||||
|| start.DistanceTo(move.Curve.Start) > PostVerificationGeometry.Epsilon
|
||||
|| move.End.DistanceTo(move.Curve.End) > PostVerificationGeometry.Epsilon)
|
||||
return new(false, false, "Lead motion is missing, degenerate or inconsistent.");
|
||||
Vector? allowed = null;
|
||||
var groupEdge = i;
|
||||
var step = move.Layer == LayerType.Leadin ? 1 : -1;
|
||||
while (groupEdge + step >= 0 && groupEdge + step < execution.Motions.Count
|
||||
&& execution.Motions[groupEdge + step].Layer == move.Layer)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
groupEdge += step;
|
||||
}
|
||||
var adjacentIndex = groupEdge + step;
|
||||
var genuineJoint = false;
|
||||
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
|
||||
{
|
||||
var adjacent = execution.Motions[adjacentIndex];
|
||||
if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
|
||||
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
var edge = execution.Motions[groupEdge];
|
||||
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
|
||||
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
|
||||
foreach (var boundary in target.Rings.SelectMany(r => r))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
if (joint.DistanceTo(contourJoint) <= PostVerificationGeometry.Epsilon
|
||||
&& boundary.SameSupport(adjacent.Curve)
|
||||
&& boundary.Contains(adjacent.Curve.Start) && boundary.Contains(adjacent.End)
|
||||
&& boundary.Contains(adjacent.Curve.Midpoint)
|
||||
&& adjacent.Length <= boundary.Length + PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
genuineJoint = true;
|
||||
if (groupEdge == i)
|
||||
allowed = joint;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!genuineJoint)
|
||||
return new(true, false, "Lead chain has no genuine adjacent target contour entry or exit.");
|
||||
if (allowed is { } jointPoint
|
||||
&& (move.Layer == LayerType.Leadin ? start : move.End).DistanceTo(jointPoint) <= PostVerificationGeometry.Epsilon)
|
||||
return new(true, false, "A positive-length lead returns to its contour joint; contact is not endpoint-only.");
|
||||
var failure = CheckMaterial(target, allowed);
|
||||
if (failure != null)
|
||||
return new(true, false, $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure}).");
|
||||
foreach (var material in otherMaterials)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (ReferenceEquals(material, target))
|
||||
continue;
|
||||
if (CheckMaterial(material, null) != null)
|
||||
return new(true, false, "Lead contacts or enters another placed material. "
|
||||
+ "Try spacing the parts farther apart or reducing the lead-in/lead-out length, then replan. "
|
||||
+ "For locked parts, edit the leads or unlock the part before replanning.");
|
||||
}
|
||||
|
||||
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint)
|
||||
{
|
||||
foreach (var boundary in material.Rings.SelectMany(r => r))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
var contacts = move.Curve.Contacts(boundary, out var overlap);
|
||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|
||||
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
|
||||
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
|
||||
}
|
||||
// With all other boundary contacts excluded, the connected open path
|
||||
// has constant material membership. Use the native arc midpoint, not
|
||||
// the chord midpoint or endpoints (which can both lie in scrap).
|
||||
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
|
||||
}
|
||||
}
|
||||
// Missing leads are the ReleasedContourState check's responsibility.
|
||||
return new(true, true, null);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
return new(false, false, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Copies only supported, exact setting types; never slices custom subclasses.</summary>
|
||||
internal static class OwnedCuttingParameters
|
||||
{
|
||||
internal static CuttingParameters Copy(CuttingParameters source)
|
||||
{
|
||||
Exact<CuttingParameters>(source);
|
||||
Exact<SequenceParameters>(source.Sequencing);
|
||||
Exact<AssignmentParameters>(source.Assignment);
|
||||
var sequence = source.Sequencing;
|
||||
var assignment = source.Assignment;
|
||||
if (!Enum.IsDefined(sequence.Method) || !Enum.IsDefined(assignment.Method))
|
||||
throw new ArgumentException("Unsupported sequence method.");
|
||||
if (assignment.Preference == null)
|
||||
throw new ArgumentException("Missing assignment preference.");
|
||||
if (!double.IsFinite(source.LeadInAngleIncrement) || source.LeadInAngleIncrement <= 0
|
||||
|| source.LeadInAngleIncrement > 360)
|
||||
throw new ArgumentException("Angle increment must be in (0, 360] degrees.");
|
||||
return new CuttingParameters
|
||||
{
|
||||
Id = source.Id,
|
||||
MachineName = source.MachineName,
|
||||
MaterialName = source.MaterialName,
|
||||
Grade = source.Grade,
|
||||
Thickness = Dimension(source.Thickness),
|
||||
Kerf = Dimension(source.Kerf),
|
||||
PartSpacing = Dimension(source.PartSpacing),
|
||||
PierceClearance = Dimension(source.PierceClearance),
|
||||
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
|
||||
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
|
||||
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
|
||||
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
|
||||
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
|
||||
ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
|
||||
RoundLeadInAngles = source.RoundLeadInAngles,
|
||||
LeadInAngleIncrement = source.LeadInAngleIncrement,
|
||||
AutoTabMinSize = Dimension(source.AutoTabMinSize),
|
||||
AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
|
||||
TabConfig = source.TabConfig == null
|
||||
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
|
||||
: CopyTab(source.TabConfig),
|
||||
TabsEnabled = source.TabsEnabled,
|
||||
Sequencing = new SequenceParameters
|
||||
{
|
||||
Method = sequence.Method,
|
||||
SmallCutoutWidth = Dimension(sequence.SmallCutoutWidth),
|
||||
SmallCutoutHeight = Dimension(sequence.SmallCutoutHeight),
|
||||
MediumCutoutWidth = Dimension(sequence.MediumCutoutWidth),
|
||||
MediumCutoutHeight = Dimension(sequence.MediumCutoutHeight),
|
||||
DistanceMediumSmall = Dimension(sequence.DistanceMediumSmall),
|
||||
AlternateRowsColumns = sequence.AlternateRowsColumns,
|
||||
AlternateCutoutsWithinRowColumn = sequence.AlternateCutoutsWithinRowColumn,
|
||||
MinDistanceBetweenRowsColumns = Dimension(sequence.MinDistanceBetweenRowsColumns)
|
||||
},
|
||||
Assignment = new AssignmentParameters
|
||||
{
|
||||
Method = assignment.Method,
|
||||
Preference = assignment.Preference,
|
||||
MinGeometryLength = Dimension(assignment.MinGeometryLength)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static LeadIn CopyLeadIn(LeadIn source) => source switch
|
||||
{
|
||||
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
|
||||
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
|
||||
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
|
||||
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn { Radius = Dimension(n.Radius) },
|
||||
LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
|
||||
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
|
||||
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
|
||||
{
|
||||
Length1 = Dimension(n.Length1),
|
||||
Length2 = Dimension(n.Length2),
|
||||
ApproachAngle1 = Angle(n.ApproachAngle1),
|
||||
ApproachAngle2 = Angle(n.ApproachAngle2)
|
||||
},
|
||||
CleanHoleLeadIn n when n.GetType() == typeof(CleanHoleLeadIn) => new CleanHoleLeadIn
|
||||
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), Kerf = Dimension(n.Kerf) },
|
||||
null => throw new ArgumentException("Missing lead-in settings."),
|
||||
_ => throw new NotSupportedException("Unsupported lead-in runtime type.")
|
||||
};
|
||||
|
||||
private static LeadOut CopyLeadOut(LeadOut source) => source switch
|
||||
{
|
||||
NoLeadOut n when n.GetType() == typeof(NoLeadOut) => new NoLeadOut(),
|
||||
LineLeadOut n when n.GetType() == typeof(LineLeadOut) => new LineLeadOut
|
||||
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
|
||||
ArcLeadOut n when n.GetType() == typeof(ArcLeadOut) => new ArcLeadOut { Radius = Dimension(n.Radius) },
|
||||
null => throw new ArgumentException("Missing lead-out settings."),
|
||||
_ => throw new NotSupportedException("Unsupported lead-out runtime type.")
|
||||
};
|
||||
|
||||
private static Tab CopyTab(Tab source)
|
||||
{
|
||||
Tab copy = source switch
|
||||
{
|
||||
NormalTab n when n.GetType() == typeof(NormalTab) => new NormalTab
|
||||
{
|
||||
CutoutMinWidth = Dimension(n.CutoutMinWidth),
|
||||
CutoutMinHeight = Dimension(n.CutoutMinHeight),
|
||||
CutoutMaxWidth = Dimension(n.CutoutMaxWidth),
|
||||
CutoutMaxHeight = Dimension(n.CutoutMaxHeight)
|
||||
},
|
||||
BreakerTab n when n.GetType() == typeof(BreakerTab) => new BreakerTab
|
||||
{
|
||||
BreakerDepth = Dimension(n.BreakerDepth),
|
||||
BreakerLeadInLength = Dimension(n.BreakerLeadInLength),
|
||||
BreakerAngle = Angle(n.BreakerAngle)
|
||||
},
|
||||
MachineTab n when n.GetType() == typeof(MachineTab) => new MachineTab { MachineTabId = n.MachineTabId },
|
||||
_ => throw new NotSupportedException("Unsupported tab runtime type.")
|
||||
};
|
||||
copy.Size = Dimension(source.Size);
|
||||
copy.TabLeadIn = source.TabLeadIn == null ? null : CopyLeadIn(source.TabLeadIn);
|
||||
copy.TabLeadOut = source.TabLeadOut == null ? null : CopyLeadOut(source.TabLeadOut);
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static void Exact<T>(T value) where T : class
|
||||
{
|
||||
if (value == null)
|
||||
throw new ArgumentException("Missing cutting settings.");
|
||||
if (value.GetType() != typeof(T))
|
||||
throw new NotSupportedException("Unsupported settings runtime type.");
|
||||
}
|
||||
|
||||
private static double Dimension(double value) => double.IsFinite(value) && value >= 0
|
||||
? value : throw new ArgumentException("Dimensions must be finite and nonnegative.");
|
||||
|
||||
// Approach angles are degrees, not lengths. Signed and periodic angles are valid.
|
||||
private static double Angle(double value) => double.IsFinite(value)
|
||||
? value : throw new ArgumentException("Angles must be finite.");
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Owned value motions; no references to live programs, instructions or settings.</summary>
|
||||
public sealed class OwnedExecution
|
||||
{
|
||||
internal OwnedExecution(IEnumerable<ExecutionMotion> motions)
|
||||
{
|
||||
Motions = Array.AsReadOnly(motions.ToArray());
|
||||
}
|
||||
|
||||
public IReadOnlyList<ExecutionMotion> Motions { get; }
|
||||
public Vector DeparturePoint => Motions[^1].End;
|
||||
public bool HasCuttingContour => Motions.Any(move => !move.Rapid
|
||||
&& move.Layer is LayerType.Cut or LayerType.Display
|
||||
&& move.Length > PostVerificationGeometry.Epsilon);
|
||||
|
||||
public double RapidDistanceFrom(Vector arrival)
|
||||
{
|
||||
PostVerificationGeometry.Validate(arrival);
|
||||
var total = 0.0;
|
||||
for (var index = 0; index < Motions.Count; index++)
|
||||
{
|
||||
var move = Motions[index];
|
||||
if (move.Rapid && (index == 0 ? arrival : move.Start) is { } start)
|
||||
total += start.DistanceTo(move.End);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An immutable motion value; native line/arc geometry stays private to Core.</summary>
|
||||
public sealed class ExecutionMotion
|
||||
{
|
||||
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
|
||||
PostVerificationGeometry.Curve curve)
|
||||
{
|
||||
Start = start;
|
||||
End = end;
|
||||
Rapid = rapid;
|
||||
Layer = layer;
|
||||
Curve = curve;
|
||||
}
|
||||
|
||||
public Vector? Start { get; }
|
||||
public Vector End { get; }
|
||||
public bool Rapid { get; }
|
||||
public LayerType Layer { get; }
|
||||
public double Length => Curve?.Length ?? 0;
|
||||
internal PostVerificationGeometry.Curve Curve { get; }
|
||||
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Lossless owned copies at the cutting-plan boundary, not a general Clone change.</summary>
|
||||
internal static class OwnedProgramCopy
|
||||
{
|
||||
private const int Limit = 1000000;
|
||||
private const int MaxDepth = 64;
|
||||
|
||||
internal static Program Copy(Program original, CancellationToken token = default)
|
||||
{
|
||||
Validate(original, token);
|
||||
// Built-in Clone preserves mode/rotation and binds calls without rotating setters.
|
||||
// Its per-parent maps can duplicate diamonds; restore one global owned graph below.
|
||||
var cloned = (Program)original.Clone();
|
||||
var programs = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
|
||||
var codes = new Dictionary<ICode, ICode>(ReferenceEqualityComparer.Instance);
|
||||
var bindings = new Dictionary<Dictionary<string, string>, Dictionary<string, string>>(ReferenceEqualityComparer.Instance);
|
||||
return Restore(original, cloned);
|
||||
|
||||
Program Restore(Program source, Program copy)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (programs.TryGetValue(source, out var existing)) return existing;
|
||||
programs.Add(source, copy);
|
||||
for (var i = 0; i < source.Codes.Count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var authored = source.Codes[i];
|
||||
if (codes.TryGetValue(authored, out var owned))
|
||||
{
|
||||
copy.Codes[i] = owned;
|
||||
continue;
|
||||
}
|
||||
owned = copy.Codes[i];
|
||||
codes.Add(authored, owned);
|
||||
if (authored is Motion motion)
|
||||
{
|
||||
var target = (Motion)owned;
|
||||
// All other built-in fields are retained by their Clone implementations.
|
||||
target.UseExactStop = motion.UseExactStop;
|
||||
target.Feedrate = motion.Feedrate;
|
||||
if (motion.VariableRefs != null)
|
||||
{
|
||||
if (!bindings.TryGetValue(motion.VariableRefs, out var refs))
|
||||
{
|
||||
refs = new Dictionary<string, string>(motion.VariableRefs, motion.VariableRefs.Comparer);
|
||||
bindings.Add(motion.VariableRefs, refs);
|
||||
}
|
||||
target.VariableRefs = refs;
|
||||
}
|
||||
}
|
||||
if (authored is SubProgramCall call)
|
||||
{
|
||||
var target = (SubProgramCall)owned;
|
||||
target.BindProgram(Restore(call.Program, target.Program));
|
||||
}
|
||||
}
|
||||
foreach (var (id, child) in source.SubPrograms)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
copy.SubPrograms[id] = Restore(child, copy.SubPrograms[id]);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
// Unlike the execution reader, inactive registered graphs can be motionless or
|
||||
// contain suppressed motions. They still must be supported, acyclic and bounded.
|
||||
internal static void Validate(Program program, CancellationToken token)
|
||||
{
|
||||
var active = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
var done = new Dictionary<Program, (int Cost, int Depth)>(ReferenceEqualityComparer.Instance);
|
||||
var budget = Limit;
|
||||
Visit(program);
|
||||
(int Cost, int Depth) Visit(Program current)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (current == null || active.Contains(current) || active.Count >= MaxDepth)
|
||||
throw new ArgumentException("Missing, recursive or excessively nested clone graph.");
|
||||
if (done.TryGetValue(current, out var previous))
|
||||
{
|
||||
if (active.Count + previous.Depth > MaxDepth)
|
||||
throw new ArgumentException("Excessively nested clone graph.");
|
||||
return previous;
|
||||
}
|
||||
if (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode))
|
||||
throw new NotSupportedException("Unsupported program runtime type or mode.");
|
||||
if (current.Codes == null)
|
||||
throw new ArgumentException("Missing clone graph instructions.");
|
||||
active.Add(current);
|
||||
var children = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
var cost = 1;
|
||||
var depth = 1;
|
||||
foreach (var code in current.Codes)
|
||||
{
|
||||
Step();
|
||||
cost++;
|
||||
if (code == null)
|
||||
throw new ArgumentException("Missing clone graph instruction.");
|
||||
var type = code.GetType();
|
||||
if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove)
|
||||
&& type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf))
|
||||
throw new NotSupportedException("Unsupported instruction runtime type.");
|
||||
if (code is SubProgramCall call) Child(call.Program);
|
||||
}
|
||||
foreach (var child in current.SubPrograms.Values)
|
||||
{
|
||||
Step();
|
||||
Child(child);
|
||||
}
|
||||
if (cost > Limit)
|
||||
throw new ArgumentException("Clone graph exceeds the verification limit.");
|
||||
active.Remove(current);
|
||||
var result = (cost, depth);
|
||||
done.Add(current, result);
|
||||
return result;
|
||||
|
||||
void Child(Program child)
|
||||
{
|
||||
var summary = Visit(child);
|
||||
// Program.Clone shares children only within one parent; bound its real
|
||||
// expanded work as well as the distinct original graph before calling it.
|
||||
if (!children.Add(child)) return;
|
||||
if (summary.Cost > Limit - cost)
|
||||
throw new ArgumentException("Clone expansion exceeds the verification limit.");
|
||||
cost += summary.Cost;
|
||||
depth = System.Math.Max(depth, summary.Depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
void Step()
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new ArgumentException("Clone graph exceeds the verification limit.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Every Part field a cutting commit can change, held by reference for exact rollback.</summary>
|
||||
internal sealed record PartCuttingState(Program Program, bool OwnsProgram, double PreLeadInRotation,
|
||||
bool HasManualLeadIns, bool LeadInsLocked, CuttingParameters CuttingParameters, Vector Location,
|
||||
Box BoundingBox);
|
||||
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Exact caller-thread record of everything a cutting proposal for one plate depends on:
|
||||
/// part list instance and order, plate quantity/size/quadrant/settings, cutoff definitions, and
|
||||
/// each part's complete cutting state with owned copies of its placed and drawing programs,
|
||||
/// drawing cutoff classification and exact settings content.
|
||||
/// A commit compares it with the live plate and refuses when anything differs.
|
||||
/// </summary>
|
||||
public sealed class PlateCuttingState
|
||||
{
|
||||
private readonly ObservableList<Part> partList;
|
||||
private readonly PartRecord[] parts;
|
||||
private readonly int quantity;
|
||||
private readonly Size size;
|
||||
private readonly int quadrant;
|
||||
private readonly ObservableList<CutOff> cutOffList;
|
||||
private readonly CutOffRecord[] cutOffs;
|
||||
private readonly Dictionary<Drawing, (Program Program, Program Copy, bool IsCutOff)> drawings;
|
||||
private readonly Dictionary<CuttingParameters, string> settings;
|
||||
private readonly CuttingParameters plateSettings;
|
||||
|
||||
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
|
||||
Dictionary<Drawing, (Program, Program, bool)> drawings, Dictionary<CuttingParameters, string> settings)
|
||||
{
|
||||
this.settings = settings;
|
||||
plateSettings = plate.CuttingParameters;
|
||||
Plate = plate;
|
||||
partList = plate.Parts;
|
||||
this.parts = parts;
|
||||
quantity = plate.Quantity;
|
||||
size = plate.Size;
|
||||
quadrant = plate.Quadrant;
|
||||
cutOffList = plate.CutOffs;
|
||||
this.cutOffs = cutOffs;
|
||||
this.drawings = drawings;
|
||||
Order = Array.AsReadOnly(parts.Select(p => p.Part).ToArray());
|
||||
}
|
||||
|
||||
public Plate Plate { get; }
|
||||
|
||||
/// <summary>Captured part order (reference identity).</summary>
|
||||
public IReadOnlyList<Part> Order { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Captures on the caller thread. Unsupported or malformed programs throw
|
||||
/// <see cref="ArgumentException"/> or <see cref="NotSupportedException"/>; so do part or
|
||||
/// plate settings that are not exact built-in settings types, whose state cannot be
|
||||
/// captured exactly.
|
||||
/// </summary>
|
||||
public static PlateCuttingState Capture(Plate plate, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
if (plate.Parts == null || plate.CutOffs == null)
|
||||
throw new ArgumentException("Plate part and cutoff lists are required.");
|
||||
var drawings = new Dictionary<Drawing, (Program, Program, bool)>(ReferenceEqualityComparer.Instance);
|
||||
var settings = new Dictionary<CuttingParameters, string>(ReferenceEqualityComparer.Instance);
|
||||
Fingerprint(plate.CuttingParameters);
|
||||
var records = new List<PartRecord>(plate.Parts.Count);
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (part?.BaseDrawing == null || part.Program == null)
|
||||
throw new ArgumentException("Plate contains a missing part, drawing or program.");
|
||||
var drawing = part.BaseDrawing;
|
||||
if (!drawings.ContainsKey(drawing))
|
||||
drawings.Add(drawing, (drawing.Program, drawing.Program == null ? null
|
||||
: OwnedProgramCopy.Copy(drawing.Program, token), drawing.IsCutOff));
|
||||
var state = part.CaptureCuttingState();
|
||||
Fingerprint(state.CuttingParameters);
|
||||
records.Add(new(part, state, OwnedProgramCopy.Copy(part.Program, token), BoxValues(state.BoundingBox)));
|
||||
}
|
||||
var cutOffs = plate.CutOffs.Select(c => c == null
|
||||
? throw new ArgumentException("Plate contains a missing cutoff definition.")
|
||||
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit)).ToArray();
|
||||
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
||||
|
||||
void Fingerprint(CuttingParameters parameters)
|
||||
{
|
||||
if (parameters != null && !settings.ContainsKey(parameters))
|
||||
settings.Add(parameters, StateFingerprint.Of(parameters));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the live plate still has exactly the captured state.</summary>
|
||||
public bool IsCurrent(CancellationToken token = default) => Difference(token) == null;
|
||||
|
||||
/// <summary>Null when current; otherwise the first observed difference.</summary>
|
||||
public string Difference(CancellationToken token = default)
|
||||
{
|
||||
var checkedSettings = new Dictionary<CuttingParameters, bool>(ReferenceEqualityComparer.Instance);
|
||||
var plate = Plate;
|
||||
if (!ReferenceEquals(plate.Parts, partList) || !ReferenceEquals(plate.CutOffs, cutOffList))
|
||||
return "The plate's part or cutoff list was replaced.";
|
||||
if (plate.Quantity != quantity || !Bits(plate.Size.Width, size.Width)
|
||||
|| !Bits(plate.Size.Length, size.Length) || plate.Quadrant != quadrant)
|
||||
return "Plate quantity, size or quadrant changed.";
|
||||
if (!ReferenceEquals(plate.CuttingParameters, plateSettings) || !SameSettings(plateSettings))
|
||||
return "Plate cutting settings changed.";
|
||||
if (plate.Parts.Count != parts.Length)
|
||||
return "Parts were added or removed.";
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var record = parts[i];
|
||||
var part = plate.Parts[i];
|
||||
if (!ReferenceEquals(part, record.Part))
|
||||
return $"Part order changed at position {i + 1}.";
|
||||
var live = part.CaptureCuttingState();
|
||||
var was = record.State;
|
||||
if (!ReferenceEquals(live.Program, was.Program) || live.OwnsProgram != was.OwnsProgram
|
||||
|| !ReferenceEquals(live.CuttingParameters, was.CuttingParameters)
|
||||
|| !ReferenceEquals(live.BoundingBox, was.BoundingBox))
|
||||
return $"Part {i + 1} program, settings or bounds were replaced.";
|
||||
if (!Bits(live.Location.X, was.Location.X) || !Bits(live.Location.Y, was.Location.Y)
|
||||
|| !Bits(live.PreLeadInRotation, was.PreLeadInRotation)
|
||||
|| live.HasManualLeadIns != was.HasManualLeadIns || live.LeadInsLocked != was.LeadInsLocked)
|
||||
return $"Part {i + 1} pose, lead-in or lock state changed.";
|
||||
if (!BoxValues(live.BoundingBox).SequenceEqual(record.Bounds))
|
||||
return $"Part {i + 1} bounds changed.";
|
||||
if (!ProgramContent.Equal(part.Program, record.Program, token))
|
||||
return $"Part {i + 1} program was edited in place.";
|
||||
if (!SameSettings(live.CuttingParameters))
|
||||
return $"Part {i + 1} cutting settings were edited in place.";
|
||||
var (drawingProgram, drawingCopy, isCutOff) = drawings[part.BaseDrawing];
|
||||
if (!ReferenceEquals(part.BaseDrawing.Program, drawingProgram)
|
||||
|| !ProgramContent.Equal(drawingProgram, drawingCopy, token))
|
||||
return $"Part {i + 1} drawing program changed.";
|
||||
if (part.BaseDrawing.IsCutOff != isCutOff)
|
||||
return $"Part {i + 1} cutoff classification changed.";
|
||||
}
|
||||
if (plate.CutOffs.Count != cutOffs.Length)
|
||||
return "Cutoffs were added or removed.";
|
||||
for (var i = 0; i < cutOffs.Length; i++)
|
||||
{
|
||||
var record = cutOffs[i];
|
||||
var cutOff = plate.CutOffs[i];
|
||||
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|
||||
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|
||||
|| !Bits(cutOff.Position.Y, record.Position.Y)
|
||||
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit))
|
||||
return $"Cutoff {i + 1} definition changed.";
|
||||
}
|
||||
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
|
||||
// all compared exactly above.
|
||||
return null;
|
||||
|
||||
// References were compared already; this catches in-place edits of the same object.
|
||||
// Each distinct settings object is fingerprinted at most once per check. A nested
|
||||
// settings object replaced since capture by an unsupported type cannot equal the
|
||||
// captured built-in state: Stale, not an exception.
|
||||
bool SameSettings(CuttingParameters parameters)
|
||||
{
|
||||
if (parameters == null)
|
||||
return true;
|
||||
if (checkedSettings.TryGetValue(parameters, out var known))
|
||||
return known;
|
||||
bool current;
|
||||
try
|
||||
{
|
||||
current = settings.TryGetValue(parameters, out var captured)
|
||||
&& captured == StateFingerprint.Of(parameters);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
current = false;
|
||||
}
|
||||
checkedSettings[parameters] = current;
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
private static long[] BoxValues(Box box) => box == null ? [] :
|
||||
[BitConverter.DoubleToInt64Bits(box.X), BitConverter.DoubleToInt64Bits(box.Y),
|
||||
BitConverter.DoubleToInt64Bits(box.Width), BitConverter.DoubleToInt64Bits(box.Length)];
|
||||
|
||||
private static bool Bits(double a, double b) =>
|
||||
BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
|
||||
|
||||
private static bool Bits(double? a, double? b) => a.HasValue == b.HasValue
|
||||
&& (!a.HasValue || Bits(a.Value, b.Value));
|
||||
|
||||
private sealed record PartRecord(Part Part, PartCuttingState State, Program Program, long[] Bounds);
|
||||
|
||||
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
|
||||
double? StartLimit, double? EndLimit);
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>A nominal native entry owned by one preparation, not an actual emitted pierce.</summary>
|
||||
public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector Point)
|
||||
{
|
||||
internal PreparedContours Owner { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated
|
||||
/// program and keep it stable during Capture. This is not a topology or lead-path verdict.
|
||||
/// </summary>
|
||||
public sealed class PreparedContours
|
||||
{
|
||||
private readonly Shape[] shapes;
|
||||
private readonly CuttingParameters parameters;
|
||||
private readonly List<Entity> scribes;
|
||||
|
||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, CuttingParameters parameters)
|
||||
{
|
||||
this.shapes = shapes;
|
||||
this.scribes = scribes;
|
||||
this.parameters = parameters;
|
||||
}
|
||||
|
||||
public int Count => shapes.Length;
|
||||
public int PerimeterOrdinal => Count - 1;
|
||||
|
||||
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var ownedParameters = OwnedCuttingParameters.Copy(parameters);
|
||||
ExecutionMotionReader.ReadSupported(program, Vector.Zero, null, token);
|
||||
// ToGeometry constructs fresh native entities. Normalize an owned motion graph
|
||||
// to incremental mode so absolute subprograms retain their frame offsets too.
|
||||
// Retain execution boundaries: ShapeBuilder can join duplicate closed contours.
|
||||
var geometryProgram = CopyForGeometry(program, token);
|
||||
var contours = new List<Shape>();
|
||||
var scribes = new List<Entity>();
|
||||
var current = new Shape();
|
||||
foreach (var entity in geometryProgram.ToGeometry())
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (entity.GetType() != typeof(Line) && entity.GetType() != typeof(Arc) && entity.GetType() != typeof(Circle))
|
||||
throw new NotSupportedException("Unsupported native geometry.");
|
||||
if (entity.Layer == SpecialLayers.Rapid || entity.Layer == SpecialLayers.Scribe)
|
||||
{
|
||||
if (current.Entities.Count != 0)
|
||||
throw new ArgumentException("Preparation requires closed execution contours.");
|
||||
if (entity.Layer == SpecialLayers.Scribe)
|
||||
scribes.Add(entity);
|
||||
continue;
|
||||
}
|
||||
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display)
|
||||
throw new ArgumentException("Preparation requires clean cut/display contours.");
|
||||
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
||||
throw new ArgumentException("Degenerate contour entity.");
|
||||
if (entity is Circle)
|
||||
{
|
||||
if (current.Entities.Count != 0)
|
||||
throw new ArgumentException("Circle interrupts an open contour.");
|
||||
current.Entities.Add(entity);
|
||||
FinishContour();
|
||||
continue;
|
||||
}
|
||||
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Discontinuous native contour.");
|
||||
current.Entities.Add(entity);
|
||||
if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon)
|
||||
FinishContour();
|
||||
}
|
||||
if (current.Entities.Count != 0 || contours.Count == 0)
|
||||
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
||||
var perimeter = 0;
|
||||
for (var i = 1; i < contours.Count; i++)
|
||||
{
|
||||
var box = contours[i].BoundingBox;
|
||||
var best = contours[perimeter].BoundingBox;
|
||||
if (box.Width * box.Length > best.Width * best.Length)
|
||||
perimeter = i;
|
||||
}
|
||||
var outer = contours[perimeter];
|
||||
contours.RemoveAt(perimeter);
|
||||
contours.Add(outer);
|
||||
return new(contours.ToArray(), scribes, ownedParameters);
|
||||
|
||||
void FinishContour()
|
||||
{
|
||||
current.UpdateBounds();
|
||||
contours.Add(current);
|
||||
current = new Shape();
|
||||
}
|
||||
}
|
||||
|
||||
public ContourChoice ClosestEntry(int contourOrdinal, Vector approach)
|
||||
{
|
||||
PostVerificationGeometry.Validate(approach);
|
||||
var shape = GetShape(contourOrdinal);
|
||||
var point = shape.ClosestPointTo(approach, out var entity);
|
||||
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closest native point first, then source-order vertices/midpoints or eight circle
|
||||
/// angles (0 through 315 degrees). Geometrical duplicates keep their first entity.
|
||||
/// Emit before validating actual paths: circle rounding/clamping may move this point.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ContourChoice> Entries(int contourOrdinal, Vector approach, int maxEntries = 16, CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (maxEntries <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxEntries));
|
||||
var shape = GetShape(contourOrdinal);
|
||||
var entries = new List<ContourChoice>();
|
||||
entries.Add(ClosestEntry(contourOrdinal, approach));
|
||||
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var entity = shape.Entities[i];
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++)
|
||||
Add(i, circle.Center + new Vector(System.Math.Cos(angle * System.Math.PI / 4),
|
||||
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius);
|
||||
}
|
||||
else
|
||||
{
|
||||
Add(i, Start(entity));
|
||||
Add(i, entity is Line line ? line.MidPoint : ((Arc)entity).MidPoint());
|
||||
Add(i, End(entity));
|
||||
}
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return entries.AsReadOnly();
|
||||
|
||||
void Add(int entityOrdinal, Vector point)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
|
||||
entries.Add(Entry(contourOrdinal, entityOrdinal, point));
|
||||
}
|
||||
}
|
||||
|
||||
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
|
||||
{
|
||||
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
|
||||
ValidateChoice(choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The uncapped preferred automatic start catalogue for one contour, in preference then
|
||||
/// contour-travel order: convex corners of the contour's own winding, then straight-edge
|
||||
/// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear
|
||||
/// line/line splits, circles and interior points never appear; each geometric point is
|
||||
/// reported once, keeping the most preferred kind. A pure-arc contour can have no
|
||||
/// preferred point at all — <see cref="AutomaticEntryCandidatesWithFallbacks"/> supplies
|
||||
/// those. Manual entry through <see cref="Entry"/> / <see cref="ClosestEntry"/> is
|
||||
/// unaffected.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidates(int contourOrdinal, CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (IsSingleCircle(shape))
|
||||
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
|
||||
return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The complete uncapped automatic start catalogue: the preferred points of
|
||||
/// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc
|
||||
/// midpoints, near-convex-corner points on straight edges, the eight compass points of a
|
||||
/// whole circle, and the exact target-facing closest point toward
|
||||
/// <paramref name="lookAhead"/> (pass the arrival point there when there is no next cut).
|
||||
/// A pure-circle contour therefore yields compass points instead of refusing. Every
|
||||
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
|
||||
/// preferred tier; fallbacks never replace a preferred point at the same geometry.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
||||
int contourOrdinal,
|
||||
Vector? lookAhead = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
var all = IsSingleCircle(shape)
|
||||
? new List<ContourEntryCandidate>()
|
||||
: PreferredCandidates(shape, contourOrdinal, token);
|
||||
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
|
||||
return MergeByGeometry(all);
|
||||
}
|
||||
|
||||
private static bool IsSingleCircle(Shape shape) =>
|
||||
shape.Entities.Count == 1 && shape.Entities[0] is Circle;
|
||||
|
||||
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
|
||||
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
|
||||
=> CataloguePoints(shape, token)
|
||||
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Tier-3 fallbacks for one contour, each already run through the reflex/cusp exclusion:
|
||||
/// native arc midpoints, the eight compass points of whole circles, near-convex-corner
|
||||
/// insets on straight edges, and the exact target-facing closest point toward
|
||||
/// <paramref name="lookAhead"/>. No ranking and no lead-safety verdict here.
|
||||
/// </summary>
|
||||
private List<ContourEntryCandidate> FallbackCandidates(
|
||||
Shape shape,
|
||||
int contourOrdinal,
|
||||
Vector? lookAhead,
|
||||
CancellationToken token)
|
||||
{
|
||||
var fallbacks = new List<ContourEntryCandidate>();
|
||||
var lead = ApplicableLeadInLength(contourOrdinal);
|
||||
var count = shape.Entities.Count;
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
switch (shape.Entities[i])
|
||||
{
|
||||
case Arc arc:
|
||||
// The native midpoint of an arc (exact native API, never tessellation).
|
||||
fallbacks.Add(Fallback(i, arc.MidPoint(), AutomaticEntryKind.ArcMidpoint));
|
||||
break;
|
||||
case Circle circle:
|
||||
// The legacy eight compass points, same native construction.
|
||||
for (var angle = 0; angle < 8; angle++)
|
||||
fallbacks.Add(Fallback(i, circle.Center + new Vector(
|
||||
System.Math.Cos(angle * System.Math.PI / 4),
|
||||
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius,
|
||||
AutomaticEntryKind.CircleCompass));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Near-convex-corner fallbacks: about twice the applicable lead-in length back from
|
||||
// each convex corner along each incident STRAIGHT edge, only when strictly inside
|
||||
// that edge. Short edges simply omit the point; it never extrapolates past an edge
|
||||
// endpoint and so never lands on the reflex/cusp vertex at the far end.
|
||||
if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle))
|
||||
{
|
||||
foreach (var corner in CataloguePoints(shape, token)
|
||||
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var cornerPoint = End(shape.Entities[corner.EntityOrdinal]);
|
||||
// Back INTO each incident edge from the corner: the edge ending at the
|
||||
// corner retreats against its own travel, the edge starting at the corner
|
||||
// advances along its own travel.
|
||||
AddInset(corner.EntityOrdinal, cornerPoint, inward: false);
|
||||
AddInset((corner.EntityOrdinal + 1) % count, cornerPoint, inward: true);
|
||||
}
|
||||
}
|
||||
|
||||
if (lookAhead != null)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
PostVerificationGeometry.Validate(lookAhead.Value);
|
||||
var facing = ClosestEntry(contourOrdinal, lookAhead.Value);
|
||||
// A raw closest point may land exactly on a reflex/cusp vertex; automatic
|
||||
// selection must never sneak a forbidden inside corner back in, so drop it.
|
||||
if (!IsForbiddenVertex(shape, facing.Point))
|
||||
fallbacks.Add(Fallback(facing.EntityOrdinal, facing.Point, AutomaticEntryKind.TargetFacing));
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return fallbacks;
|
||||
|
||||
void AddInset(int entityOrdinal, Vector corner, bool inward)
|
||||
{
|
||||
// `inward` selects along the edge's own travel from its start; without it the
|
||||
// point retreats against travel. Both ways move BACK INTO the edge from the
|
||||
// corner, which sits at the edge's end (inward=false) or start (inward=true).
|
||||
if (shape.Entities[entityOrdinal] is not Line line)
|
||||
return;
|
||||
var direction = (line.EndPoint - line.StartPoint).Normalize();
|
||||
var offset = direction * (2 * lead);
|
||||
var point = inward ? corner + offset : corner - offset;
|
||||
// Strictly inside the edge by projection parameter (distance alone loses the
|
||||
// sign when a short edge is overshoot): never the corner, never the far
|
||||
// endpoint (where a reflex vertex might sit). Short edges omit the point.
|
||||
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
|
||||
if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
|
||||
return;
|
||||
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
|
||||
}
|
||||
|
||||
ContourEntryCandidate Fallback(int entityOrdinal, Vector point, AutomaticEntryKind kind)
|
||||
=> new(Entry(contourOrdinal, entityOrdinal, point), kind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// At equal geometric points the most preferred kind wins, independent of which entity
|
||||
/// supplied it; the winner keeps its own ordinal and point. Result order: preference,
|
||||
/// then entity ordinal.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ContourEntryCandidate> MergeByGeometry(List<ContourEntryCandidate> candidates)
|
||||
{
|
||||
var byPoint = new Dictionary<(long, long), ContourEntryCandidate>();
|
||||
var order = new List<(long, long)>();
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var key = candidate.GeometryKey;
|
||||
if (!byPoint.TryGetValue(key, out var existing))
|
||||
{
|
||||
byPoint[key] = candidate;
|
||||
order.Add(key);
|
||||
}
|
||||
else if (candidate.Kind < existing.Kind)
|
||||
byPoint[key] = candidate;
|
||||
}
|
||||
return order
|
||||
.OrderBy(key => byPoint[key].Kind)
|
||||
.ThenBy(key => byPoint[key].Choice.EntityOrdinal)
|
||||
.Select(key => byPoint[key])
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="point"/> sits on a vertex of the contour that is reflex or
|
||||
/// cusp from either travel direction — a point automatic selection must never emit.
|
||||
/// </summary>
|
||||
private bool IsForbiddenVertex(Shape shape, Vector point)
|
||||
{
|
||||
for (var i = 0; i < shape.Entities.Count; i++)
|
||||
{
|
||||
if (shape.Entities[i] is Circle)
|
||||
continue; // A whole circle has no vertex.
|
||||
var vertex = End(shape.Entities[i]);
|
||||
if (vertex.DistanceTo(point) > PostVerificationGeometry.Epsilon)
|
||||
continue;
|
||||
if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, shape.Entities[i], out var corner)
|
||||
&& corner.Kind is ContourCuttingStrategy.CornerKind.Reflex or ContourCuttingStrategy.CornerKind.Cusp)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The applicable lead-in length for a contour following the emitter's own selection:
|
||||
/// the perimeter (last shape) is external, anything else internal; non-length lead-in
|
||||
/// styles contribute 0, which omits the near-corner fallback instead of approximating.
|
||||
/// </summary>
|
||||
private double ApplicableLeadInLength(int contourOrdinal)
|
||||
=> (contourOrdinal == PerimeterOrdinal
|
||||
? parameters.ExternalLeadIn
|
||||
: parameters.InternalLeadIn) switch
|
||||
{
|
||||
LineLeadIn line => line.Length,
|
||||
LineLineLeadIn lineLine => lineLine.Length1,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The preferred catalogue points for one contour: for each entity, its convex corner
|
||||
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
|
||||
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
|
||||
/// nothing; a single whole circle yields no points at all.
|
||||
/// </summary>
|
||||
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
|
||||
{
|
||||
var found = new List<(int, Vector, AutomaticEntryKind)>();
|
||||
var count = shape.Entities.Count;
|
||||
if (count == 1 && shape.Entities[0] is Circle)
|
||||
{
|
||||
// A whole circle has no corners or joints; only the fallback tier applies.
|
||||
return found;
|
||||
}
|
||||
|
||||
if (count < 2)
|
||||
throw new ArgumentException("Contour has no vertex to classify.");
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var entity = shape.Entities[i];
|
||||
|
||||
// Vertex reached by travelling along entity i (its end point), reported under
|
||||
// entity i. The closed contour guarantees every vertex appears exactly once
|
||||
// this way; each is classified from the contour's own winding.
|
||||
var vertex = End(entity);
|
||||
if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner))
|
||||
{
|
||||
switch (corner.Kind)
|
||||
{
|
||||
case ContourCuttingStrategy.CornerKind.Convex:
|
||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
|
||||
break;
|
||||
case ContourCuttingStrategy.CornerKind.Smooth
|
||||
when entity is Line && Next(i) is Arc:
|
||||
// A line leaving into an arc: the tangent joint. The reverse travel
|
||||
// order classifies the same joint from the arc, matched below.
|
||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||
break;
|
||||
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
||||
// of a plain straight edge are not preferred automatic starts here.
|
||||
case ContourCuttingStrategy.CornerKind.Smooth
|
||||
when entity is Arc && Next(i) is Line:
|
||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (entity is Line line)
|
||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
|
||||
}
|
||||
return found;
|
||||
|
||||
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
||||
}
|
||||
|
||||
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
||||
public Program Emit(IReadOnlyList<ContourChoice> choices)
|
||||
{
|
||||
Validate(choices, true);
|
||||
return EmitPrefix(choices);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic seam: emit exactly ONE owned contour — with its normal lead-in and
|
||||
/// lead-out and its ORIGINAL contour type (the perimeter keeps External even when no
|
||||
/// holes precede it) — so a candidate's emitted leads can be validated before the hole
|
||||
/// choices exist. The result is a throwaway probe, not a plan: it must never be
|
||||
/// installed on a Part or accepted as complete output. Normal <see cref="Emit"/> and
|
||||
/// <see cref="EmitPrefix"/> keep the perimeter-last rule untouched.
|
||||
/// </summary>
|
||||
internal Program EmitCandidateForValidation(ContourChoice choice)
|
||||
{
|
||||
if (choice == null || !ReferenceEquals(choice.Owner, this))
|
||||
throw new ArgumentException("Foreign contour choice.");
|
||||
ValidateChoice(choice);
|
||||
// Same owned clones as a real emission; the source shapes/settings are never used
|
||||
// directly, so the probe cannot drift the preparation or mutate it.
|
||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice);
|
||||
}
|
||||
|
||||
// Each prefix is a standalone owned program, including the same scribes once.
|
||||
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
|
||||
{
|
||||
Validate(choices, false);
|
||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices);
|
||||
}
|
||||
|
||||
internal void ValidateCompleteChoices(IReadOnlyList<ContourChoice> choices) => Validate(choices, true);
|
||||
|
||||
// Build expected-emission metadata independently of the selected payload BEFORE
|
||||
// replay. Final verification only reads this immutable execution and clean geometry.
|
||||
internal SelectedContourProgram CaptureSelectedProgram(IReadOnlyList<ContourChoice> choices,
|
||||
Vector location, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
|
||||
return new(this, choices, expected, parameters.TabsEnabled ? parameters.TabConfig.Size : 0);
|
||||
}
|
||||
|
||||
private void Validate(IReadOnlyList<ContourChoice> choices, bool complete)
|
||||
{
|
||||
if (choices == null || (complete && choices.Count != Count) || choices.Count > Count)
|
||||
throw new ArgumentException("Every contour must be represented exactly once.");
|
||||
var visited = new HashSet<int>();
|
||||
foreach (var choice in choices)
|
||||
{
|
||||
ValidateChoice(choice);
|
||||
if (!visited.Add(choice.ContourOrdinal) || choice.ContourOrdinal == PerimeterOrdinal && visited.Count != Count)
|
||||
throw new ArgumentException("Duplicate contour or perimeter before its internal contours.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateChoice(ContourChoice choice)
|
||||
{
|
||||
if (choice == null || !ReferenceEquals(choice.Owner, this))
|
||||
throw new ArgumentException("Foreign contour choice.");
|
||||
PostVerificationGeometry.Validate(choice.Point);
|
||||
var shape = GetShape(choice.ContourOrdinal);
|
||||
if (choice.EntityOrdinal < 0 || choice.EntityOrdinal >= shape.Entities.Count)
|
||||
throw new ArgumentException("Foreign entity ordinal.");
|
||||
var distance = shape.Entities[choice.EntityOrdinal].ClosestPointTo(choice.Point).DistanceTo(choice.Point);
|
||||
if (!double.IsFinite(distance) || distance > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Entry is not on the selected native entity.");
|
||||
}
|
||||
|
||||
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
|
||||
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
|
||||
|
||||
/// <summary>
|
||||
/// Representative POINTS per contour ordinal (bounding-box centre) for hole routing
|
||||
/// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the
|
||||
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<Vector?> HoleCentres(CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var centres = new Vector?[Count];
|
||||
for (var contour = 0; contour < PerimeterOrdinal; contour++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
centres[contour] = GetShape(contour).BoundingBox.Center;
|
||||
}
|
||||
return centres;
|
||||
}
|
||||
|
||||
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
|
||||
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
|
||||
|
||||
private static Program CopyForGeometry(Program source, CancellationToken token)
|
||||
{
|
||||
var copies = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
|
||||
return Copy(source);
|
||||
|
||||
Program Copy(Program current)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (copies.TryGetValue(current, out var existing))
|
||||
return existing;
|
||||
var copy = new Program(current.Mode);
|
||||
copies.Add(current, copy);
|
||||
foreach (var code in current.Codes)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var ownedCode = code.Clone();
|
||||
if (ownedCode is SubProgramCall call)
|
||||
call.BindProgram(Copy(((SubProgramCall)code).Program));
|
||||
copy.Codes.Add(ownedCode);
|
||||
}
|
||||
copy.Mode = Mode.Incremental;
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Exact structural equality of two program graphs: instruction runtime types, scalar bits,
|
||||
/// motion metadata, variables and the same instruction/sub-program sharing shape. This is
|
||||
/// a freshness check, not geometric equivalence; unsupported instruction types never match.
|
||||
/// </summary>
|
||||
internal static class ProgramContent
|
||||
{
|
||||
internal static bool Equal(Program left, Program right, CancellationToken token = default)
|
||||
{
|
||||
var programs = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
|
||||
var programOwners = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
var codes = new Dictionary<ICode, ICode>(ReferenceEqualityComparer.Instance);
|
||||
var codeOwners = new HashSet<ICode>(ReferenceEqualityComparer.Instance);
|
||||
return SameProgram(left, right);
|
||||
|
||||
bool SameProgram(Program a, Program b)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (a == null || b == null)
|
||||
return a == null && b == null;
|
||||
// Pair graphs one-to-one so shared and distinct sub-programs cannot be confused.
|
||||
if (programs.TryGetValue(a, out var paired))
|
||||
return ReferenceEquals(paired, b);
|
||||
if (!programOwners.Add(b))
|
||||
return false;
|
||||
programs.Add(a, b);
|
||||
// Codes is a writable field: a missing list is a difference, never an exception.
|
||||
if (a.Codes == null || b.Codes == null)
|
||||
return a.Codes == null && b.Codes == null && a.GetType() == b.GetType();
|
||||
if (a.GetType() != b.GetType() || a.Mode != b.Mode || !Bits(a.Rotation, b.Rotation)
|
||||
|| a.Codes.Count != b.Codes.Count || a.SubPrograms.Count != b.SubPrograms.Count
|
||||
|| !SameVariables(a.Variables, b.Variables))
|
||||
return false;
|
||||
for (var i = 0; i < a.Codes.Count; i++)
|
||||
if (!SameCode(a.Codes[i], b.Codes[i]))
|
||||
return false;
|
||||
foreach (var (id, child) in a.SubPrograms)
|
||||
if (!b.SubPrograms.TryGetValue(id, out var other) || !SameProgram(child, other))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SameCode(ICode a, ICode b)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (a == null || b == null)
|
||||
return a == null && b == null;
|
||||
if (codes.TryGetValue(a, out var paired))
|
||||
return ReferenceEquals(paired, b);
|
||||
if (!codeOwners.Add(b))
|
||||
return false;
|
||||
codes.Add(a, b);
|
||||
if (a.GetType() != b.GetType())
|
||||
return false;
|
||||
return (a, b) switch
|
||||
{
|
||||
(RapidMove x, RapidMove y) => SameMotion(x, y),
|
||||
(LinearMove x, LinearMove y) => SameMotion(x, y) && x.Layer == y.Layer,
|
||||
(ArcMove x, ArcMove y) => SameMotion(x, y) && x.Layer == y.Layer
|
||||
&& x.Rotation == y.Rotation && Bits(x.CenterPoint, y.CenterPoint),
|
||||
(SubProgramCall x, SubProgramCall y) => x.Id == y.Id && Bits(x.Offset, y.Offset)
|
||||
&& Bits(x.Rotation, y.Rotation) && SameProgram(x.Program, y.Program),
|
||||
(Comment x, Comment y) => string.Equals(x.Value, y.Value, StringComparison.Ordinal),
|
||||
(Feedrate x, Feedrate y) => Bits(x.Value, y.Value)
|
||||
&& string.Equals(x.VariableRef, y.VariableRef, StringComparison.Ordinal),
|
||||
(Kerf x, Kerf y) => x.Value == y.Value,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SameMotion(Motion a, Motion b) => Bits(a.EndPoint, b.EndPoint)
|
||||
&& a.UseExactStop == b.UseExactStop && a.Feedrate == b.Feedrate && a.Suppressed == b.Suppressed
|
||||
&& SameRefs(a.VariableRefs, b.VariableRefs);
|
||||
|
||||
private static bool SameRefs(Dictionary<string, string> a, Dictionary<string, string> b)
|
||||
{
|
||||
if (a == null || b == null)
|
||||
return a == null && b == null;
|
||||
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer) || !SameSpelling(a.Keys, b.Keys))
|
||||
return false;
|
||||
foreach (var (key, value) in a)
|
||||
if (!b.TryGetValue(key, out var other) || !string.Equals(value, other, StringComparison.Ordinal))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// A case-insensitive dictionary finds a respelled key; authored spelling must still match.
|
||||
private static bool SameSpelling(IEnumerable<string> a, IEnumerable<string> b) =>
|
||||
new HashSet<string>(a, StringComparer.Ordinal).SetEquals(b);
|
||||
|
||||
private static bool SameVariables(Dictionary<string, VariableDefinition> a, Dictionary<string, VariableDefinition> b)
|
||||
{
|
||||
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer) || !SameSpelling(a.Keys, b.Keys))
|
||||
return false;
|
||||
foreach (var (key, value) in a)
|
||||
{
|
||||
if (!b.TryGetValue(key, out var other))
|
||||
return false;
|
||||
if (ReferenceEquals(value, other))
|
||||
continue;
|
||||
if (value == null || other == null
|
||||
|| !string.Equals(value.Name, other.Name, StringComparison.Ordinal)
|
||||
|| !string.Equals(value.Expression, other.Expression, StringComparison.Ordinal)
|
||||
|| !Bits(value.Value, other.Value) || value.Inline != other.Inline || value.Global != other.Global)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool Bits(Vector a, Vector b) => Bits(a.X, b.X) && Bits(a.Y, b.Y);
|
||||
|
||||
private static bool Bits(double a, double b) =>
|
||||
BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Direct XY completed-contour checker. This is not physical machine safety.</summary>
|
||||
public sealed class ReleasedContourState
|
||||
{
|
||||
private readonly List<Obstacle> obstacles = new();
|
||||
|
||||
public ReleasedContourState Copy()
|
||||
{
|
||||
var copy = new ReleasedContourState();
|
||||
copy.obstacles.AddRange(obstacles);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>Consumes one whole owned program; part numbers are caller identity keys.</summary>
|
||||
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
|
||||
int partNumber, CancellationToken token = default) => Check(execution, arrival, partNumber, false, token);
|
||||
|
||||
/// <summary>
|
||||
/// As the analyzer does for cutoffs: rapids are checked, but cutoff cuts need no lead-in and,
|
||||
/// being open, never become obstacles.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
|
||||
int partNumber, bool cutoff, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execution);
|
||||
if (arrival is { } point)
|
||||
PostVerificationGeometry.Validate(point);
|
||||
var moves = execution.Motions.ToArray();
|
||||
moves[0] = moves[0].WithStart(arrival);
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token);
|
||||
return findings.AsReadOnly();
|
||||
}
|
||||
|
||||
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
||||
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;
|
||||
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
|
||||
System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X),
|
||||
System.Math.Max(start.Y, move.End.Y));
|
||||
foreach (var obstacle in obstacles)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
// A rapid well clear of a contour's extent can neither cross nor touch it.
|
||||
if (reach.IsClearOf(obstacle.Extent))
|
||||
continue;
|
||||
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 Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves)
|
||||
{
|
||||
internal PostVerificationGeometry.Extent Extent { get; } =
|
||||
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Exact text record of cutting settings for detecting in-place edits after capture. It is a
|
||||
/// freshness fingerprint, not a serializer.
|
||||
/// Only the exact built-in settings types are supported — the same set
|
||||
/// <see cref="OwnedCuttingParameters"/> copies: <see cref="CuttingParameters"/>,
|
||||
/// <see cref="SequenceParameters"/>, <see cref="AssignmentParameters"/> and the built-in
|
||||
/// lead-in, lead-out and tab types. Every member is written explicitly by type, so nothing is
|
||||
/// discovered reflectively and no code of another type ever runs: each object's runtime type
|
||||
/// is checked before any of its members is read, and any other runtime type (a subclass
|
||||
/// included) throws <see cref="NotSupportedException"/>. Doubles are written by bit pattern
|
||||
/// and every number is formatted invariantly, so the text is exact and culture-free.
|
||||
/// </summary>
|
||||
internal static class StateFingerprint
|
||||
{
|
||||
/// <summary>Fingerprint of <paramref name="settings"/>; null renders as a marker.</summary>
|
||||
/// <exception cref="NotSupportedException">A settings object is not an exact built-in type.</exception>
|
||||
internal static string Of(CuttingParameters settings)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
Settings(settings);
|
||||
return text.ToString();
|
||||
|
||||
void Settings(CuttingParameters p)
|
||||
{
|
||||
if (!Begin(p, typeof(CuttingParameters)))
|
||||
return;
|
||||
Int(p.Id);
|
||||
Text(p.MachineName);
|
||||
Text(p.MaterialName);
|
||||
Text(p.Grade);
|
||||
Number(p.Thickness);
|
||||
Number(p.Kerf);
|
||||
Number(p.PartSpacing);
|
||||
Lead(p.ExternalLeadIn);
|
||||
Out(p.ExternalLeadOut);
|
||||
Lead(p.InternalLeadIn);
|
||||
Out(p.InternalLeadOut);
|
||||
Lead(p.ArcCircleLeadIn);
|
||||
Out(p.ArcCircleLeadOut);
|
||||
Number(p.PierceClearance);
|
||||
Flag(p.RoundLeadInAngles);
|
||||
Number(p.LeadInAngleIncrement);
|
||||
Number(p.AutoTabMinSize);
|
||||
Number(p.AutoTabMaxSize);
|
||||
TabSettings(p.TabConfig);
|
||||
Flag(p.TabsEnabled);
|
||||
Sequence(p.Sequencing);
|
||||
Assignment(p.Assignment);
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void Sequence(SequenceParameters s)
|
||||
{
|
||||
if (!Begin(s, typeof(SequenceParameters)))
|
||||
return;
|
||||
Int((int)s.Method);
|
||||
Number(s.SmallCutoutWidth);
|
||||
Number(s.SmallCutoutHeight);
|
||||
Number(s.MediumCutoutWidth);
|
||||
Number(s.MediumCutoutHeight);
|
||||
Number(s.DistanceMediumSmall);
|
||||
Flag(s.AlternateRowsColumns);
|
||||
Flag(s.AlternateCutoutsWithinRowColumn);
|
||||
Number(s.MinDistanceBetweenRowsColumns);
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void Assignment(AssignmentParameters a)
|
||||
{
|
||||
if (!Begin(a, typeof(AssignmentParameters)))
|
||||
return;
|
||||
Int((int)a.Method);
|
||||
Text(a.Preference);
|
||||
Number(a.MinGeometryLength);
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void Lead(LeadIn lead)
|
||||
{
|
||||
if (lead == null)
|
||||
{
|
||||
text.Append("null;");
|
||||
return;
|
||||
}
|
||||
switch (lead)
|
||||
{
|
||||
case NoLeadIn when Begin(lead, typeof(NoLeadIn)):
|
||||
break;
|
||||
case LineLeadIn l when Begin(lead, typeof(LineLeadIn)):
|
||||
Number(l.Length);
|
||||
Number(l.ApproachAngle);
|
||||
break;
|
||||
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
|
||||
Number(l.Radius);
|
||||
break;
|
||||
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
|
||||
Number(l.LineLength);
|
||||
Number(l.ApproachAngle);
|
||||
Number(l.ArcRadius);
|
||||
break;
|
||||
case LineLineLeadIn l when Begin(lead, typeof(LineLineLeadIn)):
|
||||
Number(l.Length1);
|
||||
Number(l.ApproachAngle1);
|
||||
Number(l.Length2);
|
||||
Number(l.ApproachAngle2);
|
||||
break;
|
||||
case CleanHoleLeadIn l when Begin(lead, typeof(CleanHoleLeadIn)):
|
||||
Number(l.LineLength);
|
||||
Number(l.ArcRadius);
|
||||
Number(l.Kerf);
|
||||
break;
|
||||
default:
|
||||
throw Unsupported(lead);
|
||||
}
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void Out(LeadOut lead)
|
||||
{
|
||||
if (lead == null)
|
||||
{
|
||||
text.Append("null;");
|
||||
return;
|
||||
}
|
||||
switch (lead)
|
||||
{
|
||||
case NoLeadOut when Begin(lead, typeof(NoLeadOut)):
|
||||
break;
|
||||
case LineLeadOut l when Begin(lead, typeof(LineLeadOut)):
|
||||
Number(l.Length);
|
||||
Number(l.ApproachAngle);
|
||||
break;
|
||||
case ArcLeadOut l when Begin(lead, typeof(ArcLeadOut)):
|
||||
Number(l.Radius);
|
||||
break;
|
||||
default:
|
||||
throw Unsupported(lead);
|
||||
}
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void TabSettings(Tab tab)
|
||||
{
|
||||
if (tab == null)
|
||||
{
|
||||
text.Append("null;");
|
||||
return;
|
||||
}
|
||||
switch (tab)
|
||||
{
|
||||
case NormalTab t when Begin(tab, typeof(NormalTab)):
|
||||
Number(t.CutoutMinWidth);
|
||||
Number(t.CutoutMinHeight);
|
||||
Number(t.CutoutMaxWidth);
|
||||
Number(t.CutoutMaxHeight);
|
||||
break;
|
||||
case BreakerTab t when Begin(tab, typeof(BreakerTab)):
|
||||
Number(t.BreakerDepth);
|
||||
Number(t.BreakerLeadInLength);
|
||||
Number(t.BreakerAngle);
|
||||
break;
|
||||
case MachineTab t when Begin(tab, typeof(MachineTab)):
|
||||
Int(t.MachineTabId);
|
||||
break;
|
||||
default:
|
||||
throw Unsupported(tab);
|
||||
}
|
||||
Number(tab.Size);
|
||||
Lead(tab.TabLeadIn);
|
||||
Out(tab.TabLeadOut);
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
// Writes the exact type tag and returns true; null writes a marker and returns false.
|
||||
// A runtime type other than the expected exact type throws before any member is read.
|
||||
bool Begin(object value, Type exact)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
text.Append("null;");
|
||||
return false;
|
||||
}
|
||||
if (value.GetType() != exact)
|
||||
throw Unsupported(value);
|
||||
text.Append(exact.Name).Append('{');
|
||||
return true;
|
||||
}
|
||||
|
||||
void Number(double value) =>
|
||||
text.Append(BitConverter.DoubleToInt64Bits(value).ToString(CultureInfo.InvariantCulture)).Append(';');
|
||||
|
||||
void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';');
|
||||
|
||||
void Flag(bool value) => text.Append(value ? "1;" : "0;");
|
||||
|
||||
// Length-prefixed so a delimiter inside the text cannot shift fields; null differs from "".
|
||||
void Text(string value)
|
||||
{
|
||||
if (value == null)
|
||||
text.Append("~;");
|
||||
else
|
||||
text.Append(value.Length.ToString(CultureInfo.InvariantCulture)).Append(':').Append(value).Append(';');
|
||||
}
|
||||
}
|
||||
|
||||
private static NotSupportedException Unsupported(object value) =>
|
||||
new($"Cutting settings of type {value.GetType().FullName} cannot be captured exactly; " +
|
||||
"only the built-in settings types are supported.");
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -48,7 +48,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
for (var iter = 0; iter < 3; iter++)
|
||||
{
|
||||
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.Reverse();
|
||||
@@ -56,7 +61,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
}
|
||||
|
||||
var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point;
|
||||
perimeterPt = FindPerimeterIntersection(profile.Perimeter, finalLastCutout, nextPartStart, out perimeterEntity);
|
||||
perimeterPt = FindPerimeterIntersection(
|
||||
profile.Perimeter,
|
||||
finalLastCutout,
|
||||
nextPartStart,
|
||||
out perimeterEntity
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -79,18 +89,25 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
if (!profile.Perimeter.IsClosed())
|
||||
EmitRawContour(result, profile.Perimeter);
|
||||
else
|
||||
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
|
||||
EmitContour(
|
||||
result,
|
||||
profile.Perimeter,
|
||||
perimeterPt,
|
||||
perimeterEntity,
|
||||
ContourType.External
|
||||
);
|
||||
|
||||
result.Mode = Mode.Incremental;
|
||||
|
||||
return new CuttingResult
|
||||
{
|
||||
Program = result,
|
||||
LastCutPoint = perimeterPt
|
||||
};
|
||||
return new CuttingResult { 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();
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
@@ -141,14 +158,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
result.Mode = Mode.Incremental;
|
||||
|
||||
return new CuttingResult
|
||||
{
|
||||
Program = result,
|
||||
LastCutPoint = point
|
||||
};
|
||||
return new CuttingResult { 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);
|
||||
if (matched != null)
|
||||
@@ -190,20 +207,26 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
if (shapeEntity is Line sLine && clickedEntity is Line cLine)
|
||||
{
|
||||
if (sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
|
||||
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon)
|
||||
if (
|
||||
sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
|
||||
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon
|
||||
)
|
||||
return shapeEntity;
|
||||
}
|
||||
else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc)
|
||||
{
|
||||
if (System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
|
||||
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon)
|
||||
if (
|
||||
System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
|
||||
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon
|
||||
)
|
||||
return shapeEntity;
|
||||
}
|
||||
else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle)
|
||||
{
|
||||
if (System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
|
||||
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon)
|
||||
if (
|
||||
System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
|
||||
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon
|
||||
)
|
||||
return shapeEntity;
|
||||
}
|
||||
}
|
||||
@@ -211,6 +234,46 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return null;
|
||||
}
|
||||
|
||||
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices)
|
||||
{
|
||||
var result = new Program(Mode.Absolute);
|
||||
EmitScribeContours(result, scribes);
|
||||
EmitChosenContours(result, shapes, choices);
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Diagnostic seam for candidate lead validation (PreparedContours
|
||||
// .EmitCandidateForValidation): emit a single already-validated choice with its
|
||||
// ORIGINAL contour type — the last shape is External even when emitted alone, so a
|
||||
// perimeter never degrades to a hole because it was isolated. Output must not be
|
||||
// installed on a Part or accepted as a complete plan.
|
||||
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
|
||||
CuttingPlanning.ContourChoice choice)
|
||||
{
|
||||
var result = new Program(Mode.Absolute);
|
||||
EmitScribeContours(result, scribes);
|
||||
EmitChosenContours(result, shapes, new[] { choice });
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
|
||||
// One contour per choice in order; the perimeter (last shape) is always External.
|
||||
// Contour emission itself reads only the contour's own geometry and settings, never
|
||||
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
|
||||
private void EmitChosenContours(Program result, Shape[] shapes,
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices)
|
||||
{
|
||||
foreach (var choice in choices)
|
||||
{
|
||||
var shape = shapes[choice.ContourOrdinal];
|
||||
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
|
||||
exactCirclePrograms: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitRawContour(Program program, Shape shape)
|
||||
{
|
||||
var startPoint = GetShapeStartPoint(shape);
|
||||
@@ -218,7 +281,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
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 currentPoint = startPoint;
|
||||
@@ -235,7 +301,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
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);
|
||||
|
||||
@@ -269,7 +340,58 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return HashCode.Combine(r, a);
|
||||
}
|
||||
|
||||
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null)
|
||||
// Prepared emission compares the actual resolved, generated motions, not a
|
||||
// rounded geometry hash. Labels are deterministic encounter-order identifiers;
|
||||
// legacy Apply/ApplySingle retain their existing cache and labels unchanged.
|
||||
private static int RegisterPreparedCircleProgram(Program owner, Program generated)
|
||||
{
|
||||
CuttingPlanning.ExecutionMotionReader.ReadSupported(generated, Vector.Zero, null);
|
||||
if (generated.Variables.Count != 0 || generated.SubPrograms.Count != 0)
|
||||
throw new NotSupportedException("Unsupported generated circle program metadata.");
|
||||
foreach (var code in generated.Codes)
|
||||
if (code is not (RapidMove or LinearMove or ArcMove) || ((Motion)code).VariableRefs != null)
|
||||
throw new NotSupportedException("Unsupported generated circle instruction.");
|
||||
foreach (var pair in owner.SubPrograms)
|
||||
if (SameGeneratedCircleProgram(pair.Value, generated))
|
||||
return pair.Key;
|
||||
var key = checked(owner.SubPrograms.Count + 1);
|
||||
owner.SubPrograms.Add(key, generated);
|
||||
return key;
|
||||
}
|
||||
|
||||
private static bool SameGeneratedCircleProgram(Program a, Program b)
|
||||
{
|
||||
if (a.Mode != b.Mode || !SameBits(a.Rotation, b.Rotation) || a.Codes.Count != b.Codes.Count)
|
||||
return false;
|
||||
for (var i = 0; i < a.Codes.Count; i++)
|
||||
{
|
||||
var left = (Motion)a.Codes[i];
|
||||
var right = (Motion)b.Codes[i];
|
||||
if (left.GetType() != right.GetType() || left.Suppressed != right.Suppressed
|
||||
|| left.UseExactStop != right.UseExactStop || left.Feedrate != right.Feedrate
|
||||
|| !SameVector(left.EndPoint, right.EndPoint))
|
||||
return false;
|
||||
if (left is LinearMove line && line.Layer != ((LinearMove)right).Layer)
|
||||
return false;
|
||||
if (left is ArcMove arc && (arc.Layer != ((ArcMove)right).Layer
|
||||
|| arc.Rotation != ((ArcMove)right).Rotation
|
||||
|| !SameVector(arc.CenterPoint, ((ArcMove)right).CenterPoint)))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y);
|
||||
private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
|
||||
|
||||
private void EmitContour(
|
||||
Program program,
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType? forceType = null,
|
||||
bool exactCirclePrograms = false
|
||||
)
|
||||
{
|
||||
var contourType = forceType ?? DetectContourType(shape);
|
||||
var winding = DetermineWinding(shape);
|
||||
@@ -289,7 +411,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var outwardAngle = normal - System.Math.PI;
|
||||
point = new Vector(
|
||||
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);
|
||||
@@ -297,7 +420,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
// Build hole sub-program relative to (0,0)
|
||||
var holeCenter = circle.Center;
|
||||
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();
|
||||
relativeShape.Entities.Add(relativeCircle);
|
||||
|
||||
@@ -310,34 +436,64 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
subPgm.Mode = Mode.Incremental;
|
||||
|
||||
// Deduplicate: check if an identical sub-program already exists
|
||||
var key = ComputeSubProgramKey(circle.Radius, normal);
|
||||
var key = exactCirclePrograms
|
||||
? RegisterPreparedCircleProgram(program, subPgm)
|
||||
: ComputeSubProgramKey(circle.Radius, normal);
|
||||
if (!program.SubPrograms.ContainsKey(key))
|
||||
program.SubPrograms[key] = subPgm;
|
||||
|
||||
program.Codes.Add(new SubProgramCall
|
||||
{
|
||||
Id = key,
|
||||
Program = program.SubPrograms[key],
|
||||
Offset = holeCenter
|
||||
});
|
||||
program.Codes.Add(
|
||||
new SubProgramCall
|
||||
{
|
||||
Id = key,
|
||||
Program = program.SubPrograms[key],
|
||||
Offset = holeCenter,
|
||||
}
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
if (Parameters.TabsEnabled && Parameters.TabConfig != null && contourType == ContourType.External)
|
||||
var tabbed = Parameters.TabsEnabled
|
||||
&& Parameters.TabConfig != null
|
||||
&& contourType == ContourType.External;
|
||||
|
||||
// A tab leaves the contour short of the corner; a run-out through it would cut the tab.
|
||||
var leadOutPoint = point;
|
||||
var leadOutNormal = normal;
|
||||
if (tabbed)
|
||||
{
|
||||
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
||||
|
||||
// Leave from where the trimmed cut actually ends, on that entity's normal:
|
||||
// an arc generated at the nominal entry would not start on its own radius.
|
||||
if (reindexedShape.Entities.Count > 0 && reindexedShape.Entities[^1] is Line or Arc)
|
||||
{
|
||||
var last = reindexedShape.Entities[^1];
|
||||
leadOutPoint = EntityEndPoint(last);
|
||||
leadOutNormal = ComputeNormal(leadOutPoint, last, contourType, winding);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
leadOut = ResolveLeadOut(shape, point, entity, contourType, leadOut, winding,
|
||||
Parameters.PierceClearance, out leadOutNormal);
|
||||
}
|
||||
|
||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||
program.Codes.AddRange(leadOut.Generate(point, normal, winding));
|
||||
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, winding));
|
||||
}
|
||||
|
||||
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||
{
|
||||
if (scribeEntities.Count == 0) return;
|
||||
if (scribeEntities.Count == 0)
|
||||
return;
|
||||
|
||||
var shapes = ShapeBuilder.GetShapes(scribeEntities);
|
||||
foreach (var shape in shapes)
|
||||
@@ -388,8 +544,297 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return ContourType.Internal;
|
||||
}
|
||||
|
||||
public static double ComputeNormal(Vector point, Entity entity, ContourType contourType,
|
||||
RotationType winding = RotationType.CW)
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
internal enum CornerKind
|
||||
{
|
||||
Convex,
|
||||
Reflex,
|
||||
Smooth,
|
||||
Cusp,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A vertex classified for automatic start-point planning: what kind of turn the
|
||||
/// contour makes there, and the travel tangents of the two edges it joins. Read-only
|
||||
/// so callers cannot mutate the contour; winding is derived the same way
|
||||
/// <see cref="EmitContour"/> derives it, so the kind matches actual emission.
|
||||
/// </summary>
|
||||
internal readonly record struct AutomaticCorner(CornerKind Kind, Vector TangentIn, Vector TangentOut);
|
||||
|
||||
/// <summary>
|
||||
/// Classification query shared with entry planning: the turn at <paramref name="point"/>
|
||||
/// on a closed line/arc contour, using the same corner geometry and winding derivation
|
||||
/// as emission. False when <paramref name="point"/> is not a shared vertex of two
|
||||
/// chainable entities (an interior point, an open contour, a degenerate or non-finite
|
||||
/// corner). Lead generation itself is not involved.
|
||||
/// </summary>
|
||||
internal static bool TryClassifyAutomaticStartCorner(
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
out AutomaticCorner corner)
|
||||
{
|
||||
if (!TryGetCorner(shape, point, entity, out var raw))
|
||||
{
|
||||
corner = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
corner = new AutomaticCorner(
|
||||
ClassifyCorner(raw, DetermineWinding(shape)), raw.TangentIn, raw.TangentOut);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
@@ -442,7 +887,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
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)
|
||||
return leadIn;
|
||||
@@ -492,7 +942,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn,
|
||||
ContourType.Internal => Parameters.InternalLeadIn,
|
||||
_ => Parameters.ExternalLeadIn
|
||||
_ => Parameters.ExternalLeadIn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,7 +952,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut,
|
||||
ContourType.Internal => Parameters.InternalLeadOut,
|
||||
_ => Parameters.ExternalLeadOut
|
||||
_ => Parameters.ExternalLeadOut,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -565,12 +1015,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
private static Vector EntityStartPoint(Entity entity)
|
||||
{
|
||||
if (entity is Line line) return line.StartPoint;
|
||||
if (entity is Arc arc) return arc.StartPoint();
|
||||
if (entity is Line line)
|
||||
return line.StartPoint;
|
||||
if (entity is Arc arc)
|
||||
return arc.StartPoint();
|
||||
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>();
|
||||
|
||||
@@ -582,15 +1038,28 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
}
|
||||
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)
|
||||
{
|
||||
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer });
|
||||
moves.Add(
|
||||
new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer }
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new System.InvalidOperationException($"Unsupported entity type: {entity.Type}");
|
||||
throw new System.InvalidOperationException(
|
||||
$"Unsupported entity type: {entity.Type}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,9 +1069,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
private static Vector GetShapeStartPoint(Shape shape)
|
||||
{
|
||||
var first = shape.Entities[0];
|
||||
if (first is Line line) return line.StartPoint;
|
||||
if (first is Arc arc) return arc.StartPoint();
|
||||
if (first is Circle circle) return new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
|
||||
if (first is Line line)
|
||||
return line.StartPoint;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
External,
|
||||
Internal,
|
||||
ArcCircle
|
||||
ArcCircle,
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
|
||||
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 LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -7,19 +7,23 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public double Radius { get; set; }
|
||||
|
||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
|
||||
var arcCenter = new Vector(
|
||||
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
|
||||
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle));
|
||||
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
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(
|
||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
|
||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle));
|
||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
|
||||
);
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new ArcLeadIn { Radius = Radius * factor };
|
||||
public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double ArcRadius { get; set; }
|
||||
public double Kerf { get; set; }
|
||||
|
||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
|
||||
@@ -22,13 +25,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
|
||||
var arcStart = new Vector(
|
||||
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle));
|
||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,10 +47,16 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
return new Vector(
|
||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle));
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
||||
);
|
||||
}
|
||||
|
||||
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 OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public abstract class LeadIn
|
||||
{
|
||||
public abstract List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW);
|
||||
public abstract List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
);
|
||||
|
||||
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double ApproachAngle { get; set; } = 135.0;
|
||||
public double ArcRadius { get; set; }
|
||||
|
||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
|
||||
@@ -22,13 +25,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
||||
var arcStart = new Vector(
|
||||
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle));
|
||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,10 +47,16 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
return new Vector(
|
||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle));
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
||||
);
|
||||
}
|
||||
|
||||
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.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -9,15 +9,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double Length { get; set; }
|
||||
public double ApproachAngle { get; set; } = 90.0;
|
||||
|
||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
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);
|
||||
return new Vector(
|
||||
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) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -11,21 +11,25 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double Length2 { get; set; }
|
||||
public double ApproachAngle2 { get; set; } = 90.0;
|
||||
|
||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
|
||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||
var midPoint = new Vector(
|
||||
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>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
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);
|
||||
return new Vector(
|
||||
midX + Length1 * System.Math.Cos(firstAngle),
|
||||
midY + Length1 * System.Math.Sin(firstAngle));
|
||||
midY + Length1 * System.Math.Sin(firstAngle)
|
||||
);
|
||||
}
|
||||
|
||||
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 OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public class NoLeadIn : LeadIn
|
||||
{
|
||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(contourStartPoint)
|
||||
};
|
||||
return new List<ICode> { new RapidMove(contourStartPoint) };
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -7,8 +7,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public double Radius { get; set; }
|
||||
|
||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
||||
@@ -16,11 +19,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
var endPoint = new Vector(
|
||||
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>
|
||||
{
|
||||
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 OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public abstract class LeadOut
|
||||
{
|
||||
public abstract List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW);
|
||||
public abstract List<ICode> Generate(
|
||||
Vector contourEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -9,18 +9,19 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double Length { get; set; }
|
||||
public double ApproachAngle { get; set; } = 90.0;
|
||||
|
||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
|
||||
var endPoint = new Vector(
|
||||
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>
|
||||
{
|
||||
new LinearMove(endPoint) { Layer = LayerType.Leadout }
|
||||
};
|
||||
return new List<ICode> { new LinearMove(endPoint) { Layer = LayerType.Leadout } };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public class NoLeadOut : LeadOut
|
||||
{
|
||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
return new List<ICode>();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
BottomSide = 4,
|
||||
EdgeStart = 5,
|
||||
LeftSide = 7,
|
||||
RightSideAlt = 8
|
||||
RightSideAlt = 8,
|
||||
}
|
||||
|
||||
public class SequenceParameters
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double BreakerAngle { get; set; }
|
||||
|
||||
public override List<ICode> Generate(
|
||||
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
Vector tabStartPoint,
|
||||
Vector tabEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var codes = new List<ICode>();
|
||||
|
||||
@@ -21,7 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var scoreAngle = contourNormalAngle + System.Math.PI;
|
||||
var scoreEnd = new Vector(
|
||||
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 RapidMove(tabEndPoint));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -8,13 +8,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public int MachineTabId { get; set; }
|
||||
|
||||
public override List<ICode> Generate(
|
||||
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
Vector tabStartPoint,
|
||||
Vector tabEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(tabEndPoint)
|
||||
};
|
||||
return new List<ICode> { new RapidMove(tabEndPoint) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -11,8 +11,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double CutoutMaxHeight { get; set; }
|
||||
|
||||
public override List<ICode> Generate(
|
||||
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
Vector tabStartPoint,
|
||||
Vector tabEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var codes = new List<ICode>();
|
||||
|
||||
@@ -29,8 +32,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
public bool AppliesToCutout(double cutoutWidth, double cutoutHeight)
|
||||
{
|
||||
return cutoutWidth >= CutoutMinWidth && cutoutWidth <= CutoutMaxWidth
|
||||
&& cutoutHeight >= CutoutMinHeight && cutoutHeight <= CutoutMaxHeight;
|
||||
return cutoutWidth >= CutoutMinWidth
|
||||
&& cutoutWidth <= CutoutMaxWidth
|
||||
&& cutoutHeight >= CutoutMinHeight
|
||||
&& cutoutHeight <= CutoutMaxHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -10,7 +10,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public LeadOut TabLeadOut { get; set; }
|
||||
|
||||
public abstract List<ICode> Generate(
|
||||
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW);
|
||||
Vector tabStartPoint,
|
||||
Vector tabEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,7 @@
|
||||
|
||||
public const int UseMax = -2;
|
||||
|
||||
public Feedrate()
|
||||
{
|
||||
}
|
||||
public Feedrate() { }
|
||||
|
||||
public Feedrate(double value)
|
||||
{
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
|
||||
namespace OpenNest.CNC
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public enum KerfType
|
||||
{
|
||||
None,
|
||||
Left,
|
||||
Right
|
||||
Right,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
namespace OpenNest.CNC
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public enum LayerType
|
||||
{
|
||||
@@ -7,6 +6,6 @@ namespace OpenNest.CNC
|
||||
Scribe,
|
||||
Cut,
|
||||
Leadin,
|
||||
Leadout
|
||||
Leadout,
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,10 @@ namespace OpenNest.CNC
|
||||
public class LinearMove : Motion
|
||||
{
|
||||
public LinearMove()
|
||||
: this(new Vector())
|
||||
{
|
||||
}
|
||||
: this(new Vector()) { }
|
||||
|
||||
public LinearMove(double x, double y)
|
||||
: this(new Vector(x, y))
|
||||
{
|
||||
}
|
||||
: this(new Vector(x, y)) { }
|
||||
|
||||
public LinearMove(Vector endPoint)
|
||||
{
|
||||
@@ -34,7 +30,8 @@ namespace OpenNest.CNC
|
||||
{
|
||||
Layer = Layer,
|
||||
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
|
||||
{
|
||||
Absolute,
|
||||
Incremental
|
||||
Incremental,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
@@ -10,7 +10,8 @@ namespace OpenNest.CNC
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -66,9 +67,17 @@ namespace OpenNest.CNC
|
||||
{
|
||||
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}");
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -81,6 +90,9 @@ namespace OpenNest.CNC
|
||||
|
||||
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)
|
||||
{
|
||||
var code = Codes[i];
|
||||
@@ -97,10 +109,11 @@ namespace OpenNest.CNC
|
||||
var dy = subpgm.Offset.Y - origin.Y;
|
||||
subpgm.Offset = new Geometry.Vector(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -130,8 +143,7 @@ namespace OpenNest.CNC
|
||||
|
||||
if (code is SubProgramCall subpgm)
|
||||
{
|
||||
subpgm.Offset = new Geometry.Vector(
|
||||
subpgm.Offset.X + x, subpgm.Offset.Y + y);
|
||||
subpgm.Offset = new Geometry.Vector(subpgm.Offset.X + x, subpgm.Offset.Y + y);
|
||||
}
|
||||
|
||||
if (code is Motion == false)
|
||||
@@ -159,7 +171,9 @@ namespace OpenNest.CNC
|
||||
if (code is SubProgramCall subpgm)
|
||||
{
|
||||
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)
|
||||
@@ -264,7 +278,8 @@ namespace OpenNest.CNC
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
|
||||
if (motion == null) continue;
|
||||
if (motion == null)
|
||||
continue;
|
||||
|
||||
return motion.EndPoint;
|
||||
}
|
||||
@@ -280,7 +295,8 @@ namespace OpenNest.CNC
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
|
||||
if (motion == null) continue;
|
||||
if (motion == null)
|
||||
continue;
|
||||
|
||||
pos += motion.EndPoint;
|
||||
}
|
||||
@@ -292,22 +308,27 @@ namespace OpenNest.CNC
|
||||
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()
|
||||
{
|
||||
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
|
||||
// absolute-mode endpoints are relative to this fixed origin.
|
||||
var frameOrigin = pos;
|
||||
|
||||
double minX = 0.0;
|
||||
double minY = 0.0;
|
||||
double maxX = 0.0;
|
||||
double maxY = 0.0;
|
||||
var minX = double.PositiveInfinity;
|
||||
var minY = double.PositiveInfinity;
|
||||
var maxX = double.NegativeInfinity;
|
||||
var maxY = double.NegativeInfinity;
|
||||
|
||||
for (int i = 0; i < Codes.Count; ++i)
|
||||
{
|
||||
@@ -318,18 +339,19 @@ namespace OpenNest.CNC
|
||||
case CodeType.LinearMove:
|
||||
{
|
||||
var line = (LinearMove)code;
|
||||
var pt = Mode == Mode.Absolute ?
|
||||
frameOrigin + line.EndPoint :
|
||||
line.EndPoint + pos;
|
||||
var pt =
|
||||
Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
else if (pt.X < minX)
|
||||
if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
else if (pt.Y < minY)
|
||||
if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
|
||||
pos = pt;
|
||||
@@ -340,18 +362,19 @@ namespace OpenNest.CNC
|
||||
case CodeType.RapidMove:
|
||||
{
|
||||
var line = (RapidMove)code;
|
||||
var pt = Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
var pt =
|
||||
Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
else if (pt.X < minX)
|
||||
if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
else if (pt.Y < minY)
|
||||
if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
|
||||
pos = pt;
|
||||
@@ -455,7 +478,8 @@ namespace OpenNest.CNC
|
||||
// Sub-program frame origin in this program's frame
|
||||
// is frameOrigin + Offset, regardless of current pos.
|
||||
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)
|
||||
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()
|
||||
{
|
||||
var pgm = new Program()
|
||||
{
|
||||
mode = this.mode,
|
||||
Rotation = this.Rotation
|
||||
};
|
||||
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
|
||||
|
||||
var codes = new ICode[Length];
|
||||
|
||||
@@ -495,8 +522,31 @@ namespace OpenNest.CNC
|
||||
foreach (var kvp in Variables)
|
||||
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)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
return _variables.Values
|
||||
.Where(v => v.Expression != null)
|
||||
return _variables
|
||||
.Values.Where(v => v.Expression != null)
|
||||
.OrderBy(v => v.Number)
|
||||
.Select(v => $"{v.Reference}={v.Expression} ({FormatComment(v.Name)})")
|
||||
.ToList();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
@@ -7,20 +7,42 @@ namespace OpenNest.CNC
|
||||
{
|
||||
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)
|
||||
{
|
||||
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
|
||||
// pierce point. This also primes pos so the interior walk interprets
|
||||
// Incremental deltas from the correct absolute location (basePos), which
|
||||
// matters for raw pre-lead-in programs that are emitted Incremental.
|
||||
// pierce point. The walk then starts at the program origin (basePos), not
|
||||
// the pierce: the skipped first rapid still advances pos, so starting at
|
||||
// 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);
|
||||
results.Add(new Segment(startPos, firstPierce));
|
||||
|
||||
var pos = firstPierce;
|
||||
var pos = basePos;
|
||||
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)
|
||||
@@ -36,7 +58,13 @@ namespace OpenNest.CNC
|
||||
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;
|
||||
|
||||
@@ -60,9 +88,10 @@ namespace OpenNest.CNC
|
||||
}
|
||||
else if (code is Motion motion)
|
||||
{
|
||||
var endpt = pgm.Mode == Mode.Incremental
|
||||
? motion.EndPoint + pos
|
||||
: motion.EndPoint + basePos;
|
||||
var endpt =
|
||||
pgm.Mode == Mode.Incremental
|
||||
? motion.EndPoint + pos
|
||||
: motion.EndPoint + basePos;
|
||||
|
||||
if (code.Type == CodeType.RapidMove)
|
||||
{
|
||||
|
||||
@@ -30,7 +30,8 @@ namespace OpenNest.CNC
|
||||
return new RapidMove(EndPoint)
|
||||
{
|
||||
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.Math;
|
||||
|
||||
@@ -9,9 +9,7 @@ namespace OpenNest.CNC
|
||||
private double rotation;
|
||||
private Program program;
|
||||
|
||||
public SubProgramCall()
|
||||
{
|
||||
}
|
||||
public SubProgramCall() { }
|
||||
|
||||
public SubProgramCall(Program program, double rotation)
|
||||
{
|
||||
@@ -81,12 +79,28 @@ namespace OpenNest.CNC
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <returns></returns>
|
||||
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()
|
||||
|
||||
@@ -8,8 +8,13 @@ namespace OpenNest.CNC
|
||||
public bool Inline { get; }
|
||||
public bool Global { get; }
|
||||
|
||||
public VariableDefinition(string name, string expression, double value,
|
||||
bool inline = false, bool global = false)
|
||||
public VariableDefinition(
|
||||
string name,
|
||||
string expression,
|
||||
double value,
|
||||
bool inline = false,
|
||||
bool global = false
|
||||
)
|
||||
{
|
||||
Name = name;
|
||||
Expression = expression;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Linq;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
@@ -14,6 +14,12 @@ namespace OpenNest
|
||||
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
|
||||
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>
|
||||
/// 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
|
||||
@@ -44,8 +50,9 @@ namespace OpenNest
|
||||
if (drawing?.Program == null)
|
||||
return 0.0;
|
||||
|
||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
var entities = ConvertProgram
|
||||
.ToGeometry(drawing.Program)
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer));
|
||||
|
||||
var shapes = ShapeBuilder.GetShapes(entities);
|
||||
if (shapes.Count == 0)
|
||||
@@ -72,7 +79,89 @@ namespace OpenNest
|
||||
return 0.0;
|
||||
|
||||
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
|
||||
{
|
||||
public class DrawingCollection : HashSet<Drawing>
|
||||
{
|
||||
}
|
||||
public class DrawingCollection : HashSet<Drawing> { }
|
||||
}
|
||||
@@ -13,6 +13,9 @@ namespace OpenNest.Collections
|
||||
public event EventHandler<ItemRemovedEventArgs<T>> ItemRemoved;
|
||||
public event EventHandler<ItemChangedEventArgs<T>> ItemChanged;
|
||||
|
||||
/// <summary>Raised once after <see cref="Reorder"/> (or a cutting commit) installs a new order.</summary>
|
||||
public event EventHandler ItemsReordered;
|
||||
|
||||
public ObservableList()
|
||||
{
|
||||
items = new List<T>();
|
||||
@@ -64,6 +67,68 @@ namespace OpenNest.Collections
|
||||
RemoveAt(i);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes only the order of the current items. <paramref name="order"/> must hold exactly
|
||||
/// the current non-null items: the same references with the same multiplicity. No
|
||||
/// ItemAdded/ItemRemoved is raised, so quantity accounting is untouched; ItemsReordered is
|
||||
/// raised once after the new order is installed. Invalid input throws before any change.
|
||||
/// </summary>
|
||||
public void Reorder(IEnumerable<T> order)
|
||||
{
|
||||
SetOrder(ValidateReorder(order));
|
||||
ItemsReordered?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
internal T[] ValidateReorder(IEnumerable<T> order)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(order);
|
||||
var proposed = order.ToArray();
|
||||
if (proposed.Length != items.Count)
|
||||
throw new ArgumentException("A reorder must contain exactly the current items.", nameof(order));
|
||||
var comparer = typeof(T).IsValueType
|
||||
? EqualityComparer<T>.Default
|
||||
: (IEqualityComparer<T>)(object)ReferenceEqualityComparer.Instance;
|
||||
var counts = new Dictionary<T, int>(comparer);
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item == null)
|
||||
throw new InvalidOperationException("A list holding null items cannot be reordered.");
|
||||
counts[item] = counts.TryGetValue(item, out var count) ? count + 1 : 1;
|
||||
}
|
||||
foreach (var item in proposed)
|
||||
{
|
||||
if (item == null || !counts.TryGetValue(item, out var count) || count == 0)
|
||||
throw new ArgumentException("A reorder must contain exactly the current items.", nameof(order));
|
||||
counts[item] = count - 1;
|
||||
}
|
||||
return proposed;
|
||||
}
|
||||
|
||||
// Validated order only; raises nothing so a commit can publish after a whole scope installs.
|
||||
internal void SetOrder(T[] order)
|
||||
{
|
||||
items.Clear();
|
||||
items.AddRange(order);
|
||||
}
|
||||
|
||||
// Invokes every observer even if one throws, so a refresh failure cannot starve the rest.
|
||||
internal void RaiseItemsReordered(ICollection<Exception> errors)
|
||||
{
|
||||
if (ItemsReordered == null)
|
||||
return;
|
||||
foreach (var handler in ItemsReordered.GetInvocationList())
|
||||
{
|
||||
try
|
||||
{
|
||||
((EventHandler)handler)(this, EventArgs.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int IndexOf(T item)
|
||||
{
|
||||
return items.IndexOf(item);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
{
|
||||
@@ -10,7 +10,7 @@ namespace OpenNest.Converters
|
||||
Perimeter,
|
||||
Hole,
|
||||
Etch,
|
||||
Open
|
||||
Open,
|
||||
}
|
||||
|
||||
public sealed class ContourInfo
|
||||
@@ -91,7 +91,8 @@ namespace OpenNest.Converters
|
||||
// Non-perimeter shapes first (matches CNC cut order: holes before perimeter)
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
if (i == perimeterIndex) continue;
|
||||
if (i == perimeterIndex)
|
||||
continue;
|
||||
var shape = shapes[i];
|
||||
var type = ClassifyShape(shape);
|
||||
|
||||
@@ -116,7 +117,13 @@ namespace OpenNest.Converters
|
||||
}
|
||||
|
||||
// Perimeter last
|
||||
result.Add(new ContourInfo(shapes[perimeterIndex], ContourClassification.Perimeter, "Perimeter"));
|
||||
result.Add(
|
||||
new ContourInfo(
|
||||
shapes[perimeterIndex],
|
||||
ContourClassification.Perimeter,
|
||||
"Perimeter"
|
||||
)
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -124,8 +131,12 @@ namespace OpenNest.Converters
|
||||
private static ContourClassification ClassifyShape(Shape shape)
|
||||
{
|
||||
// Check etch layer — all entities must be on ETCH layer
|
||||
if (shape.Entities.Count > 0 &&
|
||||
shape.Entities.All(e => string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)))
|
||||
if (
|
||||
shape.Entities.Count > 0
|
||||
&& shape.Entities.All(e =>
|
||||
string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
)
|
||||
return ContourClassification.Etch;
|
||||
|
||||
if (shape.IsClosed())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using OpenNest.CNC;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
{
|
||||
@@ -87,14 +87,24 @@ namespace OpenNest.Converters
|
||||
|
||||
lastpt = endpt;
|
||||
|
||||
var layer = ClassifyLayer(arc);
|
||||
var sweep = System.Math.Abs(arc.SweepAngle());
|
||||
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
||||
{
|
||||
pgm.LineTo(endpt);
|
||||
pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
|
||||
}
|
||||
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;
|
||||
@@ -107,7 +117,12 @@ namespace OpenNest.Converters
|
||||
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||
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;
|
||||
return lastpt;
|
||||
@@ -118,13 +133,27 @@ namespace OpenNest.Converters
|
||||
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||
pgm.MoveTo(line.StartPoint);
|
||||
|
||||
var move = new LinearMove(line.EndPoint);
|
||||
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||
move.Layer = LayerType.Scribe;
|
||||
pgm.Codes.Add(move);
|
||||
pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
|
||||
|
||||
lastpt = line.EndPoint;
|
||||
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.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
{
|
||||
@@ -18,7 +18,12 @@ namespace OpenNest.Converters
|
||||
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
|
||||
// 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
|
||||
// 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);
|
||||
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;
|
||||
|
||||
@@ -66,16 +79,17 @@ namespace OpenNest.Converters
|
||||
pt += curpos;
|
||||
|
||||
var layer = ConvertLayer(linearMove.Layer);
|
||||
var line = new Line(curpos, pt)
|
||||
{
|
||||
Layer = layer,
|
||||
Color = layer.Color
|
||||
};
|
||||
var line = new Line(curpos, pt) { Layer = layer, Color = layer.Color };
|
||||
geometry.Add(line);
|
||||
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;
|
||||
|
||||
@@ -85,13 +99,18 @@ namespace OpenNest.Converters
|
||||
var line = new Line(curpos, pt)
|
||||
{
|
||||
Layer = SpecialLayers.Rapid,
|
||||
Color = SpecialLayers.Rapid.Color
|
||||
Color = SpecialLayers.Rapid.Color,
|
||||
};
|
||||
geometry.Add(line);
|
||||
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 endpt = arcMove.EndPoint;
|
||||
@@ -102,6 +121,8 @@ namespace OpenNest.Converters
|
||||
center += curpos;
|
||||
}
|
||||
|
||||
center = FitCenterToEndpoints(center, curpos, endpt);
|
||||
|
||||
var startAngle = center.AngleTo(curpos);
|
||||
var endAngle = center.AngleTo(endpt);
|
||||
|
||||
@@ -112,13 +133,61 @@ namespace OpenNest.Converters
|
||||
var layer = ConvertLayer(arcMove.Layer);
|
||||
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum CutOffAxis
|
||||
{
|
||||
Horizontal,
|
||||
Vertical
|
||||
Vertical,
|
||||
}
|
||||
|
||||
public class CutOff
|
||||
{
|
||||
private const double OffsetTolerance = 0.001;
|
||||
|
||||
public Vector Position { get; set; }
|
||||
public CutOffAxis Axis { get; set; }
|
||||
public double? StartLimit { get; set; }
|
||||
@@ -26,7 +28,11 @@ namespace OpenNest
|
||||
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 program = BuildProgram(segments, settings);
|
||||
@@ -40,11 +46,17 @@ namespace OpenNest
|
||||
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);
|
||||
|
||||
double lineStart, lineEnd, cutPosition;
|
||||
double lineStart,
|
||||
lineEnd,
|
||||
cutPosition;
|
||||
|
||||
if (Axis == CutOffAxis.Vertical)
|
||||
{
|
||||
@@ -68,7 +80,14 @@ namespace OpenNest
|
||||
|
||||
Entity perimeter = null;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -107,18 +126,37 @@ namespace OpenNest
|
||||
private static readonly List<(double Start, double End)> EmptyExclusions = new();
|
||||
|
||||
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 roundoff = GetBoundsRoundoff(part);
|
||||
var (partMin, partMax) = AxisBounds(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)
|
||||
return EmptyExclusions;
|
||||
|
||||
if (perimeter != null)
|
||||
{
|
||||
var perimeterExclusions = IntersectPerimeter(perimeter, cutPosition, lineStart, lineEnd, clearance);
|
||||
var perimeterExclusions = IntersectPerimeter(
|
||||
perimeter,
|
||||
cutPosition,
|
||||
lineStart,
|
||||
lineEnd,
|
||||
clearance
|
||||
);
|
||||
if (perimeterExclusions != null)
|
||||
return perimeterExclusions;
|
||||
}
|
||||
@@ -126,24 +164,68 @@ namespace OpenNest
|
||||
return new List<(double Start, double End)> { (partStart, partEnd) };
|
||||
}
|
||||
|
||||
private List<(double Start, double End)> IntersectPerimeter(
|
||||
Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
|
||||
/// <summary>
|
||||
/// 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 usedOffset = target != perimeter;
|
||||
var cutLine = new Line(MakePoint(cutPosition, lineStart), MakePoint(cutPosition, lineEnd));
|
||||
var bb = part.BoundingBox;
|
||||
var magnitude = System.Math.Max(System.Math.Abs(bb.Left), System.Math.Abs(bb.Right));
|
||||
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;
|
||||
|
||||
var coords = pts
|
||||
.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
|
||||
var coords = pts.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
|
||||
.OrderBy(c => c)
|
||||
.ToList();
|
||||
|
||||
if (coords.Count % 2 != 0)
|
||||
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 result = new List<(double Start, double End)>();
|
||||
for (var i = 0; i < coords.Count; i += 2)
|
||||
@@ -152,21 +234,37 @@ namespace OpenNest
|
||||
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)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var offset = perimeter.OffsetEntity(clearance, OffsetSide.Left);
|
||||
offset?.UpdateBounds();
|
||||
return offset;
|
||||
}
|
||||
catch
|
||||
var offset = perimeter switch
|
||||
{
|
||||
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 offset.Outers.Concat(offset.Holes).Cast<Entity>().ToList();
|
||||
}
|
||||
|
||||
private Vector MakePoint(double cutCoord, double lineCoord) =>
|
||||
@@ -184,7 +282,10 @@ namespace OpenNest
|
||||
? (bb.Y - clearance, bb.Y + bb.Width + 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();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace OpenNest
|
||||
public enum CutDirection
|
||||
{
|
||||
TowardOrigin,
|
||||
AwayFromOrigin
|
||||
AwayFromOrigin,
|
||||
}
|
||||
|
||||
public class CutOffSettings
|
||||
|
||||
@@ -11,11 +11,12 @@ public class CutParameters
|
||||
public string PostProcessor { get; set; }
|
||||
public Units Units { get; set; }
|
||||
|
||||
public static CutParameters Default => new()
|
||||
{
|
||||
Feedrate = 100,
|
||||
RapidTravelRate = 300,
|
||||
PierceTime = TimeSpan.FromSeconds(0.5),
|
||||
Units = OpenNest.Units.Inches
|
||||
};
|
||||
public static CutParameters Default =>
|
||||
new()
|
||||
{
|
||||
Feedrate = 100,
|
||||
RapidTravelRate = 300,
|
||||
PierceTime = TimeSpan.FromSeconds(0.5),
|
||||
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,80 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conservative display reuse: only unchanged ordered slots on the same plate survive.
|
||||
/// Index-changing edits may hide extra pairs, but cannot attach old geometry to new parts.
|
||||
/// </summary>
|
||||
internal bool[] UnchangedSlots(Plate current)
|
||||
{
|
||||
var matches = new bool[entries.Length];
|
||||
if (!ReferenceEquals(plate, current))
|
||||
return matches;
|
||||
for (var i = 0; i < entries.Length && i < current.Parts.Count; i++)
|
||||
matches[i] = entries[i].Matches(current.Parts[i]);
|
||||
return matches;
|
||||
}
|
||||
|
||||
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,91 @@
|
||||
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 = CodeCount(program);
|
||||
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) =>
|
||||
CodeCount(program) == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation;
|
||||
|
||||
// Program.Codes is a writable field; a missing list is a refused source, not a crash.
|
||||
private static int CodeCount(Program program) => program.Codes?.Count ?? -1;
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
Loaded 100 of 999 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user