mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 04:24:10 -04:00
Compare commits
| 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 |
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,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
|
||||
@@ -17,7 +17,9 @@ permissions:
|
||||
jobs:
|
||||
windows:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
# 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
|
||||
@@ -47,13 +49,30 @@ jobs:
|
||||
run: |
|
||||
dotnet build OpenNest.sln -c Release
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||
- name: Run all test projects on Windows
|
||||
- name: Install pinned Poppler for PDF report tests
|
||||
run: |
|
||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests')) {
|
||||
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||
$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 --logger 'trx;LogFileName=OpenNest.Tests.Debug.trx' --results-directory TestResults
|
||||
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
|
||||
@@ -85,6 +104,6 @@ jobs:
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: windows-test-results
|
||||
path: TestResults/*.trx
|
||||
path: TestResults/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
+9
-1
@@ -207,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,157 +1,64 @@
|
||||
# AGENTS.md
|
||||
# OpenNest agent instructions
|
||||
|
||||
This file contains shared repository instructions for coding agents working on OpenNest. It is the single source of truth; `CLAUDE.md` imports it for Claude Code compatibility.
|
||||
Shared instructions; keep `CLAUDE.md` as the thin `@AGENTS.md` import.
|
||||
OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform libraries.
|
||||
|
||||
## Project Overview
|
||||
## Working rules
|
||||
|
||||
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.
|
||||
- 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
|
||||
## Build and test
|
||||
|
||||
This is a .NET 8 solution using SDK-style `.csproj` files. The desktop app and Windows-dependent projects target `net8.0-windows`; the core libraries and `OpenNest.Console` target `net8.0`. Build the full solution on Windows with:
|
||||
|
||||
```bash
|
||||
```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
|
||||
```
|
||||
|
||||
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 main `OpenNest.Tests` suite also targets `net8.0`: run `dotnet test OpenNest.Tests/OpenNest.Tests.csproj` independently on Linux/macOS/Windows. It must not reference the WinForms `OpenNest` project. The API, Data, and post-processor libraries target `net8.0`. Post-processor projects live under `Posts/` (`Posts/OpenNest.Posts.<Name>/`, referencing `..\..\OpenNest.Core`); their build deployment still targets the desktop app's `net8.0-windows/Posts` directory. Optional CHR-font fixtures are configured through `OpenNest.Tests/test-config.json` and skip when absent.
|
||||
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`.
|
||||
|
||||
`OpenNest.WinForms.Tests` contains the desktop-assembly-dependent `CadBendNoteTests` (`CadText`) and `CuttingParametersSerializerTests` (`CuttingParametersSerializer`). It targets `net8.0-windows`, references `OpenNest`, and requires a Windows runner: `dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj`. Keep future desktop-dependent tests here rather than in `OpenNest.Tests`. Linux cross-compilation uses `dotnet build OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj -p:EnableWindowsTargeting=true`; cross-compilation is not Windows runtime verification.
|
||||
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.
|
||||
|
||||
Cross-platform CAD import tests: `dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj`. These synthetic-DXF and bend-repair tests target `net8.0`, require no external fixtures, and are included in the solution. Build the headless console independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
|
||||
## Project map and boundaries
|
||||
|
||||
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `Clipper2` 2.0.0 (region offsetting, in OpenNest.Core), `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).
|
||||
- `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.
|
||||
|
||||
### Windows release packaging
|
||||
## Geometry and ownership safeguards
|
||||
|
||||
The GitHub `Windows release build` workflow runs on `release/vX.Y.Z` branches, `vX.Y.Z` tags, or manual dispatch. It builds with .NET 8 on `windows-2022`, runs all four test projects in Release plus the main Debug suite, and executes `scripts/Publish-Windows.ps1`. The script creates a self-contained win-x64 desktop ZIP with all three shipped posts plus pinned Gpt6Astra/Opus55/Qwen38FlashNext plug-ins from `scripts/external-engines.json`, runs their tests against this host, validates package contents/version and registry discovery (including a missing-DLL failure case), launches the extracted app, and writes a SHA-256 checksum. It refuses an existing output directory. Workflow artifacts are release candidates, not automatically published releases; follow [the release procedure](docs/releasing.md). Keep Gitea authoritative for Git refs.
|
||||
- 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.
|
||||
|
||||
### Fill performance verification
|
||||
Read the relevant contract before changing its behavior:
|
||||
|
||||
See [fill verification](docs/performance/fill-verification.md) for opt-in measurements, targeted tests, Debug counter isolation, and predictor initialization rules. Keep training bitmaps by default; the angle builder checks predictor availability before scalar-only extraction.
|
||||
|
||||
## 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` (convex NFP only), `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, `ClipperBridge` (Clipper2 region offsetting for CPU preparation only; see Key Patterns), and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction; deliberately hand-rolled as the reference for a future GPU kernel, with the port contract in its class summary).
|
||||
- **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 use the jobs-only API. `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 built-in placement strategy.
|
||||
|
||||
- **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`, `StripPlateNester`, and `RemnantPlateNester` are built-ins with run-scoped private geometry. `PlateFillService` is the public single-plate proposal service for interactive fill/group/pack flows; it returns parts without mutating caller-owned plates. Job-path identity is reference-based rather than drawing name; `PlateOptimizer` retains name-based helpers and remains outside the runner path.
|
||||
- **Filler pipeline (`Jobs/Placement/Fillers/`)**: internal `DefaultPlateFiller`, `StripPlateFiller`, and policy-backed `RemnantPlateFiller` implement the standard single-plate geometry pipeline. `Default` runs the Linear, Pairs, RectBestFit, and Extents phases; remnant variants preserve their distinct comparer, direction, trim-axis, and angle-ordering policies.
|
||||
- **Engine registration**: `NestingEngineRegistry` holds whole-job `INestingEngine` implementations including the four fixed strategies and `StockLadder`. It loads plug-ins that implement `INestingEngine` and have a public parameterless constructor. Plug-ins for the removed single-plate inheritance API are not binary compatible.
|
||||
- **Plugin engines**: independent `INestingEngine` plugins are class libraries that reference `OpenNest.Engine` and are built outside `OpenNest.sln`. The desktop app and `OpenNest.Benchmark` load them from an `Engines/` folder next to their build output (e.g. `OpenNest.Benchmark/bin/<Config>/net8.0/Engines/`). Do not add engine projects to this repo.
|
||||
- **IFillComparer**: Interface enabling filler-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), and `HorizontalRemnantComparer` (minimize Y-extent) are grouped into `FillPolicy` on `FillContext`.
|
||||
- **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.Engine.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.Engine.CirclePacking`): Alternative packing for circular parts.
|
||||
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features; `Extract(drawing, includeBitmask: false)` skips the 32x32 training bitmap for inference while scalars stay identical; the default overload keeps it for training), `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`.
|
||||
- `Bending/BendRepair` — conservative opt-in repair configured by `CadImportOptions.BendRepair`. Requires explicit inches/mm source units and an endpoint movement limit above 0.001 and at most 3.175 physical mm. Only unambiguous paired ETCH/SCRIBE ticks may move along the existing bend axis; cut geometry and unrelated marks must remain unchanged. Opt-in imports preserve source marks without blanket etch regeneration and expose per-bend outcomes in `CadImportResult.BendRepairReports`.
|
||||
|
||||
### OpenNest.Console (console app, depends on Core + Engine + IO)
|
||||
Command-line interface for batch nesting (`net8.0`). Supports DXF import, plate configuration, linear fill, and multi-drawing auto-nesting through the active engine's `Nest()` (`--autonest`). `--repair-bends-mm <limit> --cad-units inches|mm` opts newly imported DXFs into conservative bend repair and prints per-bend reports; it does not rescale coordinates or repair saved nests.
|
||||
|
||||
### OpenNest.Gpu (class library, depends on Core + Engine)
|
||||
GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods.
|
||||
|
||||
### OpenNest.Training (console app, depends on Core + Engine)
|
||||
Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training.
|
||||
|
||||
### OpenNest.Benchmark (console app, depends on Core + Engine + IO)
|
||||
Compares registered `INestingEngine` implementations against each other on real `.nest` files. Each engine solves the whole job — it owns its own multi-plate/size strategy rather than being handed one already-sized plate at a time. Fully generic — it never hardcodes drawing geometry, just reads whatever drawings/quantities/plate settings each input file already has.
|
||||
|
||||
- `JobLoader` builds `BenchmarkJob`s from a `.nest` file or a folder of them via `NestReader`, using every drawing with `Quantity.Required > 0`. `--sheet-sizes` can sweep a fixed list of plate sizes instead of each file's own.
|
||||
- `DxfManifestLoader` builds a `BenchmarkJob` from a JSON manifest (`sheetSizes`, `spacing`, `edgeSpacing`, `quadrant`, `parts[] { dxf, quantity, allowRotation }`) instead of a `.nest`, importing each DXF with `CadImporter.ImportDrawing`. DXF paths resolve relative to the manifest; sheet sizes are required (manifest or `--sheet-sizes`, which overrides). `allowRotation: false` locks rotation the same way `NestRunner` does. `JobLoader.Load` routes `*.json` inputs to it, and folder scans pick up `*.nest` plus `*.manifest.json` (plain `*.json` is ignored so `--output` reports are never read as manifests). Invalid manifests throw rather than being skipped.
|
||||
- `BenchmarkJob.BuildNestJob(maxPlates)` converts the job into a `NestJob`: one `NestJobPart` per requested drawing (via `DrawingJobMapper.FromDrawing`) and one `NestPlateStock` per candidate sheet size (unlimited quantity — the engine decides how many of each size it uses).
|
||||
- `BenchmarkRunner` fans the (job × engine) pairs out with `Parallel.ForEach` (`NoBuffering`, `MaxDegreeOfParallelism` from `--parallel`, CLI default 3, `Run`'s own default 1) and writes results by index so report order stays job-then-engine. Each solve builds its own `NestJob` snapshot and materialized drawings, so solves share no mutable drawing state. Concurrent solves compete for cores, so `Time(ms)` is only clean at `--parallel 1`. It calls each engine's `INestingEngine.Solve(NestJob)` once per job, under a wall-clock timeout so a runaway or hanging engine can't stall the whole benchmark run, then materializes the result back into legacy `Plate`/`Part` objects via `NestResultMaterializer` for scoring.
|
||||
- `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically: each part's perimeter inflated and cutouts shrunk by the spacing, tested against the other part's raw material with holes subtracted, so part-in-part inside a cutout is legal; an X-sorted bounding-box sweep prunes distant pairs), and no drawing over its requested quantity. `ValidateAgainstJob` also checks the raw `NestJobResult`: every sheet must match a stock entry the job offered (size, spacing, edge spacing, quadrant; finite quantity not overdrawn), and every placement rotation must satisfy its part's `RotationPolicy.Allows`. An invalid, throwing, or timed-out run places nothing for scoring.
|
||||
- Ranking (`Report.Compare`): valid > invalid, fully placed > not, then lower `JobResult.Cost`, then fewer plates. Cost = salvage-credited sheet area (`StockLadderNestingEngine.EstimateNetArea` per plate, recomputed from job geometry) + `BenchmarkJob.UnplacedPartPenalty` (largest candidate sheet area) per unplaced part, so dropping hard parts never improves the score. The summary sums cost and areas across jobs (area-weighted, not a mean of per-job percentages). Without `--sheet-sizes`, `.nest` jobs only offer their original sizes, and the CLI warns that this hints engines. Numeric CLI and manifest sheet sizes parse with the invariant culture (`JobLoader.TryParseSheetSize`).
|
||||
- `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv <path>` writes a flat per-job CSV alongside the console report. `--progress` passes each solve a `JobProgressLog`, which writes `[job/engine]` lines for start, finish, every `PlateCommitted`, and `EvaluatingCandidate` throttled to one line per 2 s.
|
||||
- `tools/PepNestExport` (outside the solution; references `PepLib.Core` from the sibling `PepApi.Core` repo) converts a PepApi year of PEP nests into `.nest` files that keep PEP's placements as the benchmark `Baseline`. PEP loop quirks: sub-loop calls continue the incremental position; lead-in/out, `DESTRUCT CUT` and non-cut moves must not reach the program as rapids (a program's bounding box counts rapid endpoints); contours may be broken by uncut micro-joint tabs (a rapid of up to 0.25 across the tab, at the seam or mid-contour, e.g. a cutout cut as two halves), which the export bridges only where the pieces chain into a closed loop; and one drawing can be placed through several loops with different origins.
|
||||
|
||||
### 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.
|
||||
|
||||
- **Auto Nest engine routing**: when the selected engine is not a built-in fill strategy (`EngineSelection.IsFillStrategy` is false, i.e. StockLadder or an `Engines/` plug-in), `MainForm.RunJobEngineAsync` solves the whole job through `INestingEngine.Solve`. `JobEngineNest` builds the `NestJob` from the auto-nest items and either the plate options or the current plate, and it converts `NestJobProgress` for `NestProgressForm`: an engine's `LegacyProgress` passes through, and otherwise the stage and committed counts become the description. It then binds the result poses back onto the nest's own drawings. Whole-job engines throw on cancel, so the progress form hides Accept (`AllowAccept = false`) and Stop discards the run. Built-in strategies keep the existing per-plate fill path.
|
||||
- **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. Plugin sources live in the repository's `Posts/` folder (the solution's `PostProcessors` folder).
|
||||
|
||||
## 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 `AGENTS.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. Keep `CLAUDE.md` as a thin `@AGENTS.md` import rather than duplicating shared instructions.
|
||||
|
||||
**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.Jobs` (whole-job API, with `.Placement` and `.Adapters`), `OpenNest.Engine.ML`, `OpenNest.Engine.RapidPlanning`, `OpenNest.Engine.Sequencing`, `OpenNest.Engine.RectanglePacking`, `OpenNest.Engine.CirclePacking`. All Engine types live in namespaces matching their directory under `OpenNest.Engine/` (project files use `namespace X;` file-scoped or block style); consumers reference them via explicit `using OpenNest.Engine[.Sub];` directives.
|
||||
- `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`.
|
||||
- **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed with endpoints kept on the arc, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `NestValidator` uses `OffsetForValidation` instead: the same flattening with fine joins and no padding, inflated by the spacing less `NestTolerances.SpacingSlack` (0.0005), so a layout exactly at the spacing passes even after rotation and coordinate rounding leave it ~1e-4 short. `NestJobPlacementValidator` applies the same slack to its edge-distance check. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. `FillLinear` prepares each distinct `Program` (reference identity) once per public `Fill`/`FillRow` call and translates clones; never share that cache across calls or threads. Both `HasOverlappingParts` loops use one `PartOverlapChecker` per call (same keying; it also caches each part's triangles; parts and programs must not change while it is in use). Clipper is allowed only for cached CPU preparation, never in per-pair hot loops.
|
||||
- **Marks are not material**: scribe/etch moves are marked on the surface, never cut through, so they are left out of nesting. `SpecialLayers.IsMaterial(layer)` (excludes `Rapid` and `Scribe`) is the filter for every consumer that builds part material from a program: drawing area, canonical angle, part collision, `PartGeometry`, plate perimeters, best-fit/pair evaluation, rotation analysis, the GPU evaluators, and both validators (`NestJobPlacementValidator`, benchmark `NestValidator`). Cutting time, on-screen display, splitting, and post-processors still see marks. Older `.nest` files (e.g. `tools/PepNestExport` output) saved etch as cut moves while their source entities kept the `SCRIBE` layer; `NestReader` runs `ScribeLayerRepair` on load to move matching program moves back to `Scribe`.
|
||||
- **Native curve contact**: CPU best-fit and shared directional queries use `SpatialQuery.CurveTangencyDistance` for sum- and difference-radius tangency, checking both forward roots and both arc spans. It supplements vertex/line phases, not a complete collision/clearance validator. See [pair-spacing checks](docs/geometry/pair-spacing.md) for the regression and remaining limits.
|
||||
- `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. After its null/empty guards, `DefaultFillComparer` decides unequal counts without scoring; equal counts still use scores, and exact ties retain the current layout. `FillHelpers.FillPattern` computes eager scores only when no custom comparer is supplied; custom comparers remain authoritative and may perform their own scoring.
|
||||
- **Extents column pitch**: for finite valid geometry, finite pair height, and finite nonnegative spacing, `FillExtents.BuildColumn` uses `pair.Bbox.Width + partSpacing` directly. The old vertical slide calculation clamps to the same pitch, so it need not prepare boundaries or temporary test clones. Negative/nonfinite spacing or nonfinite pair height retains the legacy calculation: public/interactive callers do not all validate spacing. Do not remove `BuildPair` boundary preparation or the adjusted-column overlap fallback, or turn this shortcut into a geometry/validation policy change.
|
||||
- **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). MCP, API, and Training projects use `ImportDrawing` for headless conversion. The console uses `Import` followed by `BuildDrawing` so it can report bend-repair outcomes. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
|
||||
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` and the saved `SCRIBE` layer (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
|
||||
- [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.
|
||||
@@ -16,6 +16,9 @@ public class NestRequest
|
||||
|
||||
/// <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;
|
||||
|
||||
@@ -5,8 +5,8 @@ using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
@@ -19,9 +19,12 @@ 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;
|
||||
@@ -35,6 +38,9 @@ public class NestResponse
|
||||
/// <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; } = [];
|
||||
@@ -75,6 +81,8 @@ public class NestResponse
|
||||
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),
|
||||
@@ -158,6 +166,8 @@ 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 ?? [] : [],
|
||||
@@ -175,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; } = [];
|
||||
|
||||
+67
-39
@@ -5,10 +5,9 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
@@ -32,7 +31,7 @@ public static class NestRunner
|
||||
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)
|
||||
{
|
||||
@@ -68,23 +67,64 @@ public static class NestRunner
|
||||
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);
|
||||
@@ -97,29 +137,15 @@ public static class NestRunner
|
||||
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(),
|
||||
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,
|
||||
}
|
||||
@@ -140,6 +166,8 @@ public static class NestRunner
|
||||
"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));
|
||||
|
||||
@@ -83,7 +83,7 @@ namespace OpenNest.Benchmark
|
||||
parts,
|
||||
stock,
|
||||
new NestJobOptions(
|
||||
"Default",
|
||||
"Fill",
|
||||
maxPlates,
|
||||
salvageRate ?? SalvageRate,
|
||||
minimumSalvageDimension ?? 0
|
||||
|
||||
+130
-63
@@ -7,9 +7,9 @@ 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.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
@@ -21,6 +21,33 @@ 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)
|
||||
@@ -52,10 +79,8 @@ static class NestConsole
|
||||
// single-plate placement strategy. Unknown names exit with the valid choices.
|
||||
if (options.AutoNest)
|
||||
{
|
||||
var isJobsEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
||||
e.Name.Equals(options.Engine, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
if (!isJobsEngine)
|
||||
// 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))}"
|
||||
@@ -100,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);
|
||||
|
||||
@@ -110,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;
|
||||
}
|
||||
@@ -185,6 +219,9 @@ 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;
|
||||
@@ -194,6 +231,9 @@ static class NestConsole
|
||||
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;
|
||||
@@ -394,7 +434,9 @@ static class NestConsole
|
||||
);
|
||||
var existingPartsMessage = options.KeepParts
|
||||
? $"Keeping {existingCount} existing parts"
|
||||
: $"Cleared {existingCount} existing parts";
|
||||
: options.AutoNest
|
||||
? $"Will replace {existingCount} existing parts only after acceptance"
|
||||
: $"Cleared {existingCount} existing parts";
|
||||
Console.WriteLine(
|
||||
$"""
|
||||
Drawing: {drawing.Name}
|
||||
@@ -404,15 +446,17 @@ static class NestConsole
|
||||
);
|
||||
}
|
||||
|
||||
static (bool success, long elapsedMs) Fill(
|
||||
static (bool success, long elapsedMs, bool accepted) Fill(
|
||||
Nest nest,
|
||||
Plate plate,
|
||||
Drawing drawing,
|
||||
Options options
|
||||
Options options,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
bool success;
|
||||
var accepted = true;
|
||||
|
||||
if (options.AutoNest)
|
||||
{
|
||||
@@ -433,7 +477,9 @@ static class NestConsole
|
||||
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
|
||||
);
|
||||
|
||||
success = AutoNestJob(plate, nestItems, options.Engine);
|
||||
var outcome = AutoNestJob(plate, nestItems, options.Engine, options.AllowInvalid, token);
|
||||
accepted = outcome.accepted;
|
||||
success = outcome.committed > 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -447,8 +493,9 @@ static class NestConsole
|
||||
item,
|
||||
plate.WorkArea(),
|
||||
null,
|
||||
CancellationToken.None
|
||||
token
|
||||
);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (parts.Count > 0)
|
||||
plate.Parts.AddRange(parts);
|
||||
@@ -456,52 +503,45 @@ static class NestConsole
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
return (success, sw.ElapsedMilliseconds);
|
||||
return (success, sw.ElapsedMilliseconds, accepted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Solves the drawings as one whole job against this single plate using the named jobs
|
||||
/// engine, then commits the returned placements onto the plate. Placements are mapped back
|
||||
/// onto the caller's original drawings (same pose semantics as NestResultMaterializer), so
|
||||
/// the saved nest keeps its existing drawing identities.
|
||||
/// </summary>
|
||||
static bool AutoNestJob(Plate plate, List<NestItem> nestItems, string engineName)
|
||||
static (bool accepted, int committed) AutoNestJob(
|
||||
Plate plate, List<NestItem> nestItems, string engineName, bool allowInvalid, CancellationToken token)
|
||||
{
|
||||
var engine = NestingEngineRegistry.Create(engineName);
|
||||
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||
engineName, nestItems, NestStockBuilder.SinglePlate(plate)), token: token);
|
||||
foreach (var violation in result.Violations)
|
||||
Console.Error.WriteLine($"Violation: {violation}");
|
||||
|
||||
var parts = new List<NestJobPart>(nestItems.Count);
|
||||
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < nestItems.Count; i++)
|
||||
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
|
||||
{
|
||||
var partId = $"part-{i}";
|
||||
parts.Add(DrawingJobMapper.FromItem(partId, nestItems[i]));
|
||||
drawingsByPartId[partId] = nestItems[i].Drawing;
|
||||
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);
|
||||
}
|
||||
|
||||
// One physical sheet: this plate, this solve — the runner owns stock accounting.
|
||||
var stock = DrawingJobMapper.FromPlate("plate-0", plate, 1);
|
||||
var job = new NestJob(parts, [stock]);
|
||||
|
||||
var result = engine.Solve(job, null, CancellationToken.None);
|
||||
|
||||
var committed = 0;
|
||||
foreach (var plateResult in result.Plates)
|
||||
token.ThrowIfCancellationRequested();
|
||||
var proposed = result.Plates.SingleOrDefault();
|
||||
var committed = proposed?.Parts.Count ?? 0;
|
||||
if (committed == 0)
|
||||
{
|
||||
foreach (var pose in plateResult.Placements)
|
||||
{
|
||||
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
|
||||
continue;
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(pose.Rotation);
|
||||
part.Location = new Vector(pose.X, pose.Y);
|
||||
part.UpdateBounds();
|
||||
plate.Parts.Add(part);
|
||||
committed++;
|
||||
}
|
||||
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 committed > 0;
|
||||
return (true, committed);
|
||||
}
|
||||
|
||||
static string ResolveFillStrategy(string engineName)
|
||||
@@ -557,18 +597,16 @@ 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)
|
||||
@@ -633,10 +671,13 @@ 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);
|
||||
@@ -655,7 +696,15 @@ static class NestConsole
|
||||
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;
|
||||
@@ -669,8 +718,22 @@ static class NestConsole
|
||||
);
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -697,14 +760,16 @@ static class NestConsole
|
||||
--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 Whole-job nesting via the jobs engine (--engine) instead of single-plate fill
|
||||
--engine <name> With --autonest: jobs engine (default: Default; also StockLadder, Strip, ...).
|
||||
Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant)
|
||||
--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
|
||||
@@ -725,9 +790,11 @@ 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -234,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);
|
||||
@@ -300,12 +340,57 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return HashCode.Combine(r, a);
|
||||
}
|
||||
|
||||
// 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
|
||||
ContourType? forceType = null,
|
||||
bool exactCirclePrograms = false
|
||||
)
|
||||
{
|
||||
var contourType = forceType ?? DetectContourType(shape);
|
||||
@@ -351,7 +436,9 @@ 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;
|
||||
|
||||
@@ -367,19 +454,40 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
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
|
||||
var tabbed = Parameters.TabsEnabled
|
||||
&& Parameters.TabConfig != null
|
||||
&& contourType == ContourType.External
|
||||
)
|
||||
&& 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)
|
||||
@@ -436,6 +544,291 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return ContourType.Internal;
|
||||
}
|
||||
|
||||
/// <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,
|
||||
|
||||
+190
-164
@@ -90,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];
|
||||
@@ -110,7 +113,7 @@ namespace OpenNest.CNC
|
||||
);
|
||||
}
|
||||
|
||||
if (subpgm.Program != null)
|
||||
if (subpgm.Program != null && rotatedSubPrograms.Add(subpgm.Program))
|
||||
subpgm.Program.Rotate(angle, origin);
|
||||
}
|
||||
|
||||
@@ -269,37 +272,37 @@ namespace OpenNest.CNC
|
||||
switch (Mode)
|
||||
{
|
||||
case Mode.Absolute:
|
||||
{
|
||||
for (int i = Codes.Count; i >= 0; --i)
|
||||
{
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
for (int i = Codes.Count; i >= 0; --i)
|
||||
{
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
|
||||
if (motion == null)
|
||||
continue;
|
||||
if (motion == null)
|
||||
continue;
|
||||
|
||||
return motion.EndPoint;
|
||||
return motion.EndPoint;
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Mode.Incremental:
|
||||
{
|
||||
var pos = new Vector(0, 0);
|
||||
|
||||
for (int i = 0; i < Codes.Count; ++i)
|
||||
{
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
var pos = new Vector(0, 0);
|
||||
|
||||
if (motion == null)
|
||||
continue;
|
||||
for (int i = 0; i < Codes.Count; ++i)
|
||||
{
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
|
||||
pos += motion.EndPoint;
|
||||
if (motion == null)
|
||||
continue;
|
||||
|
||||
pos += motion.EndPoint;
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
|
||||
return new Vector(0, 0);
|
||||
@@ -334,164 +337,164 @@ namespace OpenNest.CNC
|
||||
switch (code.Type)
|
||||
{
|
||||
case CodeType.LinearMove:
|
||||
{
|
||||
var line = (LinearMove)code;
|
||||
var pt =
|
||||
Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
{
|
||||
var line = (LinearMove)code;
|
||||
var pt =
|
||||
Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
|
||||
pos = pt;
|
||||
pos = pt;
|
||||
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CodeType.RapidMove:
|
||||
{
|
||||
var line = (RapidMove)code;
|
||||
var pt =
|
||||
Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
{
|
||||
var line = (RapidMove)code;
|
||||
var pt =
|
||||
Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
|
||||
pos = pt;
|
||||
pos = pt;
|
||||
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CodeType.ArcMove:
|
||||
{
|
||||
var arc = (ArcMove)code;
|
||||
var radius = arc.CenterPoint.DistanceTo(arc.EndPoint);
|
||||
|
||||
Vector endpt;
|
||||
Vector centerpt;
|
||||
|
||||
if (Mode == Mode.Incremental)
|
||||
{
|
||||
endpt = arc.EndPoint + pos;
|
||||
centerpt = arc.CenterPoint + pos;
|
||||
var arc = (ArcMove)code;
|
||||
var radius = arc.CenterPoint.DistanceTo(arc.EndPoint);
|
||||
|
||||
Vector endpt;
|
||||
Vector centerpt;
|
||||
|
||||
if (Mode == Mode.Incremental)
|
||||
{
|
||||
endpt = arc.EndPoint + pos;
|
||||
centerpt = arc.CenterPoint + pos;
|
||||
}
|
||||
else
|
||||
{
|
||||
endpt = frameOrigin + arc.EndPoint;
|
||||
centerpt = frameOrigin + arc.CenterPoint;
|
||||
}
|
||||
|
||||
double minX1;
|
||||
double minY1;
|
||||
double maxX1;
|
||||
double maxY1;
|
||||
|
||||
if (pos.X < endpt.X)
|
||||
{
|
||||
minX1 = pos.X;
|
||||
maxX1 = endpt.X;
|
||||
}
|
||||
else
|
||||
{
|
||||
minX1 = endpt.X;
|
||||
maxX1 = pos.X;
|
||||
}
|
||||
|
||||
if (pos.Y < endpt.Y)
|
||||
{
|
||||
minY1 = pos.Y;
|
||||
maxY1 = endpt.Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
minY1 = endpt.Y;
|
||||
maxY1 = pos.Y;
|
||||
}
|
||||
|
||||
var startAngle = pos.AngleFrom(centerpt);
|
||||
var endAngle = endpt.AngleFrom(centerpt);
|
||||
|
||||
// switch the angle to counter clockwise.
|
||||
if (arc.Rotation == RotationType.CW)
|
||||
Generic.Swap(ref startAngle, ref endAngle);
|
||||
|
||||
startAngle = Angle.NormalizeRad(startAngle);
|
||||
endAngle = Angle.NormalizeRad(endAngle);
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, startAngle, endAngle))
|
||||
maxY1 = centerpt.Y + radius;
|
||||
|
||||
if (Angle.IsBetweenRad(System.Math.PI, startAngle, endAngle))
|
||||
minX1 = centerpt.X - radius;
|
||||
|
||||
const double oneHalfPI = System.Math.PI * 1.5;
|
||||
|
||||
if (Angle.IsBetweenRad(oneHalfPI, startAngle, endAngle))
|
||||
minY1 = centerpt.Y - radius;
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, startAngle, endAngle))
|
||||
maxX1 = centerpt.X + radius;
|
||||
|
||||
if (maxX1 > maxX)
|
||||
maxX = maxX1;
|
||||
|
||||
if (minX1 < minX)
|
||||
minX = minX1;
|
||||
|
||||
if (maxY1 > maxY)
|
||||
maxY = maxY1;
|
||||
|
||||
if (minY1 < minY)
|
||||
minY = minY1;
|
||||
|
||||
pos = endpt;
|
||||
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
endpt = frameOrigin + arc.EndPoint;
|
||||
centerpt = frameOrigin + arc.CenterPoint;
|
||||
}
|
||||
|
||||
double minX1;
|
||||
double minY1;
|
||||
double maxX1;
|
||||
double maxY1;
|
||||
|
||||
if (pos.X < endpt.X)
|
||||
{
|
||||
minX1 = pos.X;
|
||||
maxX1 = endpt.X;
|
||||
}
|
||||
else
|
||||
{
|
||||
minX1 = endpt.X;
|
||||
maxX1 = pos.X;
|
||||
}
|
||||
|
||||
if (pos.Y < endpt.Y)
|
||||
{
|
||||
minY1 = pos.Y;
|
||||
maxY1 = endpt.Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
minY1 = endpt.Y;
|
||||
maxY1 = pos.Y;
|
||||
}
|
||||
|
||||
var startAngle = pos.AngleFrom(centerpt);
|
||||
var endAngle = endpt.AngleFrom(centerpt);
|
||||
|
||||
// switch the angle to counter clockwise.
|
||||
if (arc.Rotation == RotationType.CW)
|
||||
Generic.Swap(ref startAngle, ref endAngle);
|
||||
|
||||
startAngle = Angle.NormalizeRad(startAngle);
|
||||
endAngle = Angle.NormalizeRad(endAngle);
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, startAngle, endAngle))
|
||||
maxY1 = centerpt.Y + radius;
|
||||
|
||||
if (Angle.IsBetweenRad(System.Math.PI, startAngle, endAngle))
|
||||
minX1 = centerpt.X - radius;
|
||||
|
||||
const double oneHalfPI = System.Math.PI * 1.5;
|
||||
|
||||
if (Angle.IsBetweenRad(oneHalfPI, startAngle, endAngle))
|
||||
minY1 = centerpt.Y - radius;
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, startAngle, endAngle))
|
||||
maxX1 = centerpt.X + radius;
|
||||
|
||||
if (maxX1 > maxX)
|
||||
maxX = maxX1;
|
||||
|
||||
if (minX1 < minX)
|
||||
minX = minX1;
|
||||
|
||||
if (maxY1 > maxY)
|
||||
maxY = maxY1;
|
||||
|
||||
if (minY1 < minY)
|
||||
minY = minY1;
|
||||
|
||||
pos = endpt;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case CodeType.SubProgramCall:
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
if (subpgm.Program == null)
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
if (subpgm.Program == null)
|
||||
break;
|
||||
|
||||
// Sub-program frame origin in this program's frame
|
||||
// is frameOrigin + Offset, regardless of current pos.
|
||||
pos = frameOrigin + subpgm.Offset;
|
||||
if (!subpgm.Program.BoundingBox(ref pos, out var box))
|
||||
break;
|
||||
|
||||
if (box.Left < minX)
|
||||
minX = box.Left;
|
||||
|
||||
if (box.Right > maxX)
|
||||
maxX = box.Right;
|
||||
|
||||
if (box.Bottom < minY)
|
||||
minY = box.Bottom;
|
||||
|
||||
if (box.Top > maxY)
|
||||
maxY = box.Top;
|
||||
|
||||
break;
|
||||
|
||||
// Sub-program frame origin in this program's frame
|
||||
// is frameOrigin + Offset, regardless of current pos.
|
||||
pos = frameOrigin + subpgm.Offset;
|
||||
if (!subpgm.Program.BoundingBox(ref pos, out var box))
|
||||
break;
|
||||
|
||||
if (box.Left < minX)
|
||||
minX = box.Left;
|
||||
|
||||
if (box.Right > maxX)
|
||||
maxX = box.Right;
|
||||
|
||||
if (box.Bottom < minY)
|
||||
minY = box.Bottom;
|
||||
|
||||
if (box.Top > maxY)
|
||||
maxY = box.Top;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
@@ -79,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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -135,8 +135,15 @@ namespace OpenNest
|
||||
)
|
||||
{
|
||||
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;
|
||||
@@ -157,6 +164,25 @@ namespace OpenNest
|
||||
return new List<(double Start, double End)> { (partStart, partEnd) };
|
||||
}
|
||||
|
||||
/// <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 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;
|
||||
}
|
||||
|
||||
private List<(double Start, double End)> IntersectPerimeter(
|
||||
Entity perimeter,
|
||||
double cutPosition,
|
||||
@@ -191,6 +217,15 @@ namespace OpenNest
|
||||
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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
public enum OverlapDisplayMode { Off, Areas, Centroids, Both }
|
||||
public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Failed, Canceled, Stale }
|
||||
|
||||
/// <summary>UI-thread lifecycle policy, independent of workers, GDI and view transforms.</summary>
|
||||
public sealed class OverlapReportState
|
||||
{
|
||||
private OverlapGeometryStamp stamp;
|
||||
private OverlapGeometryStamp displayStamp;
|
||||
private OverlapGeometryStamp observedDisplayStamp;
|
||||
private int uncheckedPartCount;
|
||||
|
||||
public long Generation { get; private set; }
|
||||
public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked;
|
||||
public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas;
|
||||
public PlateOverlapReport Report { get; private set; }
|
||||
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary>
|
||||
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
|
||||
public bool IsRunning => Status == OverlapCheckStatus.Checking;
|
||||
|
||||
public string Message => Status switch
|
||||
{
|
||||
OverlapCheckStatus.Checking => "Checking overlaps…",
|
||||
OverlapCheckStatus.Current => Report.Pairs.Count == 0
|
||||
? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs",
|
||||
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
|
||||
+ $"{uncheckedPartCount} parts could not be checked",
|
||||
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
|
||||
OverlapCheckStatus.Canceled => "Overlap check canceled",
|
||||
OverlapCheckStatus.Stale => "Overlap check out of date — run Check Overlaps again",
|
||||
_ => "Overlaps: not checked"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Starts a request. A manual check from Off shows Areas; an automatic recheck keeps
|
||||
/// the user's display choice, including Off.
|
||||
/// </summary>
|
||||
public long Begin(Plate plate, bool automatic = false)
|
||||
{
|
||||
RefreshDisplayPairs(plate);
|
||||
Clear(OverlapCheckStatus.Checking, preserveDisplay: true);
|
||||
stamp = OverlapGeometryStamp.Capture(plate);
|
||||
if (!automatic && DisplayMode == OverlapDisplayMode.Off)
|
||||
DisplayMode = OverlapDisplayMode.Areas;
|
||||
return Generation;
|
||||
}
|
||||
|
||||
public bool TryPublish(long generation, Plate plate, PlateOverlapReport report)
|
||||
{
|
||||
if (!CanComplete(generation, plate))
|
||||
return false;
|
||||
Report = report;
|
||||
DisplayPairs = report.Pairs;
|
||||
displayStamp = stamp;
|
||||
observedDisplayStamp = stamp;
|
||||
uncheckedPartCount = CountUncheckedParts(report.Issues);
|
||||
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryFail(long generation, Plate plate)
|
||||
{
|
||||
if (!CanComplete(generation, plate))
|
||||
return false;
|
||||
Clear(OverlapCheckStatus.Failed);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CanComplete(long generation, Plate plate) =>
|
||||
generation == Generation && IsRunning && EnsureFresh(plate);
|
||||
|
||||
public bool EnsureFresh(Plate plate)
|
||||
{
|
||||
RefreshDisplayPairs(plate);
|
||||
if (stamp == null)
|
||||
return false;
|
||||
if (stamp.Matches(plate))
|
||||
return true;
|
||||
Invalidate(plate);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Layout edit: retain only pairs whose two ordered slots still match exactly.</summary>
|
||||
public void Invalidate(Plate plate)
|
||||
{
|
||||
RefreshDisplayPairs(plate);
|
||||
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
|
||||
Clear(OverlapCheckStatus.Stale, preserveDisplay: true);
|
||||
}
|
||||
|
||||
/// <summary>In-place geometry edits and teardown must forget every cached display pair.</summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
ClearDisplayPairs();
|
||||
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
|
||||
Clear(OverlapCheckStatus.Stale);
|
||||
}
|
||||
|
||||
private void RefreshDisplayPairs(Plate plate)
|
||||
{
|
||||
if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true)
|
||||
return;
|
||||
var unchanged = displayStamp.UnchangedSlots(plate);
|
||||
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList();
|
||||
if (retained.Count != DisplayPairs.Count)
|
||||
DisplayPairs = retained.AsReadOnly();
|
||||
if (DisplayPairs.Count == 0)
|
||||
ClearDisplayPairs();
|
||||
else
|
||||
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
|
||||
}
|
||||
|
||||
private void ClearDisplayPairs()
|
||||
{
|
||||
DisplayPairs = Array.Empty<PlateOverlapPair>();
|
||||
displayStamp = null;
|
||||
observedDisplayStamp = null;
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
if (IsRunning)
|
||||
Clear(OverlapCheckStatus.Canceled);
|
||||
}
|
||||
|
||||
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
|
||||
|
||||
private void Clear(OverlapCheckStatus status, bool preserveDisplay = false)
|
||||
{
|
||||
if (!preserveDisplay)
|
||||
ClearDisplayPairs();
|
||||
Generation++;
|
||||
Report = null;
|
||||
uncheckedPartCount = 0;
|
||||
stamp = null;
|
||||
Status = status;
|
||||
}
|
||||
|
||||
public static int CountUncheckedParts(IEnumerable<PlateOverlapIssue> issues)
|
||||
{
|
||||
var ids = new HashSet<int>();
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
ids.Add(issue.PartAId);
|
||||
if (issue.PartBId.HasValue)
|
||||
ids.Add(issue.PartBId.Value);
|
||||
}
|
||||
return ids.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only, hole-aware material overlap diagnostics, separate from the engine's boolean checks.
|
||||
/// Uses clean drawing outlines, not placed lead-in/tab toolpaths or spacing offsets.
|
||||
/// </summary>
|
||||
public static class PlateOverlapAnalyzer
|
||||
{
|
||||
public const double ChordTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Captures poses and converts each distinct clean source program to owned entities once.
|
||||
/// Inputs must not change during capture. Later analysis never reads live domain objects.
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Capture(parts, new OverlapMaterialCache(), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
|
||||
/// and prepared drawing material from <paramref name="cache"/> across requests. Clear the
|
||||
/// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parts);
|
||||
ArgumentNullException.ThrowIfNull(cache);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var captured = new List<CapturedOverlapPart>();
|
||||
var issues = new List<PlateOverlapIssue>();
|
||||
for (var id = 0; id < parts.Count; id++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var part = parts[id];
|
||||
if (part?.BaseDrawing?.IsCutOff == true)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
if (part?.BaseDrawing?.Program == null)
|
||||
throw new ArgumentException("Part has no clean drawing program.");
|
||||
var program = part.BaseDrawing.Program;
|
||||
var rotation = part.Rotation - program.Rotation;
|
||||
var location = part.Location;
|
||||
if (!double.IsFinite(rotation) || !OverlapMaterial.IsFinite(location))
|
||||
throw new ArgumentException("Part pose must be finite.");
|
||||
var source = cache.Get(program, CaptureSource);
|
||||
if (source.Error != null)
|
||||
throw new ArgumentException(source.Error);
|
||||
captured.Add(new CapturedOverlapPart(id, part.BaseDrawing.Name,
|
||||
source, rotation, location));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapSnapshot(captured, issues);
|
||||
}
|
||||
|
||||
private static OverlapSource CaptureSource(Program program)
|
||||
{
|
||||
try
|
||||
{
|
||||
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance), false);
|
||||
// Conversion creates fresh geometry, including expanded shared hole calls;
|
||||
// no cloning/rotation of a live program or subprogram is necessary.
|
||||
return new OverlapSource(program, ConvertProgram.ToGeometry(program)
|
||||
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
|
||||
&& entity.Layer != SpecialLayers.Leadin
|
||||
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
return new OverlapSource(program, null, exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Convenience synchronous capture and analysis of a group of parts.</summary>
|
||||
public static PlateOverlapReport Analyze(IReadOnlyList<Part> parts,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Analyze(Capture(parts, cancellationToken), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns deterministic pair reports containing closed world-coordinate overlap fragments.
|
||||
/// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear.
|
||||
/// </summary>
|
||||
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Analyze(snapshot, null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Incremental recheck: identical to a full analysis of <paramref name="snapshot"/>, but a
|
||||
/// pair whose two parts are unchanged since <paramref name="previous"/> (same captured source
|
||||
/// and bit-identical pose, in the same relative order) reuses that report's result instead
|
||||
/// of being clipped again. After moving one part only its own neighbors are recomputed.
|
||||
/// Reuse needs sources shared through one <see cref="OverlapMaterialCache"/>; otherwise every
|
||||
/// pair is recomputed. Null <paramref name="previous"/> performs a full analysis.
|
||||
/// </summary>
|
||||
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot, PlateOverlapReport previous,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var reuse = PairReuse.Create(previous, snapshot);
|
||||
var issues = snapshot.Issues.ToList();
|
||||
var pairs = new List<PlateOverlapPair>();
|
||||
var prepared = new List<PreparedPart>();
|
||||
foreach (var part in snapshot.Parts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
// Prepared material is shared by every part and request using this source.
|
||||
var source = part.Source.Prepare(cancellationToken);
|
||||
if (source.Error != null)
|
||||
throw new ArgumentException(source.Error);
|
||||
var material = source.Material.Transform(part.Rotation, part.Location);
|
||||
prepared.Add(new PreparedPart(part, material));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(part.Id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
var sorted = prepared.OrderBy(part => part.Material.Outer.BoundingBox.Left)
|
||||
.ThenBy(part => part.Input.Id).ToArray();
|
||||
for (var i = 0; i < sorted.Length; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var first = sorted[i];
|
||||
var bounds = first.Material.Outer.BoundingBox;
|
||||
for (var j = i + 1; j < sorted.Length; j++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var second = sorted[j];
|
||||
var otherBounds = second.Material.Outer.BoundingBox;
|
||||
if (otherBounds.Left >= bounds.Right)
|
||||
break;
|
||||
if (otherBounds.Bottom >= bounds.Top || bounds.Bottom >= otherBounds.Top)
|
||||
continue;
|
||||
var a = first.Input.Id < second.Input.Id ? first : second;
|
||||
var b = first.Input.Id < second.Input.Id ? second : first;
|
||||
if (reuse != null && reuse.TryReuse(a.Input, b.Input, pairs, issues))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// Keep pair clipping arithmetic near the parts where possible, then
|
||||
// restore output to world space. Triangulation itself also uses stable
|
||||
// local-origin winding so tiny holes in a huge part remain correct.
|
||||
var origin = a.Material.Outer.Vertices[0];
|
||||
var localA = a.Material.Transform(0, origin * -1);
|
||||
var localB = b.Material.Transform(0, origin * -1);
|
||||
var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
|
||||
if (!result.Overlaps)
|
||||
continue;
|
||||
// Evaluate every hole-subtracted fragment before restoring world space.
|
||||
// A failed moment must make this pair incomplete, never an origin marker.
|
||||
var moments = new List<PolygonAreaMoments>();
|
||||
var regions = new List<PlateOverlapRegion>();
|
||||
foreach (var region in result.OverlapRegions)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment))
|
||||
throw new ArithmeticException("Overlap fragment area moments are invalid.");
|
||||
moments.Add(fragment);
|
||||
regions.Add(new PlateOverlapRegion(region.Vertices.Select(point => point + origin),
|
||||
fragment.Area));
|
||||
}
|
||||
if (!PolygonAreaMoments.TryCombine(moments, out var combined))
|
||||
throw new ArithmeticException("Combined overlap area moments are invalid.");
|
||||
var centroid = combined.Centroid + origin;
|
||||
if (!OverlapMaterial.IsFinite(centroid))
|
||||
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
|
||||
pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id,
|
||||
a.Input.Name, b.Input.Name, regions, centroid));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(a.Input.Id, b.Input.Id, exception.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
|
||||
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
|
||||
.ThenBy(issue => issue.PartBId).ToList(), snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps unchanged parts to their previous input positions and looks up previous pair results.
|
||||
/// A part is unchanged when its captured source object and exact pose bits match; identical
|
||||
/// duplicates are matched in input order, which is safe because their inputs are bit-identical.
|
||||
/// </summary>
|
||||
private sealed class PairReuse
|
||||
{
|
||||
private readonly Dictionary<int, int> previousIds;
|
||||
private readonly Dictionary<(int, int), PlateOverlapPair> pairs = new();
|
||||
private readonly Dictionary<(int, int), PlateOverlapIssue> issues = new();
|
||||
|
||||
private PairReuse(Dictionary<int, int> previousIds, PlateOverlapReport previous)
|
||||
{
|
||||
this.previousIds = previousIds;
|
||||
foreach (var pair in previous.Pairs)
|
||||
pairs[(pair.PartAId, pair.PartBId)] = pair;
|
||||
foreach (var issue in previous.Issues)
|
||||
if (issue.PartBId.HasValue)
|
||||
issues[(issue.PartAId, issue.PartBId.Value)] = issue;
|
||||
}
|
||||
|
||||
public static PairReuse Create(PlateOverlapReport previous, PlateOverlapSnapshot snapshot)
|
||||
{
|
||||
if (previous?.Snapshot == null)
|
||||
return null;
|
||||
var available = new Dictionary<PoseKey, Queue<int>>();
|
||||
foreach (var part in previous.Snapshot.Parts)
|
||||
{
|
||||
var key = PoseKey.Of(part);
|
||||
if (!available.TryGetValue(key, out var ids))
|
||||
available.Add(key, ids = new Queue<int>());
|
||||
ids.Enqueue(part.Id);
|
||||
}
|
||||
var previousIds = new Dictionary<int, int>();
|
||||
foreach (var part in snapshot.Parts)
|
||||
if (available.TryGetValue(PoseKey.Of(part), out var ids) && ids.Count > 0)
|
||||
previousIds.Add(part.Id, ids.Dequeue());
|
||||
return previousIds.Count < 2 ? null : new PairReuse(previousIds, previous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every bounding-box candidate pair among prepared parts was evaluated by the previous
|
||||
/// analysis, and unchanged parts have identical bounds, so absence there means clear.
|
||||
/// The previous pair must have had the same operand order: clipping is order-sensitive.
|
||||
/// </summary>
|
||||
public bool TryReuse(CapturedOverlapPart a, CapturedOverlapPart b,
|
||||
List<PlateOverlapPair> pairOutput, List<PlateOverlapIssue> issueOutput)
|
||||
{
|
||||
if (!previousIds.TryGetValue(a.Id, out var oldA) || !previousIds.TryGetValue(b.Id, out var oldB)
|
||||
|| oldA >= oldB)
|
||||
return false;
|
||||
if (pairs.TryGetValue((oldA, oldB), out var pair))
|
||||
pairOutput.Add(pair.Renumber(a.Id, b.Id, a.Name, b.Name));
|
||||
else if (issues.TryGetValue((oldA, oldB), out var issue))
|
||||
issueOutput.Add(issue with { PartAId = a.Id, PartBId = b.Id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct PoseKey(OverlapSource Source, long Rotation, long X, long Y)
|
||||
{
|
||||
public static PoseKey Of(CapturedOverlapPart part) => new(part.Source,
|
||||
BitConverter.DoubleToInt64Bits(part.Rotation),
|
||||
BitConverter.DoubleToInt64Bits(part.Location.X),
|
||||
BitConverter.DoubleToInt64Bits(part.Location.Y));
|
||||
}
|
||||
|
||||
internal static bool IsGeometryFailure(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
|
||||
// Exact built-in instruction types. Anything else, subclasses included, is refused before the
|
||||
// graph is copied or converted, so no unknown Clone or cast runs.
|
||||
private static readonly HashSet<Type> SupportedCodeTypes =
|
||||
[
|
||||
typeof(RapidMove), typeof(LinearMove), typeof(ArcMove), typeof(SubProgramCall),
|
||||
typeof(Comment), typeof(Feedrate), typeof(Kerf),
|
||||
];
|
||||
|
||||
private static void ValidateProgram(Program program, HashSet<Program> visiting, bool subprogram)
|
||||
{
|
||||
if (program == null || !visiting.Add(program) || visiting.Count > 64)
|
||||
throw new ArgumentException("Missing, recursive, or excessively nested subprogram.");
|
||||
if (program.Codes == null)
|
||||
throw new ArgumentException("Program has no instruction list.");
|
||||
// The converter adds a call's frame offset to incremental moves only, so an absolute
|
||||
// subprogram would be read at its frame origin. Converting it exactly needs a lossless
|
||||
// frame transform; until then it is refused rather than misread (OpenNest writes hole
|
||||
// subprograms in incremental mode).
|
||||
if (subprogram && program.Mode == Mode.Absolute)
|
||||
throw new NotSupportedException("Absolute-mode subprograms are not supported by the overlap check.");
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
if (code == null)
|
||||
throw new ArgumentException("Program contains a missing instruction.");
|
||||
if (!SupportedCodeTypes.Contains(code.GetType()))
|
||||
throw new NotSupportedException("Program contains an unsupported instruction.");
|
||||
if (code is Motion motion && !OverlapMaterial.IsFinite(motion.EndPoint)
|
||||
|| code is ArcMove arc && !OverlapMaterial.IsFinite(arc.CenterPoint))
|
||||
throw new ArgumentException("Program coordinates must be finite.");
|
||||
if (code is SubProgramCall call)
|
||||
{
|
||||
if (!OverlapMaterial.IsFinite(call.Offset) || !double.IsFinite(call.Rotation))
|
||||
throw new ArgumentException("Subprogram pose must be finite.");
|
||||
ValidateProgram(call.Program, visiting, true);
|
||||
}
|
||||
}
|
||||
visiting.Remove(program);
|
||||
}
|
||||
|
||||
private sealed record PreparedPart(CapturedOverlapPart Input, OverlapMaterial Material);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>An owned diagnostic result. An empty Pairs list is clear only if IsComplete is true.</summary>
|
||||
public sealed class PlateOverlapReport
|
||||
{
|
||||
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
|
||||
PlateOverlapSnapshot snapshot)
|
||||
{
|
||||
Pairs = pairs.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
Snapshot = snapshot;
|
||||
}
|
||||
|
||||
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
|
||||
internal PlateOverlapSnapshot Snapshot { get; }
|
||||
|
||||
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
|
||||
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||
public bool IsComplete => Issues.Count == 0;
|
||||
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
|
||||
}
|
||||
|
||||
/// <summary>Shared material for two input positions, ordered by zero-based input index.</summary>
|
||||
public sealed class PlateOverlapPair
|
||||
{
|
||||
private readonly Box bounds;
|
||||
|
||||
private PlateOverlapPair(PlateOverlapPair source, int partAId, int partBId, string partAName,
|
||||
string partBName)
|
||||
{
|
||||
PartAId = partAId;
|
||||
PartBId = partBId;
|
||||
PartAName = partAName;
|
||||
PartBName = partBName;
|
||||
Regions = source.Regions;
|
||||
Area = source.Area;
|
||||
Centroid = source.Centroid;
|
||||
bounds = source.bounds;
|
||||
}
|
||||
|
||||
/// <summary>The same immutable geometry under new input positions and captured names.</summary>
|
||||
internal PlateOverlapPair Renumber(int partAId, int partBId, string partAName, string partBName) =>
|
||||
new(this, partAId, partBId, partAName, partBName);
|
||||
|
||||
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
|
||||
List<PlateOverlapRegion> regions, Vector centroid)
|
||||
{
|
||||
PartAId = partAId;
|
||||
PartBId = partBId;
|
||||
PartAName = partAName;
|
||||
PartBName = partBName;
|
||||
Regions = regions.AsReadOnly();
|
||||
Area = regions.Sum(region => region.Area);
|
||||
Centroid = centroid;
|
||||
var points = regions.SelectMany(region => region.Vertices).ToArray();
|
||||
var left = points.Min(point => point.X);
|
||||
var bottom = points.Min(point => point.Y);
|
||||
bounds = new Box(left, bottom, points.Max(point => point.X) - left,
|
||||
points.Max(point => point.Y) - bottom);
|
||||
}
|
||||
|
||||
public int PartAId { get; }
|
||||
public int PartBId { get; }
|
||||
public string PartAName { get; }
|
||||
public string PartBName { get; }
|
||||
/// <summary>Convex fragments, not connected islands; no mutable kernel polygons are exposed.</summary>
|
||||
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
|
||||
public double Area { get; }
|
||||
/// <summary>
|
||||
/// Finite world-coordinate area centroid of all shared material, after hole subtraction.
|
||||
/// This can lie outside disconnected or concave shared material. Returned by value.
|
||||
/// </summary>
|
||||
public Vector Centroid { get; }
|
||||
/// <summary>A fresh world-coordinate bounds copy.</summary>
|
||||
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||
}
|
||||
|
||||
/// <summary>A positive-area, hole-subtracted convex polygon in world coordinates.</summary>
|
||||
public sealed class PlateOverlapRegion
|
||||
{
|
||||
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area)
|
||||
{
|
||||
Vertices = System.Array.AsReadOnly(vertices.ToArray());
|
||||
Area = area;
|
||||
}
|
||||
|
||||
/// <summary>Read-only vertices with an exactly repeated closing vertex.</summary>
|
||||
public IReadOnlyList<Vector> Vertices { get; }
|
||||
public double Area { get; }
|
||||
}
|
||||
|
||||
/// <summary>An input or pair that could not be checked. IDs are zero-based input positions.</summary>
|
||||
public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Owned clean geometry and poses. Capture while inputs are stable, then analyze on a worker.
|
||||
/// No live Part, Drawing, Program, or subprogram is retained.
|
||||
/// </summary>
|
||||
public sealed class PlateOverlapSnapshot
|
||||
{
|
||||
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues)
|
||||
{
|
||||
Parts = parts.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
}
|
||||
|
||||
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
||||
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||
}
|
||||
|
||||
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
|
||||
double Rotation, Vector Location);
|
||||
@@ -0,0 +1,128 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only pre-post diagnostics. The caller must keep the nest stable for the entire call.
|
||||
/// Placed programs are already rotated; only their placement translation is applied here.
|
||||
/// This intentionally does not invoke a post or promise machine collision avoidance.
|
||||
/// </summary>
|
||||
public static class PostVerificationAnalyzer
|
||||
{
|
||||
public static PostVerificationReport AnalyzeForPost(Nest nest, IPostProcessor postProcessor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var report = Analyze(nest, cancellationToken);
|
||||
if (postProcessor is IPostVerificationSupport { PreservesPlacedProgramOrder: true })
|
||||
return report;
|
||||
var findings = report.Findings.ToList();
|
||||
findings.Add(new(PostVerificationKind.Incomplete, 0, null, null,
|
||||
$"Post '{postProcessor?.Name ?? "unknown"}' does not declare that it preserves placed part/contour order " +
|
||||
"and pierce positions. Nest-level checks ran, but the final rapid sequence requires manual review."));
|
||||
return new PostVerificationReport(findings);
|
||||
}
|
||||
|
||||
public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default) =>
|
||||
Analyze(nest, Vector.Zero, cancellationToken);
|
||||
|
||||
public static PostVerificationReport Analyze(Nest nest, Vector startPoint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
PostVerificationGeometry.Validate(startPoint);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
if (nest.Plates == null)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, 1, null, null, "Nest has no plate collection."));
|
||||
return new PostVerificationReport(findings);
|
||||
}
|
||||
for (var plateIndex = 0; plateIndex < nest.Plates.Count; plateIndex++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var plate = nest.Plates[plateIndex];
|
||||
var plateNumber = plateIndex + 1;
|
||||
if (plate?.Parts == null)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||
"Plate has no part collection."));
|
||||
continue;
|
||||
}
|
||||
var materialParts = new List<Part>();
|
||||
var indices = new List<int>();
|
||||
var obstacles = new ReleasedContourState();
|
||||
Vector? position = startPoint;
|
||||
for (var index = 0; index < plate.Parts.Count; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var part = plate.Parts[index];
|
||||
var partNumber = index + 1;
|
||||
var cutoff = part?.BaseDrawing?.IsCutOff == true;
|
||||
var expectsCuts = cutoff;
|
||||
// Preflight even clean programs before calling the overlap converter: malformed
|
||||
// recursive graphs must never reach Program.Clone or unguarded conversion.
|
||||
if (!cutoff)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clean = ExecutionMotionReader.Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken).Motions;
|
||||
expectsCuts = clean.Any(move => !move.Rapid && move.Layer != LayerType.Scribe);
|
||||
if (!clean.Any(move => move.Layer == LayerType.Scribe)
|
||||
|| expectsCuts)
|
||||
{
|
||||
materialParts.Add(part);
|
||||
indices.Add(partNumber);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
Incomplete("Overlap check: " + exception.Message);
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
if (part == null || !double.IsFinite(part.Rotation))
|
||||
throw new ArgumentException("Missing part or invalid rotation.");
|
||||
var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions;
|
||||
if (expectsCuts && !moves.Any(move => !move.Rapid
|
||||
&& move.Layer is LayerType.Cut or LayerType.Display
|
||||
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
||||
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
||||
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
|
||||
position = moves[^1].End;
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
Incomplete("Lead-in/rapid check: " + exception.Message);
|
||||
// Subsequent internal moves can still be checked, but the incoming segment
|
||||
// cannot be reconstructed after an invalid program.
|
||||
position = null;
|
||||
}
|
||||
|
||||
void Incomplete(string message) => findings.Add(new(PostVerificationKind.Incomplete,
|
||||
plateNumber, partNumber, null, message));
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var overlap = PlateOverlapAnalyzer.Analyze(
|
||||
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
|
||||
foreach (var pair in overlap.Pairs)
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted)."));
|
||||
foreach (var issue in overlap.Issues)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
|
||||
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PostVerificationReport(findings);
|
||||
}
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>Local native line/arc queries, without changing the engine's geometry semantics.</summary>
|
||||
internal static class PostVerificationGeometry
|
||||
{
|
||||
internal const double Epsilon = 1e-8;
|
||||
private const double TwoPi = 2 * System.Math.PI;
|
||||
|
||||
/// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary>
|
||||
internal readonly record struct Extent(double MinX, double MinY, double MaxX, double MaxY)
|
||||
{
|
||||
/// <summary>No extent: unions with anything give the other extent.</summary>
|
||||
internal static Extent None => new(double.PositiveInfinity, double.PositiveInfinity,
|
||||
double.NegativeInfinity, double.NegativeInfinity);
|
||||
|
||||
internal Extent Union(Extent other) => new(System.Math.Min(MinX, other.MinX),
|
||||
System.Math.Min(MinY, other.MinY), System.Math.Max(MaxX, other.MaxX), System.Math.Max(MaxY, other.MaxY));
|
||||
|
||||
/// <summary>
|
||||
/// The gap two extents need before their checks may be skipped: ten times the widest
|
||||
/// absolute band any native contact query allows beyond an extent (the 0.00001 bounding-box
|
||||
/// allowance of native intersections, the 0.0001 contact reach of tiny arcs).
|
||||
/// </summary>
|
||||
internal const double ClearMargin = 1e-3;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates up to which extents may be skipped at all. Within it rounding stays far below
|
||||
/// <see cref="ClearMargin"/>; beyond it, where rounding of large supports can exceed any fixed
|
||||
/// margin, every check runs.
|
||||
/// </summary>
|
||||
internal const double WellConditionedLimit = 1e6;
|
||||
|
||||
/// <summary>
|
||||
/// True only when both extents are finite, lie within <see cref="WellConditionedLimit"/> and
|
||||
/// are more than <see cref="ClearMargin"/> apart on some axis, so no native query can count
|
||||
/// anything in one as touching or entering the other. Everything else must be checked.
|
||||
/// </summary>
|
||||
internal bool IsClearOf(Extent other) => IsWellConditioned && other.IsWellConditioned
|
||||
&& (MaxX + ClearMargin < other.MinX || other.MaxX + ClearMargin < MinX
|
||||
|| MaxY + ClearMargin < other.MinY || other.MaxY + ClearMargin < MinY);
|
||||
|
||||
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
|
||||
// (including the empty extent's) fail this test too.
|
||||
private bool IsWellConditioned =>
|
||||
System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)),
|
||||
System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit;
|
||||
}
|
||||
|
||||
internal static void Validate(Vector point)
|
||||
{
|
||||
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y)
|
||||
|| System.Math.Abs(point.X) > 1e12 || System.Math.Abs(point.Y) > 1e12)
|
||||
throw new ArgumentException("Nonfinite or numerically unsupported program coordinates.");
|
||||
}
|
||||
|
||||
internal static bool Closed(IReadOnlyList<Curve> curves) => curves.Count > 0
|
||||
&& curves[0].Start.DistanceTo(curves[^1].End) <= Epsilon;
|
||||
|
||||
internal static bool Crosses(Vector start, Vector end, IReadOnlyList<Curve> curves,
|
||||
CancellationToken token)
|
||||
{
|
||||
var delta = end - start;
|
||||
var length = start.DistanceTo(end);
|
||||
if (length <= Epsilon)
|
||||
return false;
|
||||
var direction = delta * (1 / length);
|
||||
foreach (var curve in curves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.ContactAfterStart(start, direction, length))
|
||||
return true;
|
||||
}
|
||||
// If there are no contacts except possibly departure, all open-segment points
|
||||
// have the same inside/outside status. A midpoint catches travel entirely inside
|
||||
// and departure into the interior, without flagging start-only outward contact.
|
||||
var midpoint = start + delta * 0.5;
|
||||
var inside = false;
|
||||
foreach (var curve in curves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.CrossesRay(midpoint))
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
||||
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||
private static double Normalize(double angle)
|
||||
{
|
||||
angle %= TwoPi;
|
||||
return angle < 0 ? angle + TwoPi : angle;
|
||||
}
|
||||
|
||||
internal sealed class Curve
|
||||
{
|
||||
private Curve(Vector start, Vector end, Vector? center, double radius, double sweep)
|
||||
{
|
||||
Start = start;
|
||||
End = end;
|
||||
Center = center;
|
||||
Radius = radius;
|
||||
Sweep = sweep;
|
||||
}
|
||||
|
||||
internal Vector Start { get; }
|
||||
internal Vector End { get; }
|
||||
private Vector? Center { get; }
|
||||
private double Radius { get; }
|
||||
private double Sweep { get; }
|
||||
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
|
||||
|
||||
// Native entities are freshly allocated; the immutable curve never exposes state.
|
||||
internal Entity ToEntity() => Center is { } center
|
||||
? System.Math.Abs(Sweep) >= TwoPi
|
||||
? new Circle(center, Radius)
|
||||
: new Arc(center, Radius, Normalize(StartAngle), Normalize(StartAngle + Sweep), Sweep < 0)
|
||||
: new Line(Start, End);
|
||||
|
||||
/// <summary>
|
||||
/// A conservative axis-aligned extent of everything a native query may count as touching
|
||||
/// this curve: for an arc, its whole supporting circle widened by the contact band of
|
||||
/// <see cref="ContactAfterStart"/>; for a line, its endpoints. Nonfinite geometry yields NaN
|
||||
/// bounds, which no separation test can pass.
|
||||
/// </summary>
|
||||
internal Extent Extent => Center is { } center
|
||||
? new(center.X - ContactReach, center.Y - ContactReach, center.X + ContactReach, center.Y + ContactReach)
|
||||
: new(System.Math.Min(Start.X, End.X), System.Math.Min(Start.Y, End.Y),
|
||||
System.Math.Max(Start.X, End.X), System.Math.Max(Start.Y, End.Y));
|
||||
|
||||
internal Vector Midpoint => Center is { } center
|
||||
? new Vector(center.X + Radius * System.Math.Cos(StartAngle + Sweep / 2),
|
||||
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
|
||||
: (Start + End) * 0.5;
|
||||
|
||||
internal bool SameSupport(Curve other)
|
||||
{
|
||||
if (Center is { } center)
|
||||
return other.Center is { } c && center.DistanceTo(c) <= Epsilon
|
||||
&& System.Math.Abs(Radius - other.Radius) <= Epsilon;
|
||||
if (other.Center.HasValue || Length <= Epsilon || other.Length <= Epsilon)
|
||||
return false;
|
||||
var direction = (End - Start) * (1 / Length);
|
||||
return System.Math.Abs(Cross(other.Start - Start, direction)) <= Epsilon
|
||||
&& System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon;
|
||||
}
|
||||
|
||||
// Directed native arc-length coordinates for contour replay, not collision queries.
|
||||
internal bool SameDirection(Curve other) => SameSupport(other)
|
||||
&& (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
|
||||
: Dot(End - Start, other.End - other.Start) > 0);
|
||||
|
||||
internal double DistanceAlong(Vector point) => Center is { } center
|
||||
? point.DistanceTo(Start) <= Epsilon ? 0
|
||||
: Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)) * Radius
|
||||
: Dot(point - Start, (End - Start) * (1 / Length));
|
||||
|
||||
internal Vector PointAtLength(double distance)
|
||||
{
|
||||
if (distance <= 0) return Start;
|
||||
if (distance >= Length) return End;
|
||||
return Center is { } center
|
||||
? center + new Vector(System.Math.Cos(StartAngle + System.Math.Sign(Sweep) * distance / Radius),
|
||||
System.Math.Sin(StartAngle + System.Math.Sign(Sweep) * distance / Radius)) * Radius
|
||||
: Start + (End - Start) * (distance / Length);
|
||||
}
|
||||
|
||||
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon;
|
||||
|
||||
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
||||
{
|
||||
var entity = ToEntity();
|
||||
var candidate = other.ToEntity();
|
||||
// Native arc filters discard NaN supporting-circle intersections. Inspect
|
||||
// both unfiltered queries first: roundoff can differ by operand direction.
|
||||
// Coincident supports have separate overlap handling below.
|
||||
if (Center is { } center && other.Center is { } otherCenter && !SameSupport(other))
|
||||
{
|
||||
var support = new Circle(center, Radius);
|
||||
var otherSupport = new Circle(otherCenter, other.Radius);
|
||||
support.Intersects(otherSupport, out var forward);
|
||||
otherSupport.Intersects(support, out var reverse);
|
||||
foreach (var point in forward)
|
||||
Validate(point);
|
||||
foreach (var point in reverse)
|
||||
Validate(point);
|
||||
}
|
||||
List<Vector> points;
|
||||
bool intersects;
|
||||
switch (candidate)
|
||||
{
|
||||
case Line line: intersects = entity.Intersects(line, out points); break;
|
||||
case Arc arc: intersects = entity.Intersects(arc, out points); break;
|
||||
case Circle circle: intersects = entity.Intersects(circle, out points); break;
|
||||
default: throw new NotSupportedException("Unsupported native boundary.");
|
||||
}
|
||||
if (!intersects)
|
||||
points.Clear();
|
||||
// Coincident circles produce NaNs in the native discrete-contact query.
|
||||
// Their support overlap is handled separately, without inventing crossings.
|
||||
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other)
|
||||
|| InteriorWitness(other.Midpoint, this)
|
||||
|| InteriorWitness(Start, other) || InteriorWitness(End, other)
|
||||
|| InteriorWitness(other.Start, this) || InteriorWitness(other.End, this));
|
||||
if (SameSupport(other))
|
||||
points.Clear();
|
||||
foreach (var point in points)
|
||||
Validate(point);
|
||||
var nativeContactCount = points.Count;
|
||||
if (Center is { } c && other.Center is { } oc && SameSupport(other)
|
||||
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius))
|
||||
overlap = true; // Nearly coincident supports are uncertain, never clear.
|
||||
foreach (var point in new[] { Start, End, other.Start, other.End })
|
||||
if (Contains(point) && other.Contains(point)
|
||||
&& !points.Exists(p => p.DistanceTo(point) <= Epsilon))
|
||||
points.Add(point);
|
||||
// Existing exact line/ray contact semantics guard native queries which
|
||||
// suppress very short or nearly parallel intersections. Uncertainty refuses.
|
||||
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other))
|
||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this))
|
||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||
return points;
|
||||
|
||||
static bool InteriorWitness(Vector point, Curve curve) => curve.Contains(point)
|
||||
&& (curve.Start.DistanceTo(curve.End) <= Epsilon
|
||||
|| (point.DistanceTo(curve.Start) > Epsilon && point.DistanceTo(curve.End) > Epsilon));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether exact ray semantics find <paramref name="curve"/> touching <paramref name="line"/>
|
||||
/// anywhere except at a line endpoint the curve contains, which is already a recorded contact.
|
||||
/// Rays from both ends together cover the whole line. A ray stops short of a contained
|
||||
/// endpoint (both stop half-way when both endpoints are contained): a line meeting a
|
||||
/// tangent arc at their shared vertex is otherwise rediscovered there as a contact the
|
||||
/// native query missed, because rounding can drop the tangent root of its quadratic.
|
||||
/// </summary>
|
||||
private static bool ExactLineContact(Curve line, Curve curve)
|
||||
{
|
||||
var direction = (line.End - line.Start) * (1 / line.Length);
|
||||
var startRecorded = curve.Contains(line.Start);
|
||||
var endRecorded = curve.Contains(line.End);
|
||||
var forward = endRecorded ? startRecorded ? line.Length / 2 : 0 : line.Length;
|
||||
var backward = startRecorded ? endRecorded ? line.Length / 2 : 0 : line.Length;
|
||||
return (forward > 0 && curve.ContactAfterStart(line.Start, direction, forward))
|
||||
|| (backward > 0 && curve.ContactAfterStart(line.End, direction * -1, backward));
|
||||
}
|
||||
|
||||
|
||||
internal static Curve Create(Vector start, Vector end, Vector? center, bool clockwise)
|
||||
{
|
||||
if (center is not { } c)
|
||||
return new Curve(start, end, null, 0, 0);
|
||||
Validate(c);
|
||||
var radius = start.DistanceTo(c);
|
||||
if (radius <= Epsilon || !double.IsFinite(radius)
|
||||
|| System.Math.Abs(radius - end.DistanceTo(c)) > Epsilon * System.Math.Max(1, radius))
|
||||
throw new ArgumentException("Arc has zero or inconsistent radius.");
|
||||
var a = System.Math.Atan2(start.Y - c.Y, start.X - c.X);
|
||||
var b = System.Math.Atan2(end.Y - c.Y, end.X - c.X);
|
||||
var sweep = start.DistanceTo(end) <= Epsilon ? TwoPi
|
||||
: Normalize(clockwise ? a - b : b - a);
|
||||
return new Curve(start, end, c, radius, clockwise ? -sweep : sweep);
|
||||
}
|
||||
|
||||
private double StartAngle => System.Math.Atan2(Start.Y - Center.Value.Y, Start.X - Center.Value.X);
|
||||
private double Travel(double angle) => Normalize(Sweep < 0 ? StartAngle - angle : angle - StartAngle);
|
||||
private bool OnArc(Vector point) => Travel(System.Math.Atan2(point.Y - Center.Value.Y,
|
||||
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|
||||
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
|
||||
|
||||
// How far past r^2 a squared distance from the centre still counts as touching the arc.
|
||||
// For small arcs this band reaches well beyond the radius (sqrt(1e-8) = 1e-4 as r -> 0).
|
||||
private double ContactSlack => Epsilon * System.Math.Max(1, Radius * 2);
|
||||
|
||||
// The largest distance from the centre that ContactAfterStart can count as contact.
|
||||
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
|
||||
|
||||
internal bool ContactAfterStart(Vector origin, Vector direction, double length)
|
||||
{
|
||||
if (Center is { } center)
|
||||
{
|
||||
// Intersect the actual circle, not an inscribed chord polygon: tangencies
|
||||
// and short arcs must not vanish between tessellation vertices.
|
||||
var relative = center - origin;
|
||||
var projection = Dot(relative, direction);
|
||||
var perpendicular = Cross(relative, direction);
|
||||
var square = Radius * Radius - perpendicular * perpendicular;
|
||||
if (square < -ContactSlack)
|
||||
return false;
|
||||
var offset = System.Math.Sqrt(System.Math.Max(0, square));
|
||||
return Hit(projection - offset) || Hit(projection + offset);
|
||||
|
||||
bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon
|
||||
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length));
|
||||
}
|
||||
var edge = End - Start;
|
||||
var relativeStart = Start - origin;
|
||||
var denominator = Cross(direction, edge);
|
||||
if (System.Math.Abs(denominator) <= 1e-12 * System.Math.Max(1, Length))
|
||||
{
|
||||
if (System.Math.Abs(Cross(relativeStart, direction)) > Epsilon)
|
||||
return false;
|
||||
var a = Dot(relativeStart, direction);
|
||||
var b = Dot(End - origin, direction);
|
||||
var low = System.Math.Max(0, System.Math.Min(a, b));
|
||||
var high = System.Math.Min(length, System.Math.Max(a, b));
|
||||
return high > Epsilon && low <= high + Epsilon;
|
||||
}
|
||||
var distanceAlongRapid = Cross(relativeStart, edge) / denominator;
|
||||
var fractionAlongEdge = Cross(relativeStart, direction) / denominator;
|
||||
return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon
|
||||
&& fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon)
|
||||
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon);
|
||||
}
|
||||
|
||||
internal bool CrossesRay(Vector point)
|
||||
{
|
||||
if (Center is not { } center)
|
||||
return (Start.Y > point.Y) != (End.Y > point.Y)
|
||||
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
|
||||
|
||||
// Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
|
||||
// half-open endpoint rule as a polygon ray test, solving X on the native
|
||||
// circle. This handles full circles, reversed arcs and shared vertices.
|
||||
var breaks = new List<double> { 0, System.Math.Abs(Sweep) };
|
||||
foreach (var angle in new[] { System.Math.PI / 2, 3 * System.Math.PI / 2 })
|
||||
{
|
||||
var travel = Travel(angle);
|
||||
if (travel > 0 && travel < System.Math.Abs(Sweep))
|
||||
breaks.Add(travel);
|
||||
}
|
||||
breaks.Sort();
|
||||
var inside = false;
|
||||
for (var i = 1; i < breaks.Count; i++)
|
||||
{
|
||||
var a = StartAngle + System.Math.Sign(Sweep) * breaks[i - 1];
|
||||
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
|
||||
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
|
||||
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
|
||||
if ((ya > point.Y) == (yb > point.Y))
|
||||
continue;
|
||||
var dy = point.Y - center.Y;
|
||||
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
|
||||
var x = center.X + (System.Math.Cos((a + b) / 2) >= 0 ? dx : -dx);
|
||||
if (x > point.X)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
public enum PostVerificationKind
|
||||
{
|
||||
Overlap,
|
||||
MissingLeadIn,
|
||||
RapidCrossing,
|
||||
Incomplete
|
||||
}
|
||||
|
||||
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
||||
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber,
|
||||
int? PartNumber, int? OtherPartNumber, string Message);
|
||||
|
||||
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
|
||||
public sealed class PostVerificationReport
|
||||
{
|
||||
internal PostVerificationReport(IEnumerable<PostVerificationFinding> findings)
|
||||
{
|
||||
Findings = Array.AsReadOnly(findings.ToArray());
|
||||
}
|
||||
|
||||
public IReadOnlyList<PostVerificationFinding> Findings { get; }
|
||||
public bool HasWarnings => Findings.Count != 0;
|
||||
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
|
||||
|
||||
public string ToDisplayText()
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine("Pre-post verification");
|
||||
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
|
||||
Summary(PostVerificationKind.Overlap, "Overlap");
|
||||
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
|
||||
Summary(PostVerificationKind.RapidCrossing, "Rapid crossings");
|
||||
foreach (var finding in Findings)
|
||||
{
|
||||
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
|
||||
if (finding.PartNumber is { } part)
|
||||
text.Append($", part {part}");
|
||||
if (finding.OtherPartNumber is { } other)
|
||||
text.Append($", other part {other}");
|
||||
text.AppendLine($": {finding.Kind}: {finding.Message}");
|
||||
}
|
||||
text.AppendLine("This is not a physical safety certification. The check uses direct XY rapids " +
|
||||
"in plate/program order; the post may change order, routing or retracts. Inspect the posted " +
|
||||
"machine program and machine setup. Actual contour gaps are not proof of adequate retention.");
|
||||
return text.ToString();
|
||||
|
||||
void Summary(PostVerificationKind kind, string label)
|
||||
{
|
||||
var count = Findings.Count(finding => finding.Kind == kind);
|
||||
text.AppendLine($"{label}: {count} warning(s)" +
|
||||
(incomplete ? "; verification incomplete — do not treat as clear." : "."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
@@ -32,13 +32,18 @@ namespace OpenNest
|
||||
Color.FromArgb(215, 130, 130), // Light Coral
|
||||
};
|
||||
|
||||
public static Color GetNextColor()
|
||||
public static bool UseGoldenAngleColors { get; set; }
|
||||
|
||||
public static Color GetPartColor(int index)
|
||||
{
|
||||
var color = PartColors[nextColorIndex % PartColors.Length];
|
||||
nextColorIndex++;
|
||||
return color;
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||
return UseGoldenAngleColors
|
||||
? PartColorPalette.GoldenAngle(index)
|
||||
: PartColors[index % PartColors.Length];
|
||||
}
|
||||
|
||||
public static Color GetNextColor() => GetPartColor(unchecked(nextColorIndex++) & int.MaxValue);
|
||||
|
||||
public Drawing()
|
||||
: this(string.Empty, new Program()) { }
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Captures drawing program text before an edit and rebuilds only the parts whose
|
||||
/// drawing program changed. Drawing keys use reference identity because names are editable.
|
||||
/// </summary>
|
||||
public sealed class DrawingProgramSnapshot
|
||||
{
|
||||
private readonly Dictionary<Drawing, string> programs;
|
||||
private readonly Func<Program, string> fingerprint;
|
||||
|
||||
private DrawingProgramSnapshot(
|
||||
Dictionary<Drawing, string> programs,
|
||||
Func<Program, string> fingerprint)
|
||||
{
|
||||
this.programs = programs;
|
||||
this.fingerprint = fingerprint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture before handing drawings to an editor, including its load operation:
|
||||
/// programs can be edited in place. The callback must include both the main
|
||||
/// program text and its hole sub-programs, and must not mutate the program.
|
||||
/// </summary>
|
||||
public static DrawingProgramSnapshot Capture(
|
||||
IEnumerable<Drawing> drawings,
|
||||
Func<Program, string> fingerprint)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drawings);
|
||||
ArgumentNullException.ThrowIfNull(fingerprint);
|
||||
var programs = new Dictionary<Drawing, string>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var drawing in drawings)
|
||||
{
|
||||
if (!drawing.IsCutOff && !programs.ContainsKey(drawing))
|
||||
programs.Add(drawing, fingerprint(drawing.Program));
|
||||
}
|
||||
|
||||
return new DrawingProgramSnapshot(programs, fingerprint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete the captured edit by rebuilding changed drawings' parts across all plates.
|
||||
/// Part.Update preserves the placement and clears obsolete lead-ins, tabs and locks.
|
||||
/// Unchanged and uncaptured drawings' parts retain their program instances and state.
|
||||
/// Returns the rebuilt parts so a UI can invalidate just their graphics.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Part> UpdateChangedParts(IEnumerable<Plate> plates)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
var changed = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var entry in programs)
|
||||
{
|
||||
if (!entry.Key.IsCutOff
|
||||
&& !string.Equals(entry.Value, fingerprint(entry.Key.Program), StringComparison.Ordinal))
|
||||
changed.Add(entry.Key);
|
||||
}
|
||||
|
||||
var updated = new List<Part>();
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (!changed.Contains(part.BaseDrawing))
|
||||
continue;
|
||||
|
||||
part.Update();
|
||||
updated.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Tuning for <see cref="DrawingAligner"/>. Every default is a design choice
|
||||
/// under measurement, not a calibrated safe envelope: until calibration
|
||||
/// establishes one, every alignment pair still requires operator confirmation.
|
||||
/// Values are expressed in the declared units of the drawings being aligned.
|
||||
/// </summary>
|
||||
public sealed class AlignmentOptions
|
||||
{
|
||||
/// <summary>Chord-error tolerance used when flattening arcs for measurement.</summary>
|
||||
public double FlattenTolerance { get; set; } = 0.01;
|
||||
|
||||
/// <summary>
|
||||
/// Target arclength between resampled contour samples. Must resolve the
|
||||
/// intended residual gate: a residual below roughly half this spacing is
|
||||
/// not meaningful.
|
||||
/// </summary>
|
||||
public double SamplingSpacing { get; set; } = 0.5;
|
||||
|
||||
/// <summary>Upper bound on samples per contour ring.</summary>
|
||||
public int MaxSamplesPerRing { get; set; } = 4000;
|
||||
|
||||
/// <summary>Upper bound on total samples across all rings of one drawing.</summary>
|
||||
public int MaxTotalSamples { get; set; } = 20000;
|
||||
|
||||
/// <summary>Trim fraction: worst-distance correspondences excluded from each ICP fit.</summary>
|
||||
public double TrimFraction { get; set; } = 0.10;
|
||||
|
||||
/// <summary>Fit iterations per candidate start.</summary>
|
||||
public int MaxIterations { get; set; } = 40;
|
||||
|
||||
/// <summary>Pose change (radians) treated as converged.</summary>
|
||||
public double RotationEpsilon { get; set; } = 1e-7;
|
||||
|
||||
/// <summary>Translation change in units treated as converged.</summary>
|
||||
public double TranslationEpsilon { get; set; } = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// Correspondences farther than this count as unmatched (changed geometry),
|
||||
/// not as fit error. Defaults to a generous envelope; the review gate
|
||||
/// reports coverage instead of silently clamping it.
|
||||
/// </summary>
|
||||
public double OutlierDistance { get; set; } = 10.0;
|
||||
|
||||
/// <summary>Minimum distinct samples required to attempt a fit at all.</summary>
|
||||
public int MinSamples { get; set; } = 8;
|
||||
}
|
||||
|
||||
/// <summary>Why an alignment is not trustworthy. Absence of all flags is not a
|
||||
/// calibrated safe envelope; it only means no review reason fired.</summary>
|
||||
[Flags]
|
||||
public enum AlignmentReasons
|
||||
{
|
||||
None = 0,
|
||||
|
||||
/// <summary>The fit stopped on an iteration/work limit or stagnated without converging.</summary>
|
||||
FailedConvergence = 1,
|
||||
|
||||
/// <summary>The revised geometry has too few usable samples, too little outer
|
||||
/// support, or too little of the target matched to it.</summary>
|
||||
InsufficientSupport = 2,
|
||||
|
||||
/// <summary>Significant changed/unmatched boundary spans in either direction.</summary>
|
||||
SignificantBoundaryChange = 4,
|
||||
|
||||
/// <summary>Two or more genuinely distinct poses fit near-equally (symmetry,
|
||||
/// repeated features). The reported transform is still valid geometry.</summary>
|
||||
UnresolvedAlternatives = 8,
|
||||
|
||||
/// <summary>Input geometry is invalid: nonfinite coordinates, degenerate or
|
||||
/// unclosed rings, unsupported topology, zero usable perimeter.</summary>
|
||||
InvalidGeometry = 16,
|
||||
|
||||
/// <summary>A reflected candidate fits as well as the best rigid one, or the
|
||||
/// input cannot exclude reflection. Reflection is never applied silently.</summary>
|
||||
ReflectionUncertain = 32,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured outcome of aligning one revised drawing to one old drawing.
|
||||
/// The transform maps NEW points into the OLD drawing-local frame:
|
||||
/// reflect about the declared local axis (only when <see cref="Reflection"/> is
|
||||
/// true, which the automatic aligner never sets), then rotate by
|
||||
/// <see cref="Rotation"/> (radians), then translate by <see cref="Translation"/>.
|
||||
/// Diagnostics are in the drawings' declared units. A diagnostic IoU is
|
||||
/// bounded to [0, 1] and is not a calibrated probability.
|
||||
/// </summary>
|
||||
public sealed class AlignmentResult
|
||||
{
|
||||
public bool Converged { get; init; }
|
||||
public double Rotation { get; init; }
|
||||
public Vector Translation { get; init; }
|
||||
public bool Reflection { get; init; }
|
||||
|
||||
/// <summary>Review reasons; combined across candidate selection. Empty does
|
||||
/// not license skipping the operator overlay — no calibrated envelope
|
||||
/// exists yet.</summary>
|
||||
public AlignmentReasons Reasons { get; init; }
|
||||
|
||||
/// <summary>Trimmed RMS of matched sample-to-segment residuals.</summary>
|
||||
public double ResidualRms { get; init; }
|
||||
public double ResidualP50 { get; init; }
|
||||
public double ResidualP90 { get; init; }
|
||||
|
||||
/// <summary>Fraction of NEW samples matching OLD within the outlier bound, and vice versa.</summary>
|
||||
public double NewToOldCoverage { get; init; }
|
||||
public double OldToNewCoverage { get; init; }
|
||||
|
||||
/// <summary>Number of distinct converged candidate poses clustered as equivalent.</summary>
|
||||
public int EquivalentCandidateCount { get; init; }
|
||||
|
||||
public int Iterations { get; init; }
|
||||
public int NewSampleCount { get; init; }
|
||||
public int OldSampleCount { get; init; }
|
||||
|
||||
/// <summary>Bounded diagnostic intersection-over-union of the material regions,
|
||||
/// or null when regions are unavailable or degenerate. Never a gate input.</summary>
|
||||
public double? DiagnosticIoU { get; init; }
|
||||
|
||||
public string FailureMessage { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -118,6 +119,27 @@ namespace OpenNest.Geometry
|
||||
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sums signed angular change traversing consecutive points around a center.
|
||||
/// Positive = CCW, negative = CW.
|
||||
/// </summary>
|
||||
public static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI)
|
||||
da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI)
|
||||
da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the maximum radial deviation of interior points from a circle.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Signed clearance between two closed polygons, plus the unit direction that
|
||||
/// increases it by moving the first polygon.
|
||||
/// </summary>
|
||||
public struct ClearanceResult
|
||||
{
|
||||
/// <summary>
|
||||
/// > 0: minimum boundary distance. 0: touching. < 0: penetration depth
|
||||
/// (the translation of <c>a</c> along <see cref="Direction"/> needed to end
|
||||
/// contact).
|
||||
/// </summary>
|
||||
public double Distance;
|
||||
|
||||
/// <summary>
|
||||
/// Unit direction for translating <c>a</c> away from <c>b</c>. For penetration
|
||||
/// this is the minimum-translation direction. Never zero-length; degenerate
|
||||
/// (coincident-centroid) penetration resolves to a deterministic axis.
|
||||
/// </summary>
|
||||
public Vector Direction;
|
||||
|
||||
public ClearanceResult(double distance, Vector direction)
|
||||
{
|
||||
Distance = distance;
|
||||
Direction = direction;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Omnidirectional clearance between two closed, lines-only polygons.
|
||||
/// Complements <see cref="SpatialQuery.DirectionalDistance"/> (movement along a
|
||||
/// fixed ray) with the all-directions minimum distance and separating direction,
|
||||
/// and <see cref="Collision"/> (boolean overlap) with depth and direction.
|
||||
/// <para>
|
||||
/// Reference quality, not hot-loop quality: separation is a brute-force
|
||||
/// segment-pair minimum with a bounding-box reject, penetration is a
|
||||
/// separating-axis sweep over both polygons' edge normals. The overlap verdict
|
||||
/// defers to <see cref="Collision.HasOverlap(Polygon, Polygon, List{Polygon}, List{Polygon})"/>
|
||||
/// so callers that validate with Collision never see a disagreeing kernel.
|
||||
/// Rings with holes are handled by the caller: pass every ring pair (a part's
|
||||
/// material boundary is its outer ring plus its hole rings).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Clearance
|
||||
{
|
||||
public static ClearanceResult Between(Polygon a, Polygon b)
|
||||
{
|
||||
var linesA = a.ToLines();
|
||||
var linesB = b.ToLines();
|
||||
|
||||
if (linesA.Count == 0 || linesB.Count == 0)
|
||||
return new ClearanceResult(0, new Vector(1, 0));
|
||||
|
||||
if (Collision.HasOverlap(a, b))
|
||||
return Penetration(linesA, linesB);
|
||||
|
||||
return Separation(linesA, linesB);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-negative minimum boundary (edge-to-edge) distance between two rings
|
||||
/// and the direction that translates <paramref name="a"/> away from
|
||||
/// <paramref name="b"/> at the closest contact. Never tests overlap: a ring
|
||||
/// contained in another (a part inside a cutout ring) still reports its true
|
||||
/// gap. For signed material clearance use <see cref="Between"/>.
|
||||
/// </summary>
|
||||
public static ClearanceResult BoundaryDistance(Polygon a, Polygon b)
|
||||
{
|
||||
var linesA = a.ToLines();
|
||||
var linesB = b.ToLines();
|
||||
|
||||
if (linesA.Count == 0 || linesB.Count == 0)
|
||||
return new ClearanceResult(0, new Vector(1, 0));
|
||||
|
||||
return Separation(linesA, linesB);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum boundary distance between two non-overlapping rings and the
|
||||
/// direction that translates <paramref name="linesA"/> away from
|
||||
/// <paramref name="linesB"/> at the closest contact.
|
||||
/// </summary>
|
||||
private static ClearanceResult Separation(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var pa = Vector.Zero;
|
||||
var pb = Vector.Zero;
|
||||
|
||||
var boxes = new Box[linesB.Count];
|
||||
for (var i = 0; i < linesB.Count; i++)
|
||||
boxes[i] = SegmentBox(linesB[i]);
|
||||
|
||||
foreach (var la in linesA)
|
||||
{
|
||||
var boxA = SegmentBox(la);
|
||||
|
||||
for (var i = 0; i < linesB.Count; i++)
|
||||
{
|
||||
if (!BoxesWithin(boxA, boxes[i], minDist))
|
||||
continue;
|
||||
|
||||
var d = SegmentDistance(la, linesB[i], out var qa, out var qb);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
pa = qa;
|
||||
pb = qb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dir = pa - pb;
|
||||
var len = Magnitude(dir);
|
||||
|
||||
if (len <= Tolerance.Epsilon)
|
||||
dir = CentroidAway(linesA, linesB);
|
||||
else
|
||||
dir = dir / len;
|
||||
|
||||
return new ClearanceResult(minDist, dir);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Penetration depth and minimum-translation direction along the separating-
|
||||
/// axis candidates of both rings. Per candidate axis the true translation
|
||||
/// depth is used (exit distance to the far side), so containment reports the
|
||||
/// depth that actually ends contact, not the interval-intersection length.
|
||||
/// Depth is reported as a negative clearance.
|
||||
/// </summary>
|
||||
private static ClearanceResult Penetration(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var ca = Centroid(linesA);
|
||||
var cb = Centroid(linesB);
|
||||
|
||||
var bestDepth = double.MaxValue;
|
||||
var bestDir = new Vector(1, 0);
|
||||
|
||||
var bestAxis = -1;
|
||||
|
||||
for (var axis = 0; axis < 2; axis++)
|
||||
{
|
||||
var lines = axis == 0 ? linesA : linesB;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var edge = line.pt2 - line.pt1;
|
||||
var n = new Vector(edge.Y, -edge.X);
|
||||
var len = Magnitude(n);
|
||||
if (len <= Tolerance.Epsilon)
|
||||
continue;
|
||||
n = n / len;
|
||||
|
||||
var (minA, maxA) = Project(linesA, n);
|
||||
var (minB, maxB) = Project(linesB, n);
|
||||
|
||||
if (maxA <= minB || maxB <= minA)
|
||||
continue; // separating axis found
|
||||
|
||||
// Depth pushing a away from b along ±n.
|
||||
var forward = maxB - minA; // move a in +n until minA >= maxB
|
||||
var backward = maxA - minB; // move a in -n until maxA <= minB
|
||||
|
||||
double depth;
|
||||
Vector dir;
|
||||
if (forward <= backward)
|
||||
{
|
||||
depth = forward;
|
||||
dir = n;
|
||||
}
|
||||
else
|
||||
{
|
||||
depth = backward;
|
||||
dir = -n;
|
||||
}
|
||||
|
||||
if (depth < bestDepth - Tolerance.Epsilon || bestAxis < 0)
|
||||
{
|
||||
bestDepth = depth;
|
||||
bestDir = dir;
|
||||
bestAxis = axis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestAxis < 0)
|
||||
{
|
||||
// No candidate axis (degenerate rings): deterministic fallback.
|
||||
var away = ca - cb;
|
||||
var len = Magnitude(away);
|
||||
bestDir = len > Tolerance.Epsilon ? away / len : new Vector(1, 0);
|
||||
bestDepth = 0;
|
||||
}
|
||||
|
||||
return new ClearanceResult(-bestDepth, bestDir);
|
||||
}
|
||||
|
||||
private static Vector CentroidAway(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var away = Centroid(linesA) - Centroid(linesB);
|
||||
var len = Magnitude(away);
|
||||
return len > Tolerance.Epsilon ? away / len : new Vector(1, 0);
|
||||
}
|
||||
|
||||
private static Vector Centroid(List<Line> lines)
|
||||
{
|
||||
var sum = Vector.Zero;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
sum += line.pt1;
|
||||
sum += line.pt2;
|
||||
}
|
||||
return sum / (2 * lines.Count);
|
||||
}
|
||||
|
||||
private static (double Min, double Max) Project(List<Line> lines, Vector n)
|
||||
{
|
||||
var min = double.MaxValue;
|
||||
var max = double.MinValue;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var d1 = line.pt1.DotProduct(n);
|
||||
var d2 = line.pt2.DotProduct(n);
|
||||
if (d1 < min)
|
||||
min = d1;
|
||||
if (d1 > max)
|
||||
max = d1;
|
||||
if (d2 < min)
|
||||
min = d2;
|
||||
if (d2 > max)
|
||||
max = d2;
|
||||
}
|
||||
|
||||
return (min, max);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum distance between two segments with the closest points.
|
||||
/// Non-parallel segments use the classic clamped closest-point solve;
|
||||
/// (near-)parallel segments fall back to the four endpoint-to-segment
|
||||
/// distances, which is where the minimum always lies.
|
||||
/// </summary>
|
||||
private static double SegmentDistance(Line a, Line b, out Vector pa, out Vector pb)
|
||||
{
|
||||
var p = a.pt1;
|
||||
var r = a.pt2 - a.pt1;
|
||||
var q = b.pt1;
|
||||
var s = b.pt2 - b.pt1;
|
||||
|
||||
var rxr = r.DotProduct(r);
|
||||
var sxs = s.DotProduct(s);
|
||||
var rxs = r.DotProduct(s);
|
||||
|
||||
const double eps = 1e-12;
|
||||
|
||||
var denom = rxr * sxs - rxs * rxs;
|
||||
if (denom > eps && rxr > eps && sxs > eps)
|
||||
{
|
||||
// Minimize |(p + r t) - (q + s u)|^2; setting both partials to
|
||||
// zero and solving (Cramer) with d0 = p - q:
|
||||
// t = ((r.s)(d0.s) - (d0.r)(s.s)) / (rr.ss - (r.s)^2)
|
||||
// u = ((r.r)(d0.s) - (r.s)(d0.r)) / (rr.ss - (r.s)^2)
|
||||
var d0 = p - q;
|
||||
var d0r = d0.DotProduct(r);
|
||||
var d0s = d0.DotProduct(s);
|
||||
|
||||
var t = Clamp((rxs * d0s - d0r * sxs) / denom, 0, 1);
|
||||
var u = Clamp((rxs * t + d0s) / sxs, 0, 1); // nearest u on b for clamped t
|
||||
t = Clamp((rxs * u - d0r) / rxr, 0, 1); // re-solve t for clamped u
|
||||
|
||||
pa = p + r * t;
|
||||
pb = q + s * u;
|
||||
return pa.DistanceTo(pb);
|
||||
}
|
||||
|
||||
// Degenerate or parallel: the minimum is attained at an endpoint.
|
||||
var bestPa = p;
|
||||
var bestPb = q;
|
||||
var best = double.MaxValue;
|
||||
|
||||
void Consider(Vector pt, Line seg, bool ptOnA)
|
||||
{
|
||||
var d = seg.pt2 - seg.pt1;
|
||||
var len2 = d.DotProduct(d);
|
||||
var u = len2 <= eps ? 0 : Clamp((pt - seg.pt1).DotProduct(d) / len2, 0, 1);
|
||||
var on = seg.pt1 + d * u;
|
||||
var dist = pt.DistanceTo(on);
|
||||
if (dist < best)
|
||||
{
|
||||
best = dist;
|
||||
bestPa = ptOnA ? pt : on;
|
||||
bestPb = ptOnA ? on : pt;
|
||||
}
|
||||
}
|
||||
|
||||
Consider(p, b, true);
|
||||
Consider(a.pt2, b, true);
|
||||
Consider(q, a, false);
|
||||
Consider(b.pt2, a, false);
|
||||
|
||||
pa = bestPa;
|
||||
pb = bestPb;
|
||||
return best;
|
||||
}
|
||||
|
||||
private static double Clamp(double v, double lo, double hi) =>
|
||||
v < lo ? lo : (v > hi ? hi : v);
|
||||
|
||||
private static double Magnitude(Vector v) => System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
|
||||
|
||||
private static Box SegmentBox(Line line)
|
||||
{
|
||||
return new Box(
|
||||
System.Math.Min(line.pt1.X, line.pt2.X),
|
||||
System.Math.Min(line.pt1.Y, line.pt2.Y),
|
||||
System.Math.Abs(line.pt2.X - line.pt1.X),
|
||||
System.Math.Abs(line.pt2.Y - line.pt1.Y)
|
||||
);
|
||||
}
|
||||
|
||||
private static bool BoxesWithin(Box a, Box b, double distance)
|
||||
{
|
||||
return !(
|
||||
a.Right + distance < b.Left
|
||||
|| b.Right + distance < a.Left
|
||||
|| a.Top + distance < b.Bottom
|
||||
|| b.Top + distance < a.Bottom
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// One world-space point on a cut contour together with the cut direction there.
|
||||
/// Produced by <see cref="ContourSampler"/> for rendering (cut-direction arrows)
|
||||
/// and for measurement (contour alignment); neither caller may mutate it.
|
||||
/// </summary>
|
||||
public readonly struct ContourSample
|
||||
{
|
||||
/// <summary>Point on the contour in world coordinates.</summary>
|
||||
public Vector Position { get; }
|
||||
|
||||
/// <summary>Unit vector pointing in the direction of travel along the contour.</summary>
|
||||
public Vector Direction { get; }
|
||||
|
||||
/// <summary>
|
||||
/// World-frame tangent angle in radians (atan2 of <see cref="Direction"/>).
|
||||
/// Screen-space conversion is the renderer's job.
|
||||
/// </summary>
|
||||
public double Tangent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Arclength of this sample from the start of the contour walk it came from:
|
||||
/// cumulative distance along non-rapid, non-suppressed moves for a program
|
||||
/// walk, and from the ring's first vertex for a ring walk.
|
||||
/// </summary>
|
||||
public double At { get; }
|
||||
|
||||
public ContourSample(Vector position, Vector direction, double tangent, double at)
|
||||
{
|
||||
Position = position;
|
||||
Direction = direction;
|
||||
Tangent = tangent;
|
||||
At = at;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure contour sampling math shared by the cut-direction arrow renderer and by
|
||||
/// contour alignment. All positions and tangents are world-space; no screen
|
||||
/// conversion, no arrowheads, and no view state appear here.
|
||||
/// <para>
|
||||
/// Two scheduling policies live here deliberately:
|
||||
/// <see cref="LineMoves"/> and <see cref="ArcMoves"/> implement the arrow
|
||||
/// renderer's established display policy (skip moves shorter than half the
|
||||
/// spacing, place <c>max(1, trunc(len/spacing))</c> arrows strictly inside each
|
||||
/// move, resetting per move), while <see cref="RingMoves"/> is a contour-wide
|
||||
/// arclength scheduler for measurement: distance is carried across segment
|
||||
/// boundaries, no segment is omitted, and the closing vertex of a ring is never
|
||||
/// duplicated. Alignment must use the ring policy; display zoom must never
|
||||
/// change an alignment result because alignment spacing comes from the model,
|
||||
/// not the view.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ContourSampler
|
||||
{
|
||||
/// <summary>
|
||||
/// Samples one bounded line move using the arrow display policy: no samples
|
||||
/// when the move is shorter than half the spacing or degenerate; otherwise
|
||||
/// <c>max(1, (int)(length / spacing))</c> samples strictly between the
|
||||
/// endpoints at uniform spacing. Appends to <paramref name="output"/>.
|
||||
/// </summary>
|
||||
public static void LineMoves(
|
||||
Vector start,
|
||||
Vector end,
|
||||
double spacing,
|
||||
List<ContourSample> output
|
||||
)
|
||||
{
|
||||
var dx = end.X - start.X;
|
||||
var dy = end.Y - start.Y;
|
||||
var length = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (length < spacing * 0.5)
|
||||
return;
|
||||
|
||||
var dirX = dx / length;
|
||||
var dirY = dy / length;
|
||||
var tangent = System.Math.Atan2(dirY, dirX);
|
||||
|
||||
var count = System.Math.Max(1, (int)(length / spacing));
|
||||
var step = length / (count + 1);
|
||||
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
var t = step * i;
|
||||
var pt = new Vector(start.X + dirX * t, start.Y + dirY * t);
|
||||
output.Add(new ContourSample(pt, new Vector(dirX, dirY), tangent, t));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Samples one bounded arc move using the arrow display policy. The sweep is
|
||||
/// taken in the requested rotation direction and always in (0, 2*PI], so a
|
||||
/// full circle (equal endpoints) yields a full turn. No samples when the arc
|
||||
/// is shorter than half the spacing or the radius is degenerate. Tangents
|
||||
/// follow the direction of travel: +90 degrees from the radius for CCW,
|
||||
/// -90 degrees for CW. Appends to <paramref name="output"/>.
|
||||
/// </summary>
|
||||
public static void ArcMoves(
|
||||
Vector start,
|
||||
Vector end,
|
||||
Vector center,
|
||||
RotationType rotation,
|
||||
double spacing,
|
||||
List<ContourSample> output
|
||||
)
|
||||
{
|
||||
var radius = center.DistanceTo(start);
|
||||
if (radius < Tolerance.Epsilon)
|
||||
return;
|
||||
|
||||
var startAngle = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
|
||||
var endAngle = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
|
||||
|
||||
double sweep;
|
||||
if (rotation == RotationType.CCW)
|
||||
{
|
||||
sweep = endAngle - startAngle;
|
||||
if (sweep <= 0)
|
||||
sweep += 2 * System.Math.PI;
|
||||
}
|
||||
else
|
||||
{
|
||||
sweep = startAngle - endAngle;
|
||||
if (sweep <= 0)
|
||||
sweep += 2 * System.Math.PI;
|
||||
}
|
||||
|
||||
var arcLength = radius * System.Math.Abs(sweep);
|
||||
if (arcLength < spacing * 0.5)
|
||||
return;
|
||||
|
||||
var count = System.Math.Max(1, (int)(arcLength / spacing));
|
||||
var stepAngle = sweep / (count + 1);
|
||||
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
double angle;
|
||||
if (rotation == RotationType.CCW)
|
||||
angle = startAngle + stepAngle * i;
|
||||
else
|
||||
angle = startAngle - stepAngle * i;
|
||||
|
||||
var pt = new Vector(
|
||||
center.X + radius * System.Math.Cos(angle),
|
||||
center.Y + radius * System.Math.Sin(angle)
|
||||
);
|
||||
|
||||
double tangent;
|
||||
if (rotation == RotationType.CCW)
|
||||
tangent = angle + System.Math.PI / 2;
|
||||
else
|
||||
tangent = angle - System.Math.PI / 2;
|
||||
|
||||
var dir = new Vector(System.Math.Cos(tangent), System.Math.Sin(tangent));
|
||||
var at = radius * System.Math.Abs(stepAngle * i);
|
||||
output.Add(new ContourSample(pt, dir, tangent, at));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks a CNC program in world space with the same traversal policy the cut
|
||||
/// direction renderer has always used: absolute endpoints are relative to
|
||||
/// <paramref name="basePos"/>, incremental endpoints and arc centers are
|
||||
/// relative to the current position, suppressed moves and rapids advance the
|
||||
/// pen but produce no samples, and each sub-program call executes at
|
||||
/// <c>basePos + Offset</c> against a shared program (callers own the shared
|
||||
/// program; this method only reads it). Suppressed sub-program content is
|
||||
/// filtered inside the sub-program itself.
|
||||
/// </summary>
|
||||
/// <returns>The pen position after the program, so callers keep the
|
||||
/// reference semantics of the renderer's by-ref position.</returns>
|
||||
public static Vector ProgramMoves(
|
||||
Program pgm,
|
||||
Vector basePos,
|
||||
Vector pos,
|
||||
double spacing,
|
||||
List<ContourSample> output
|
||||
)
|
||||
{
|
||||
var at = 0.0;
|
||||
WalkProgram(pgm, basePos, ref pos, spacing, output, ref at);
|
||||
return pos;
|
||||
}
|
||||
|
||||
private static void WalkProgram(
|
||||
Program pgm,
|
||||
Vector basePos,
|
||||
ref Vector pos,
|
||||
double spacing,
|
||||
List<ContourSample> output,
|
||||
ref double at
|
||||
)
|
||||
{
|
||||
for (var i = 0; i < pgm.Length; ++i)
|
||||
{
|
||||
var code = pgm[i];
|
||||
|
||||
if (code.Type == CodeType.SubProgramCall)
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
if (subpgm.Program != null)
|
||||
{
|
||||
var holeBase = basePos + subpgm.Offset;
|
||||
pos = holeBase;
|
||||
WalkProgram(
|
||||
subpgm.Program,
|
||||
holeBase,
|
||||
ref pos,
|
||||
spacing,
|
||||
output,
|
||||
ref at
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code is not Motion motion)
|
||||
continue;
|
||||
|
||||
var endpt =
|
||||
pgm.Mode == Mode.Incremental
|
||||
? motion.EndPoint + pos
|
||||
: motion.EndPoint + basePos;
|
||||
|
||||
if (code.Type == CodeType.LinearMove)
|
||||
{
|
||||
var line = (LinearMove)code;
|
||||
if (!line.Suppressed)
|
||||
{
|
||||
var before = output.Count;
|
||||
LineMoves(pos, endpt, spacing, output);
|
||||
Relocate(output, before, at);
|
||||
at += Distance(pos, endpt);
|
||||
}
|
||||
}
|
||||
else if (code.Type == CodeType.ArcMove)
|
||||
{
|
||||
var arc = (ArcMove)code;
|
||||
if (!arc.Suppressed)
|
||||
{
|
||||
var center =
|
||||
pgm.Mode == Mode.Incremental
|
||||
? arc.CenterPoint + pos
|
||||
: arc.CenterPoint + basePos;
|
||||
var before = output.Count;
|
||||
ArcMoves(pos, endpt, center, arc.Rotation, spacing, output);
|
||||
Relocate(output, before, at);
|
||||
at += ArcDistance(pos, endpt, center, arc.Rotation);
|
||||
}
|
||||
}
|
||||
|
||||
pos = endpt;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resamples a closed ring at near-uniform arclength for measurement. The
|
||||
/// distance counter is carried across segment boundaries, no segment is
|
||||
/// omitted, and the closing vertex is not duplicated: samples sit at
|
||||
/// arclength <c>i * step</c> for <c>i in [0, count)</c> where
|
||||
/// <c>step = perimeter / count</c> divides the perimeter exactly, so the
|
||||
/// sample set is invariant to where the ring's start vertex sits as long as
|
||||
/// the caller quantizes consistently. A duplicated explicit closing vertex
|
||||
/// is accepted and ignored.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The ring has fewer than three distinct vertices, nonfinite coordinates,
|
||||
/// or a zero perimeter.
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="spacing"/> is not finite or not positive.</exception>
|
||||
public static void RingMoves(IList<Vector> ring, double spacing, List<ContourSample> output)
|
||||
{
|
||||
if (ring == null)
|
||||
throw new ArgumentNullException(nameof(ring));
|
||||
if (!(spacing > 0) || double.IsInfinity(spacing) || double.IsNaN(spacing))
|
||||
throw new ArgumentOutOfRangeException(nameof(spacing));
|
||||
|
||||
var n = ring.Count;
|
||||
if (n > 1 && ring[0] == ring[n - 1])
|
||||
n--; // ignore an explicit closing vertex; the ring closes implicitly
|
||||
if (n < 3)
|
||||
throw new ArgumentException("Ring needs at least 3 distinct vertices.", nameof(ring));
|
||||
|
||||
var perimeter = 0.0;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var a = ring[i];
|
||||
var b = ring[(i + 1) % n];
|
||||
if (double.IsNaN(a.X) || double.IsNaN(a.Y) || double.IsNaN(b.X) || double.IsNaN(b.Y))
|
||||
throw new ArgumentException("Ring contains nonfinite coordinates.", nameof(ring));
|
||||
perimeter += Distance(a, b);
|
||||
}
|
||||
if (!(perimeter > Tolerance.Epsilon))
|
||||
throw new ArgumentException("Ring has zero perimeter.", nameof(ring));
|
||||
|
||||
var count = System.Math.Max(1, (int)System.Math.Round(perimeter / spacing));
|
||||
var step = perimeter / count;
|
||||
|
||||
var seg = 0;
|
||||
var segStart = 0.0; // cumulative arclength at the start of segment seg
|
||||
for (var k = 0; k < count; k++)
|
||||
{
|
||||
var s = step * k;
|
||||
|
||||
// Carry the walk across segment boundaries; short segments advance
|
||||
// the arclength counter without ever being skipped.
|
||||
var a = ring[seg];
|
||||
var b = ring[(seg + 1) % n];
|
||||
var segLen = Distance(a, b);
|
||||
while (s > segStart + segLen && seg + 1 < n)
|
||||
{
|
||||
segStart += segLen;
|
||||
seg++;
|
||||
a = ring[seg];
|
||||
b = ring[(seg + 1) % n];
|
||||
segLen = Distance(a, b);
|
||||
}
|
||||
|
||||
var local = segLen > 0 ? (s - segStart) / segLen : 0.0;
|
||||
var dir = SegmentDirection(a, b);
|
||||
var pt = new Vector(a.X + (b.X - a.X) * local, a.Y + (b.Y - a.Y) * local);
|
||||
output.Add(new ContourSample(pt, dir, System.Math.Atan2(dir.Y, dir.X), s));
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector SegmentDirection(Vector a, Vector b)
|
||||
{
|
||||
var dx = b.X - a.X;
|
||||
var dy = b.Y - a.Y;
|
||||
var len = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
return len > 0 ? new Vector(dx / len, dy / len) : new Vector(1, 0);
|
||||
}
|
||||
|
||||
private static double Distance(Vector a, Vector b)
|
||||
{
|
||||
var dx = b.X - a.X;
|
||||
var dy = b.Y - a.Y;
|
||||
return System.Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
private static double ArcDistance(
|
||||
Vector start,
|
||||
Vector end,
|
||||
Vector center,
|
||||
RotationType rotation
|
||||
)
|
||||
{
|
||||
var radius = center.DistanceTo(start);
|
||||
if (radius < Tolerance.Epsilon)
|
||||
return 0.0;
|
||||
|
||||
// Same sweep convention as ArcMoves: always in (0, 2*PI], so a full
|
||||
// circle counts its whole circumference toward the walk's arclength.
|
||||
var startAngle = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
|
||||
var endAngle = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
|
||||
var sweep =
|
||||
rotation == RotationType.CCW ? endAngle - startAngle : startAngle - endAngle;
|
||||
if (sweep <= 0)
|
||||
sweep += 2 * System.Math.PI;
|
||||
return radius * sweep;
|
||||
}
|
||||
|
||||
private static void Relocate(List<ContourSample> output, int from, double baseAt)
|
||||
{
|
||||
if (baseAt == 0.0)
|
||||
return;
|
||||
for (var i = from; i < output.Count; i++)
|
||||
{
|
||||
var s = output[i];
|
||||
output[i] = new ContourSample(
|
||||
s.Position,
|
||||
s.Direction,
|
||||
s.Tangent,
|
||||
baseAt + s.At
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,14 +148,12 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
private static double SignedArea(List<Vector> verts)
|
||||
{
|
||||
// World-coordinate products can erase the sign of a small polygon's area
|
||||
// far from the origin, leaving CW outlines/holes untriangulated. Measure
|
||||
// relative to a vertex, just as the clipping kernel measures its fragments.
|
||||
var area = 0.0;
|
||||
|
||||
for (var i = 0; i < verts.Count; i++)
|
||||
{
|
||||
var j = (i + 1) % verts.Count;
|
||||
area += verts[i].X * verts[j].Y;
|
||||
area -= verts[j].X * verts[i].Y;
|
||||
}
|
||||
for (var i = 1; i + 1 < verts.Count; i++)
|
||||
area += Cross(verts[0], verts[i], verts[i + 1]);
|
||||
|
||||
return area * 0.5;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -108,8 +108,18 @@ namespace OpenNest.Geometry
|
||||
nameof(tolerance),
|
||||
"Tolerance must be positive."
|
||||
);
|
||||
if (semiMajor <= 0 || semiMinor <= 0)
|
||||
throw new ArgumentOutOfRangeException("Semi-axis lengths must be positive.");
|
||||
if (semiMajor <= 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(semiMajor),
|
||||
semiMajor,
|
||||
"Semi-major axis length must be positive."
|
||||
);
|
||||
if (semiMinor <= 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(semiMinor),
|
||||
semiMinor,
|
||||
"Semi-minor axis length must be positive."
|
||||
);
|
||||
|
||||
if (endParam <= startParam)
|
||||
endParam += Angle.TwoPI;
|
||||
@@ -320,7 +330,7 @@ namespace OpenNest.Geometry
|
||||
var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X);
|
||||
|
||||
var points = new List<Vector> { p0, pMid, p1 };
|
||||
var isReversed = SumSignedAngles(arcCenter, points) < 0;
|
||||
var isReversed = ArcFit.SumSignedAngles(arcCenter, points) < 0;
|
||||
|
||||
if (startAngle < 0)
|
||||
startAngle += Angle.TwoPI;
|
||||
@@ -329,22 +339,5 @@ namespace OpenNest.Geometry
|
||||
|
||||
return new Arc(arcCenter, radius, startAngle, endAngle, isReversed);
|
||||
}
|
||||
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI)
|
||||
da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI)
|
||||
da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,32 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Threading;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public abstract class Entity : IBoundable
|
||||
{
|
||||
private static long idCounter;
|
||||
private static readonly Guid idSalt = Guid.NewGuid();
|
||||
|
||||
protected Box boundingBox;
|
||||
|
||||
protected Entity()
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
// Retain a process-random prefix; allocate the final six bytes atomically.
|
||||
// IDs are opaque persistence keys, not unpredictable security tokens.
|
||||
var counter = Interlocked.Increment(ref idCounter);
|
||||
if ((ulong)counter > 0xFFFFFFFFFFFFUL)
|
||||
throw new InvalidOperationException("Entity identifier sequence exhausted.");
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
idSalt.TryWriteBytes(bytes);
|
||||
Span<byte> counterBytes = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(counterBytes, counter);
|
||||
counterBytes[..6].CopyTo(bytes[10..]);
|
||||
Id = new Guid(bytes);
|
||||
Layer = OpenNest.Geometry.Layer.Default;
|
||||
boundingBox = new Box();
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@ public class GeometrySimplifier
|
||||
// Reject arcs that subtend a tiny angle — these are nearly-straight lines
|
||||
// that happen to fit a huge circle. Applied after extension so that many small
|
||||
// segments can accumulate enough sweep to qualify.
|
||||
var sweep = System.Math.Abs(SumSignedAngles(center, points));
|
||||
var sweep = System.Math.Abs(ArcFit.SumSignedAngles(center, points));
|
||||
if (sweep < Angle.ToRadians(5))
|
||||
return null;
|
||||
|
||||
@@ -454,7 +454,7 @@ public class GeometrySimplifier
|
||||
continue;
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var isReversed = ArcFit.SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
continue;
|
||||
@@ -673,7 +673,7 @@ public class GeometrySimplifier
|
||||
var lastPt = points[^1];
|
||||
var rx = lastPt.X - center.X;
|
||||
var ry = lastPt.Y - center.Y;
|
||||
var sign = SumSignedAngles(center, points) >= 0 ? 1 : -1;
|
||||
var sign = ArcFit.SumSignedAngles(center, points) >= 0 ? 1 : -1;
|
||||
return new Vector(-sign * ry, sign * rx);
|
||||
}
|
||||
|
||||
@@ -792,7 +792,7 @@ public class GeometrySimplifier
|
||||
var endAngle = NormalizeAngle(
|
||||
System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X)
|
||||
);
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var isReversed = ArcFit.SumSignedAngles(center, points) < 0;
|
||||
|
||||
var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
|
||||
arc.Layer = sourceEntity.Layer;
|
||||
@@ -816,27 +816,6 @@ public class GeometrySimplifier
|
||||
_ => Vector.Invalid,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Sums signed angular change traversing consecutive points around a center.
|
||||
/// Positive = CCW, negative = CW.
|
||||
/// </summary>
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI)
|
||||
da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI)
|
||||
da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures the maximum distance from sampled points along the fitted arc
|
||||
/// back to the original line segments. This catches cases where points lie
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Clipper2Lib;
|
||||
|
||||
namespace OpenNest.Geometry;
|
||||
|
||||
/// <summary>
|
||||
/// Maximal empty axis-aligned rectangles: rectangles of free space that cannot grow in any
|
||||
/// direction. The first result is the largest by area.
|
||||
/// </summary>
|
||||
public static class MaximalRectangles
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds maximal axis-aligned rectangles that lie wholly inside a region, such as a cutout
|
||||
/// already shrunk by the part spacing. Rectangles may touch the region's boundary but never
|
||||
/// cross it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The grid has a line through every vertex coordinate plus <paramref name="divisions"/>
|
||||
/// evenly spaced lines per axis. A cell is free only when no edge passes through its
|
||||
/// interior and its centre is inside the region, so results are exact for regions whose
|
||||
/// edges are all horizontal or vertical. Slanted and curved edges are followed as a
|
||||
/// staircase: results stay inside, but can fall short of the true maximum by up to about
|
||||
/// one cell on each side. Rotate the region to search other rectangle angles.
|
||||
/// </remarks>
|
||||
/// <param name="region">Closed, non-crossing paths, as returned by a Clipper Boolean or offset.
|
||||
/// A point is inside when an odd number of paths enclose it, so holes are subtracted.</param>
|
||||
/// <param name="minDimension">Rectangles narrower than this in either axis are dropped.</param>
|
||||
/// <param name="divisions">Even subdivisions of the region's bounds per axis, which bound the
|
||||
/// staircase loss along slanted edges.</param>
|
||||
/// <returns>Rectangles not contained in another result, largest area first.</returns>
|
||||
public static List<Box> InRegion(PathsD region, double minDimension = 0, int divisions = 64)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(region);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(divisions, 1);
|
||||
|
||||
var paths = region.Where(path => path.Count >= 3).ToList();
|
||||
if (paths.Count == 0)
|
||||
return new List<Box>();
|
||||
if (paths.Any(path => path.Any(point => !double.IsFinite(point.x) || !double.IsFinite(point.y))))
|
||||
throw new ArgumentException("Region coordinates must be finite.", nameof(region));
|
||||
|
||||
var bounds = Clipper.GetBounds(new PathsD(paths));
|
||||
var xs = GridLines(paths.SelectMany(path => path).Select(point => point.x), bounds.left, bounds.right, divisions);
|
||||
var ys = GridLines(paths.SelectMany(path => path).Select(point => point.y), bounds.top, bounds.bottom, divisions);
|
||||
if (xs.Count < 2 || ys.Count < 2)
|
||||
return new List<Box>();
|
||||
|
||||
var rows = ys.Count - 1;
|
||||
var cols = xs.Count - 1;
|
||||
var crossed = new bool[rows, cols];
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var previous = path[^1];
|
||||
foreach (var current in path)
|
||||
{
|
||||
MarkCrossedCells(previous, current, xs, ys, crossed);
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
|
||||
var empty = new bool[rows, cols];
|
||||
var crossings = new List<double>();
|
||||
for (var r = 0; r < rows; r++)
|
||||
{
|
||||
// Even-odd scan along the row's centre line. No vertex lies on it, and an edge that
|
||||
// meets it strictly inside a cell has already marked that cell crossed, so each
|
||||
// uncrossed cell is on the same side as its centre.
|
||||
var y = (ys[r] + ys[r + 1]) / 2;
|
||||
crossings.Clear();
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var previous = path[^1];
|
||||
foreach (var current in path)
|
||||
{
|
||||
if ((previous.y > y) != (current.y > y))
|
||||
crossings.Add(previous.x + (y - previous.y) * (current.x - previous.x) / (current.y - previous.y));
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
crossings.Sort();
|
||||
|
||||
var passed = 0;
|
||||
for (var c = 0; c < cols; c++)
|
||||
{
|
||||
var x = (xs[c] + xs[c + 1]) / 2;
|
||||
while (passed < crossings.Count && crossings[passed] < x)
|
||||
passed++;
|
||||
empty[r, c] = !crossed[r, c] && passed % 2 == 1;
|
||||
}
|
||||
}
|
||||
|
||||
return FromGrid(xs, ys, empty, minDimension);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the maximal rectangles of empty cells in a rectilinear grid, using the histogram
|
||||
/// method: for each row, a height histogram of consecutive empty cells below it, scanned
|
||||
/// with a stack.
|
||||
/// </summary>
|
||||
/// <param name="xs">Ascending column boundaries; column c spans xs[c] to xs[c + 1].</param>
|
||||
/// <param name="ys">Ascending row boundaries; row r spans ys[r] to ys[r + 1].</param>
|
||||
/// <param name="empty">Free cells, indexed [row, column].</param>
|
||||
/// <param name="minDimension">Rectangles narrower than this in either axis are dropped.</param>
|
||||
/// <returns>Rectangles not contained in another result, largest area first.</returns>
|
||||
public static List<Box> FromGrid(
|
||||
IReadOnlyList<double> xs,
|
||||
IReadOnlyList<double> ys,
|
||||
bool[,] empty,
|
||||
double minDimension = 0
|
||||
)
|
||||
{
|
||||
var merged = MergeCells(xs, ys, empty);
|
||||
var sized = FilterBySize(merged, minDimension);
|
||||
return RemoveDominated(sized);
|
||||
}
|
||||
|
||||
private static List<double> GridLines(IEnumerable<double> vertices, double min, double max, int divisions)
|
||||
{
|
||||
var lines = new SortedSet<double>(vertices);
|
||||
var exact = lines.ToList();
|
||||
var step = (max - min) / divisions;
|
||||
for (var i = 1; i < divisions; i++)
|
||||
{
|
||||
// Skip even lines that would only cut a sliver off a vertex line.
|
||||
var line = min + i * step;
|
||||
var index = exact.BinarySearch(line);
|
||||
if (index >= 0)
|
||||
continue;
|
||||
index = ~index;
|
||||
var near = (index > 0 && line - exact[index - 1] < Math.Tolerance.Epsilon)
|
||||
|| (index < exact.Count && exact[index] - line < Math.Tolerance.Epsilon);
|
||||
if (!near)
|
||||
lines.Add(line);
|
||||
}
|
||||
return lines.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Marks every cell whose open interior a slanted edge passes through.</summary>
|
||||
private static void MarkCrossedCells(PointD a, PointD b, List<double> xs, List<double> ys, bool[,] crossed)
|
||||
{
|
||||
// Edges along a grid line touch cells without entering them; vertex coordinates
|
||||
// are grid lines, so every horizontal or vertical edge lies on one.
|
||||
if (a.x == b.x || a.y == b.y)
|
||||
return;
|
||||
|
||||
var c0 = xs.BinarySearch(System.Math.Min(a.x, b.x));
|
||||
var c1 = xs.BinarySearch(System.Math.Max(a.x, b.x));
|
||||
var r0 = ys.BinarySearch(System.Math.Min(a.y, b.y));
|
||||
var r1 = ys.BinarySearch(System.Math.Max(a.y, b.y));
|
||||
for (var r = r0; r < r1; r++)
|
||||
{
|
||||
for (var c = c0; c < c1; c++)
|
||||
{
|
||||
if (!crossed[r, c] && EntersInterior(a, b, xs[c], ys[r], xs[c + 1], ys[r + 1]))
|
||||
crossed[r, c] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clips the segment to the closed cell (Liang-Barsky). A segment that enters the open
|
||||
/// interior has the midpoint of its clipped piece strictly inside; one that only touches
|
||||
/// a side or corner does not.
|
||||
/// </summary>
|
||||
private static bool EntersInterior(PointD a, PointD b, double left, double bottom, double right, double top)
|
||||
{
|
||||
var dx = b.x - a.x;
|
||||
var dy = b.y - a.y;
|
||||
var t0 = 0.0;
|
||||
var t1 = 1.0;
|
||||
if (
|
||||
!Clip(-dx, a.x - left, ref t0, ref t1)
|
||||
|| !Clip(dx, right - a.x, ref t0, ref t1)
|
||||
|| !Clip(-dy, a.y - bottom, ref t0, ref t1)
|
||||
|| !Clip(dy, top - a.y, ref t0, ref t1)
|
||||
)
|
||||
return false;
|
||||
|
||||
var t = (t0 + t1) / 2;
|
||||
var x = a.x + t * dx;
|
||||
var y = a.y + t * dy;
|
||||
return x > left && x < right && y > bottom && y < top;
|
||||
}
|
||||
|
||||
private static bool Clip(double p, double q, ref double t0, ref double t1)
|
||||
{
|
||||
if (p == 0)
|
||||
return q >= 0;
|
||||
var ratio = q / p;
|
||||
if (p < 0)
|
||||
{
|
||||
if (ratio > t1)
|
||||
return false;
|
||||
if (ratio > t0)
|
||||
t0 = ratio;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ratio < t0)
|
||||
return false;
|
||||
if (ratio < t1)
|
||||
t1 = ratio;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<Box> MergeCells(IReadOnlyList<double> xs, IReadOnlyList<double> ys, bool[,] empty)
|
||||
{
|
||||
var rows = empty.GetLength(0);
|
||||
var cols = empty.GetLength(1);
|
||||
var height = new int[rows, cols];
|
||||
|
||||
for (var c = 0; c < cols; c++)
|
||||
{
|
||||
for (var r = 0; r < rows; r++)
|
||||
height[r, c] = empty[r, c] ? (r > 0 ? height[r - 1, c] + 1 : 1) : 0;
|
||||
}
|
||||
|
||||
var candidates = new List<Box>();
|
||||
|
||||
for (var r = 0; r < rows; r++)
|
||||
{
|
||||
var stack = new Stack<(int startCol, int h)>();
|
||||
|
||||
for (var c = 0; c <= cols; c++)
|
||||
{
|
||||
var h = c < cols ? height[r, c] : 0;
|
||||
var startCol = c;
|
||||
|
||||
while (stack.Count > 0 && stack.Peek().h > h)
|
||||
{
|
||||
var top = stack.Pop();
|
||||
startCol = top.startCol;
|
||||
|
||||
candidates.Add(
|
||||
new Box(
|
||||
xs[top.startCol],
|
||||
ys[r - top.h + 1],
|
||||
xs[c] - xs[top.startCol],
|
||||
ys[r + 1] - ys[r - top.h + 1]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (h > 0)
|
||||
stack.Push((startCol, h));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static List<Box> FilterBySize(List<Box> boxes, double minDimension)
|
||||
{
|
||||
if (minDimension <= 0)
|
||||
return boxes;
|
||||
|
||||
var result = new List<Box>();
|
||||
|
||||
foreach (var box in boxes)
|
||||
{
|
||||
if (box.Width >= minDimension && box.Length >= minDimension)
|
||||
result.Add(box);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Box> RemoveDominated(List<Box> boxes)
|
||||
{
|
||||
boxes.Sort((a, b) => b.Area().CompareTo(a.Area()));
|
||||
var results = new List<Box>();
|
||||
|
||||
foreach (var box in boxes)
|
||||
{
|
||||
var dominated = false;
|
||||
|
||||
foreach (var larger in results)
|
||||
{
|
||||
if (IsContainedIn(box, larger))
|
||||
{
|
||||
dominated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dominated)
|
||||
results.Add(box);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static bool IsContainedIn(Box inner, Box outer)
|
||||
{
|
||||
var eps = Math.Tolerance.Epsilon;
|
||||
return inner.Left >= outer.Left - eps
|
||||
&& inner.Right <= outer.Right + eps
|
||||
&& inner.Bottom >= outer.Bottom - eps
|
||||
&& inner.Top <= outer.Top + eps;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry;
|
||||
|
||||
/// <summary>
|
||||
/// Positive area and area centroid of a simple polygon, independent of winding.
|
||||
/// Moments are evaluated about a nearby origin rather than the world origin.
|
||||
/// </summary>
|
||||
public readonly struct PolygonAreaMoments
|
||||
{
|
||||
private PolygonAreaMoments(double area, Vector centroid)
|
||||
{
|
||||
Area = area;
|
||||
Centroid = centroid;
|
||||
}
|
||||
|
||||
public double Area { get; }
|
||||
public Vector Centroid { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Accepts an open vertex list or an exactly repeated closing vertex. Returns false
|
||||
/// for degenerate or nonfinite moments; does not validate polygon topology.
|
||||
/// </summary>
|
||||
public static bool TryCompute(IReadOnlyList<Vector> vertices, out PolygonAreaMoments moments)
|
||||
{
|
||||
moments = default;
|
||||
if (vertices == null || vertices.Count < 3)
|
||||
return false;
|
||||
foreach (var point in vertices)
|
||||
if (!IsFinite(point))
|
||||
return false;
|
||||
|
||||
var origin = vertices[0];
|
||||
var count = vertices.Count;
|
||||
if (vertices[count - 1].X == origin.X && vertices[count - 1].Y == origin.Y)
|
||||
count--;
|
||||
if (count < 3)
|
||||
return false;
|
||||
|
||||
var twiceArea = 0.0;
|
||||
var momentX = 0.0;
|
||||
var momentY = 0.0;
|
||||
// The closing edges meet the local origin and contribute zero. The signed
|
||||
// triangle fan also handles concavity without averaging polygon vertices.
|
||||
for (var i = 1; i + 1 < count; i++)
|
||||
{
|
||||
var a = vertices[i] - origin;
|
||||
var b = vertices[i + 1] - origin;
|
||||
var cross = a.X * b.Y - a.Y * b.X;
|
||||
twiceArea += cross;
|
||||
momentX += (a.X + b.X) * cross;
|
||||
momentY += (a.Y + b.Y) * cross;
|
||||
}
|
||||
|
||||
var area = System.Math.Abs(twiceArea) * 0.5;
|
||||
if (!double.IsFinite(area) || area <= 0
|
||||
|| !double.IsFinite(momentX) || !double.IsFinite(momentY))
|
||||
return false;
|
||||
// Dividing signed moments by signed area cancels the winding, while Area
|
||||
// stays positive so independently wound fragments always add material.
|
||||
var centroid = origin + new Vector(momentX / twiceArea / 3, momentY / twiceArea / 3);
|
||||
if (!IsFinite(centroid))
|
||||
return false;
|
||||
moments = new PolygonAreaMoments(area, centroid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines nonoverlapping, already hole-subtracted fragments using positive area
|
||||
/// weights and another local origin. The centroid may lie outside the material.
|
||||
/// Empty input or any invalid fragment fails the entire result.
|
||||
/// </summary>
|
||||
public static bool TryCombine(IEnumerable<PolygonAreaMoments> fragments, out PolygonAreaMoments moments)
|
||||
{
|
||||
moments = default;
|
||||
if (fragments == null)
|
||||
return false;
|
||||
var area = 0.0;
|
||||
var origin = Vector.Zero;
|
||||
var firstMoment = Vector.Zero;
|
||||
foreach (var fragment in fragments)
|
||||
{
|
||||
if (!double.IsFinite(fragment.Area) || fragment.Area <= 0 || !IsFinite(fragment.Centroid))
|
||||
return false;
|
||||
if (area == 0)
|
||||
origin = fragment.Centroid;
|
||||
firstMoment += (fragment.Centroid - origin) * fragment.Area;
|
||||
area += fragment.Area;
|
||||
}
|
||||
|
||||
if (!double.IsFinite(area) || area <= 0 || !IsFinite(firstMoment))
|
||||
return false;
|
||||
var centroid = origin + firstMoment / area;
|
||||
if (!IsFinite(centroid))
|
||||
return false;
|
||||
moments = new PolygonAreaMoments(area, centroid);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
internal enum ContactSide
|
||||
{
|
||||
/// <summary>The boundary could not be decomposed into closed loops.</summary>
|
||||
Unresolved,
|
||||
|
||||
/// <summary>The point is not on the boundary: a tolerance near-miss, not a contact.</summary>
|
||||
Off,
|
||||
|
||||
/// <summary>Several boundary runs meet here, or the corner is a cusp or spike.</summary>
|
||||
Ambiguous,
|
||||
|
||||
/// <summary>The material sector is known.</summary>
|
||||
Sector,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closed boundary loops of one entity list, prepared so a directional slide can tell
|
||||
/// which side of each boundary point is material. Immutable after
|
||||
/// <see cref="Prepare"/>, so one instance may be shared by concurrent queries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Loops are recovered from contiguous runs whose end points chain back to their start
|
||||
/// (the order produced by <see cref="ShapeBuilder"/> and the offset helpers). Nesting
|
||||
/// depth decides holes: material is inside even-depth loops and outside odd-depth ones.
|
||||
/// When the list cannot be decomposed that way, every contact query is unresolved.
|
||||
/// </remarks>
|
||||
public sealed class SlideContactGeometry
|
||||
{
|
||||
// Contact points are computed from unsnapped ray parameters, so a genuine contact is
|
||||
// on both boundaries to floating-point accuracy. This also bounds the overlap sliver a
|
||||
// tangential classification can admit, so keep it far below spacing tolerances.
|
||||
internal const double IncidenceTolerance = 1e-7;
|
||||
|
||||
private readonly List<Entity> entities;
|
||||
private readonly int[] loopOf;
|
||||
private readonly int[] previous;
|
||||
private readonly int[] following;
|
||||
private readonly bool[] materialLeft;
|
||||
|
||||
private SlideContactGeometry(
|
||||
List<Entity> entities,
|
||||
int[] loopOf,
|
||||
int[] previous,
|
||||
int[] following,
|
||||
bool[] materialLeft
|
||||
)
|
||||
{
|
||||
this.entities = entities;
|
||||
this.loopOf = loopOf;
|
||||
this.previous = previous;
|
||||
this.following = following;
|
||||
this.materialLeft = materialLeft;
|
||||
}
|
||||
|
||||
/// <summary>True when every entity belongs to a closed loop with a known material side.</summary>
|
||||
public bool IsResolved => materialLeft != null;
|
||||
|
||||
public static SlideContactGeometry Prepare(List<Entity> entities)
|
||||
{
|
||||
var count = entities.Count;
|
||||
var loopOf = new int[count];
|
||||
var previous = new int[count];
|
||||
var following = new int[count];
|
||||
var loops = new List<(int First, int Last)>();
|
||||
|
||||
var i = 0;
|
||||
while (i < count)
|
||||
{
|
||||
var first = i;
|
||||
if (entities[i] is Circle)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TryEndpoints(entities[i], out var start, out _))
|
||||
return Unresolved(entities);
|
||||
|
||||
var closed = false;
|
||||
while (i < count && TryEndpoints(entities[i], out _, out var end))
|
||||
{
|
||||
// A lone closed arc is a loop; a lone line cannot be, even when it
|
||||
// has zero length and so ends where it starts.
|
||||
if (Near(end, start) && (i > first || entities[i] is Arc))
|
||||
{
|
||||
closed = true;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
i + 1 >= count
|
||||
|| !TryEndpoints(entities[i + 1], out var nextStart, out _)
|
||||
|| !Near(nextStart, end)
|
||||
)
|
||||
break;
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (!closed)
|
||||
return Unresolved(entities);
|
||||
}
|
||||
|
||||
var loop = loops.Count;
|
||||
loops.Add((first, i - 1));
|
||||
for (var k = first; k < i; k++)
|
||||
{
|
||||
loopOf[k] = loop;
|
||||
previous[k] = k == first ? i - 1 : k - 1;
|
||||
following[k] = k == i - 1 ? first : k + 1;
|
||||
}
|
||||
}
|
||||
|
||||
var materialLeft = new bool[loops.Count];
|
||||
|
||||
for (var loop = 0; loop < loops.Count; loop++)
|
||||
{
|
||||
var area = SignedArea(entities, loops[loop].First, loops[loop].Last);
|
||||
if (System.Math.Abs(area) <= Tolerance.Epsilon)
|
||||
return Unresolved(entities);
|
||||
|
||||
var depth = 0;
|
||||
if (loops.Count > 1)
|
||||
{
|
||||
var sample = SamplePoint(entities[loops[loop].First]);
|
||||
for (var other = 0; other < loops.Count; other++)
|
||||
{
|
||||
if (other == loop)
|
||||
continue;
|
||||
if (Contains(entities, loops[other].First, loops[other].Last, sample))
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
materialLeft[loop] = (area > 0) == (depth % 2 == 0);
|
||||
}
|
||||
|
||||
return new SlideContactGeometry(entities, loopOf, previous, following, materialLeft);
|
||||
}
|
||||
|
||||
private static SlideContactGeometry Unresolved(List<Entity> entities) =>
|
||||
new SlideContactGeometry(entities, null, null, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Material directions at a boundary point: an angular sector starting at
|
||||
/// <paramref name="start"/> and sweeping CCW by <paramref name="width"/>.
|
||||
/// Concavity is recorded separately at each sector ray: only the supporting
|
||||
/// curve, not an unrelated curve at that corner, can block a tangential slide.
|
||||
/// Entities wholly inside the incidence tolerance are treated as part of the corner.
|
||||
/// </summary>
|
||||
internal ContactSide GetMaterialSector(
|
||||
Vector point,
|
||||
out double start,
|
||||
out double width,
|
||||
out bool startConcave,
|
||||
out bool endConcave
|
||||
)
|
||||
{
|
||||
start = width = 0;
|
||||
startConcave = endConcave = false;
|
||||
if (materialLeft == null)
|
||||
return ContactSide.Unresolved;
|
||||
|
||||
var best = -1;
|
||||
var bestDistance = double.MaxValue;
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
var distance = DistanceTo(entities[i], point);
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (best < 0 || bestDistance > IncidenceTolerance)
|
||||
return ContactSide.Off;
|
||||
|
||||
// Walk to the entities that enter and leave the tolerance disc.
|
||||
var loopLength = LoopLength(best);
|
||||
var incoming = best;
|
||||
var steps = 0;
|
||||
var smoothLoop = loopLength == 1 && (entities[best] is Circle
|
||||
|| entities[best] is Arc fullArc && fullArc.IsFullCircle());
|
||||
while (!smoothLoop && StartsNear(incoming, point))
|
||||
{
|
||||
incoming = previous[incoming];
|
||||
if (++steps >= loopLength)
|
||||
return ContactSide.Ambiguous;
|
||||
}
|
||||
|
||||
var outgoing = best;
|
||||
steps = 0;
|
||||
while (!smoothLoop && EndsNear(outgoing, point))
|
||||
{
|
||||
outgoing = following[outgoing];
|
||||
if (++steps >= loopLength)
|
||||
return ContactSide.Ambiguous;
|
||||
}
|
||||
|
||||
// Anything else touching this point (another loop, a spike, a self-crossing)
|
||||
// makes the local material side ambiguous.
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
if (InRun(i, incoming, outgoing))
|
||||
continue;
|
||||
if (DistanceTo(entities[i], point) <= IncidenceTolerance)
|
||||
return ContactSide.Ambiguous;
|
||||
}
|
||||
|
||||
var interior = incoming == best && outgoing == best && !EndsNear(best, point);
|
||||
var inTangent = interior ? TangentAt(entities[best], point) : EndTangent(entities[incoming]);
|
||||
var outTangent = interior
|
||||
? inTangent
|
||||
: StartTangent(entities[outgoing]);
|
||||
|
||||
// A circle has no endpoints, so its point is always interior.
|
||||
if (smoothLoop)
|
||||
inTangent = outTangent = TangentAt(entities[best], point);
|
||||
|
||||
if (IsZero(inTangent) || IsZero(outTangent))
|
||||
return ContactSide.Ambiguous;
|
||||
|
||||
var outAngle = System.Math.Atan2(outTangent.Y, outTangent.X);
|
||||
var inAngle = System.Math.Atan2(-inTangent.Y, -inTangent.X);
|
||||
var left = materialLeft[loopOf[best]];
|
||||
|
||||
start = left ? outAngle : inAngle;
|
||||
width = Angle.NormalizeRad((left ? inAngle : outAngle) - start);
|
||||
|
||||
startConcave = IsConcave(entities[left ? outgoing : incoming], left);
|
||||
endConcave = IsConcave(entities[left ? incoming : outgoing], left);
|
||||
|
||||
return
|
||||
width > SlideContact.AngleTolerance
|
||||
&& width < Angle.TwoPI - 2 * SlideContact.SplitOverlap
|
||||
? ContactSide.Sector
|
||||
: ContactSide.Ambiguous;
|
||||
}
|
||||
|
||||
private int LoopLength(int index)
|
||||
{
|
||||
var length = 1;
|
||||
for (var i = following[index]; i != index; i = following[i])
|
||||
length++;
|
||||
return length;
|
||||
}
|
||||
|
||||
private bool StartsNear(int index, Vector point) =>
|
||||
TryEndpoints(entities[index], out var start, out _)
|
||||
&& start.DistanceTo(point) <= IncidenceTolerance;
|
||||
|
||||
private bool EndsNear(int index, Vector point) =>
|
||||
TryEndpoints(entities[index], out _, out var end)
|
||||
&& end.DistanceTo(point) <= IncidenceTolerance;
|
||||
|
||||
private bool InRun(int index, int first, int last)
|
||||
{
|
||||
for (var i = first; ; i = following[i])
|
||||
{
|
||||
if (i == index)
|
||||
return true;
|
||||
if (i == last)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsZero(Vector v) => v.X == 0 && v.Y == 0;
|
||||
|
||||
private static bool IsConcave(Entity entity, bool materialLeft)
|
||||
{
|
||||
// A CCW curve has its center on its left; that center is on the free side
|
||||
// (a concave boundary) exactly when material is on the right.
|
||||
return entity switch
|
||||
{
|
||||
Arc arc => materialLeft == arc.IsReversed,
|
||||
Circle circle => materialLeft == (circle.Rotation == RotationType.CW),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector StartTangent(Entity entity) =>
|
||||
entity switch
|
||||
{
|
||||
Line line => Direction(line.pt1, line.pt2),
|
||||
Arc arc => ArcTangent(arc.StartAngle, arc.IsReversed),
|
||||
_ => new Vector(),
|
||||
};
|
||||
|
||||
private static Vector EndTangent(Entity entity) =>
|
||||
entity switch
|
||||
{
|
||||
Line line => Direction(line.pt1, line.pt2),
|
||||
Arc arc => ArcTangent(arc.EndAngle, arc.IsReversed),
|
||||
_ => new Vector(),
|
||||
};
|
||||
|
||||
private static Vector TangentAt(Entity entity, Vector point) =>
|
||||
entity switch
|
||||
{
|
||||
Line line => Direction(line.pt1, line.pt2),
|
||||
Arc arc => ArcTangent(arc.Center.AngleTo(point), arc.IsReversed),
|
||||
Circle circle => ArcTangent(
|
||||
circle.Center.AngleTo(point),
|
||||
circle.Rotation == RotationType.CW
|
||||
),
|
||||
_ => new Vector(),
|
||||
};
|
||||
|
||||
private static Vector ArcTangent(double angle, bool clockwise)
|
||||
{
|
||||
var sign = clockwise ? -1.0 : 1.0;
|
||||
return new Vector(-System.Math.Sin(angle) * sign, System.Math.Cos(angle) * sign);
|
||||
}
|
||||
|
||||
private static Vector Direction(Vector from, Vector to)
|
||||
{
|
||||
var dx = to.X - from.X;
|
||||
var dy = to.Y - from.Y;
|
||||
var length = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
return length > 0 ? new Vector(dx / length, dy / length) : new Vector();
|
||||
}
|
||||
|
||||
private static double DistanceTo(Entity entity, Vector point)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
return point.DistanceTo(line.ClosestPointTo(point));
|
||||
case Arc arc:
|
||||
{
|
||||
var angle = arc.Center.AngleTo(point);
|
||||
if (Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||
return System.Math.Abs(arc.Center.DistanceTo(point) - arc.Radius);
|
||||
return System.Math.Min(
|
||||
point.DistanceTo(arc.StartPoint()),
|
||||
point.DistanceTo(arc.EndPoint())
|
||||
);
|
||||
}
|
||||
case Circle circle:
|
||||
return System.Math.Abs(circle.Center.DistanceTo(point) - circle.Radius);
|
||||
default:
|
||||
return double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryEndpoints(Entity entity, out Vector start, out Vector end)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
start = line.pt1;
|
||||
end = line.pt2;
|
||||
return true;
|
||||
case Arc arc:
|
||||
start = arc.StartPoint();
|
||||
end = arc.EndPoint();
|
||||
return true;
|
||||
default:
|
||||
start = end = new Vector();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance;
|
||||
|
||||
private static double SignedArea(List<Entity> entities, int first, int last)
|
||||
{
|
||||
var area = 0.0;
|
||||
for (var i = first; i <= last; i++)
|
||||
{
|
||||
switch (entities[i])
|
||||
{
|
||||
case Circle circle:
|
||||
var sign = circle.Rotation == RotationType.CW ? -1 : 1;
|
||||
area += sign * System.Math.PI * circle.Radius * circle.Radius;
|
||||
break;
|
||||
case Line line:
|
||||
area += Cross(line.pt1, line.pt2) / 2;
|
||||
break;
|
||||
case Arc arc:
|
||||
var sweep = arc.IsReversed ? -arc.SweepAngle() : arc.SweepAngle();
|
||||
var r = arc.Radius;
|
||||
area += Cross(arc.StartPoint(), arc.EndPoint()) / 2;
|
||||
area += r * r / 2 * (sweep - System.Math.Sin(sweep));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
private static double Cross(Vector a, Vector b) => a.X * b.Y - b.X * a.Y;
|
||||
|
||||
private static Vector SamplePoint(Entity entity) =>
|
||||
entity switch
|
||||
{
|
||||
Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||
Arc arc => arc.StartPoint(),
|
||||
Line line => line.pt1,
|
||||
_ => new Vector(),
|
||||
};
|
||||
|
||||
// Exact horizontal-ray parity. Split arcs at Y extrema so every piece is
|
||||
// monotone; the same half-open endpoint rule as lines avoids seam double counts.
|
||||
// A coarse inscribed polygon can misclassify thin rings as solid material.
|
||||
private static bool Contains(List<Entity> entities, int first, int last, Vector point)
|
||||
{
|
||||
var inside = false;
|
||||
for (var i = first; i <= last; i++)
|
||||
{
|
||||
if (entities[i] is Circle circle)
|
||||
return circle.Center.DistanceTo(point) < circle.Radius;
|
||||
if (entities[i] is Line line)
|
||||
{
|
||||
var a = line.pt1;
|
||||
var b = line.pt2;
|
||||
if ((a.Y > point.Y) != (b.Y > point.Y)
|
||||
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
|
||||
inside = !inside;
|
||||
}
|
||||
else if (entities[i] is Arc arc)
|
||||
{
|
||||
var sweep = arc.SweepAngle();
|
||||
var sign = arc.IsReversed ? -1.0 : 1.0;
|
||||
var cuts = new List<double> { 0, sweep };
|
||||
foreach (var extreme in new[] { Angle.HalfPI, 3 * Angle.HalfPI })
|
||||
{
|
||||
var t = Angle.NormalizeRad(sign * (extreme - arc.StartAngle));
|
||||
if (t > 0 && t < sweep)
|
||||
cuts.Add(t);
|
||||
}
|
||||
cuts.Sort();
|
||||
for (var k = 1; k < cuts.Count; k++)
|
||||
{
|
||||
var a = arc.StartAngle + sign * cuts[k - 1];
|
||||
var b = arc.StartAngle + sign * cuts[k];
|
||||
var y1 = arc.Center.Y + arc.Radius * System.Math.Sin(a);
|
||||
var y2 = arc.Center.Y + arc.Radius * System.Math.Sin(b);
|
||||
if ((y1 > point.Y) == (y2 > point.Y))
|
||||
continue;
|
||||
var dy = point.Y - arc.Center.Y;
|
||||
var dx = System.Math.Sqrt(System.Math.Max(0, arc.Radius * arc.Radius - dy * dy));
|
||||
var x = arc.Center.X + (System.Math.Cos((a + b) / 2) >= 0 ? dx : -dx);
|
||||
if (point.X < x)
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact classifier for one moving/stationary pair of boundaries. Geometry is prepared
|
||||
/// on first use, so a slide whose nearest contact is never classified pays nothing; call
|
||||
/// <see cref="Prepare"/> before sharing one instance across threads. Each boundary is
|
||||
/// given in its own frame; the origins place those frames in the world coordinates used
|
||||
/// by slide events.
|
||||
/// </summary>
|
||||
public sealed class SlideContactClassifier
|
||||
{
|
||||
private readonly System.Func<List<Entity>> movingSource;
|
||||
private readonly System.Func<List<Entity>> stationarySource;
|
||||
private SlideContactGeometry moving;
|
||||
private SlideContactGeometry stationary;
|
||||
|
||||
public SlideContactClassifier(List<Entity> movingEntities, List<Entity> stationaryEntities)
|
||||
: this(movingEntities, Vector.Zero, stationaryEntities, Vector.Zero) { }
|
||||
|
||||
public SlideContactClassifier(
|
||||
List<Entity> movingEntities,
|
||||
Vector movingOrigin,
|
||||
List<Entity> stationaryEntities,
|
||||
Vector stationaryOrigin
|
||||
)
|
||||
: this(() => movingEntities, movingOrigin, () => stationaryEntities, stationaryOrigin)
|
||||
{ }
|
||||
|
||||
public SlideContactClassifier(
|
||||
SlideContactGeometry moving,
|
||||
Vector movingOrigin,
|
||||
SlideContactGeometry stationary,
|
||||
Vector stationaryOrigin
|
||||
)
|
||||
{
|
||||
this.moving = moving;
|
||||
this.stationary = stationary;
|
||||
MovingOrigin = movingOrigin;
|
||||
StationaryOrigin = stationaryOrigin;
|
||||
}
|
||||
|
||||
private SlideContactClassifier(
|
||||
System.Func<List<Entity>> movingSource,
|
||||
Vector movingOrigin,
|
||||
System.Func<List<Entity>> stationarySource,
|
||||
Vector stationaryOrigin
|
||||
)
|
||||
{
|
||||
this.movingSource = movingSource;
|
||||
this.stationarySource = stationarySource;
|
||||
MovingOrigin = movingOrigin;
|
||||
StationaryOrigin = stationaryOrigin;
|
||||
}
|
||||
|
||||
public Vector MovingOrigin { get; }
|
||||
|
||||
public Vector StationaryOrigin { get; }
|
||||
|
||||
public static SlideContactClassifier FromLines(
|
||||
List<Line> movingLines,
|
||||
Vector movingOrigin,
|
||||
List<Line> stationaryLines,
|
||||
Vector stationaryOrigin
|
||||
) =>
|
||||
new SlideContactClassifier(
|
||||
() => new List<Entity>(movingLines),
|
||||
movingOrigin,
|
||||
() => new List<Entity>(stationaryLines),
|
||||
stationaryOrigin
|
||||
);
|
||||
|
||||
public static SlideContactClassifier FromEdges(
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
Vector movingOrigin,
|
||||
(Vector start, Vector end)[] stationaryEdges,
|
||||
Vector stationaryOrigin
|
||||
)
|
||||
{
|
||||
// The kernel sorts edge arrays in place, so snapshot the chain order now.
|
||||
var moving = ((Vector start, Vector end)[])movingEdges.Clone();
|
||||
var stationary = ((Vector start, Vector end)[])stationaryEdges.Clone();
|
||||
return new SlideContactClassifier(
|
||||
() => ToLines(moving),
|
||||
movingOrigin,
|
||||
() => ToLines(stationary),
|
||||
stationaryOrigin
|
||||
);
|
||||
}
|
||||
|
||||
private static List<Entity> ToLines((Vector start, Vector end)[] edges)
|
||||
{
|
||||
var lines = new List<Entity>(edges.Length);
|
||||
foreach (var (start, end) in edges)
|
||||
lines.Add(new Line(start, end));
|
||||
// Public edge arrays are sorted in place by previous queries. Recover their
|
||||
// chains on private line objects; never reverse or reorder caller geometry.
|
||||
var ordered = new List<Entity>(lines.Count);
|
||||
foreach (var shape in ShapeBuilder.GetShapes(lines))
|
||||
ordered.AddRange(shape.Entities);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
public SlideContactClassifier Prepare()
|
||||
{
|
||||
moving ??= SlideContactGeometry.Prepare(movingSource?.Invoke() ?? new List<Entity>());
|
||||
stationary ??= SlideContactGeometry.Prepare(
|
||||
stationarySource?.Invoke() ?? new List<Entity>()
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>The same prepared boundaries placed at other origins.</summary>
|
||||
public SlideContactClassifier At(Vector movingOrigin, Vector stationaryOrigin)
|
||||
{
|
||||
Prepare();
|
||||
return new SlideContactClassifier(moving, movingOrigin, stationary, stationaryOrigin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when moving along (dirX, dirY) from this world-space contact would push
|
||||
/// material into material, or the contact cannot be classified.
|
||||
/// </summary>
|
||||
public bool Blocks(Vector movingPoint, Vector stationaryPoint, double dirX, double dirY)
|
||||
{
|
||||
Prepare();
|
||||
return SlideContact.Blocks(
|
||||
moving,
|
||||
movingPoint - MovingOrigin,
|
||||
stationary,
|
||||
stationaryPoint - StationaryOrigin,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Receives candidate contact events from a directional slide query.</summary>
|
||||
public interface ISlideEventSink
|
||||
{
|
||||
/// <summary>True once further events cannot change this sink's result.</summary>
|
||||
bool IsDone { get; }
|
||||
|
||||
/// <param name="distance">Travel to the contact, snapped to zero within Tolerance.Epsilon.</param>
|
||||
/// <param name="movingPoint">Contact on the moving boundary, at its start position.</param>
|
||||
/// <param name="stationaryPoint">Contact on the stationary boundary.</param>
|
||||
void Add(double distance, Vector movingPoint, Vector stationaryPoint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates every candidate contact of one slide. Must yield the same events each
|
||||
/// time it is enumerated.
|
||||
/// </summary>
|
||||
public interface ISlideEventSource
|
||||
{
|
||||
void Enumerate<TSink>(ref TSink sink)
|
||||
where TSink : struct, ISlideEventSink;
|
||||
}
|
||||
|
||||
/// <summary>Keeps the nearest event; stops at a contact that is already touching.</summary>
|
||||
public struct NearestSlideEvent : ISlideEventSink
|
||||
{
|
||||
public bool Found;
|
||||
public double Distance;
|
||||
public Vector MovingPoint;
|
||||
public Vector StationaryPoint;
|
||||
|
||||
public bool IsDone => Found && Distance <= 0;
|
||||
|
||||
public void Add(double distance, Vector movingPoint, Vector stationaryPoint)
|
||||
{
|
||||
if (Found && distance >= Distance)
|
||||
return;
|
||||
|
||||
Found = true;
|
||||
Distance = distance;
|
||||
MovingPoint = movingPoint;
|
||||
StationaryPoint = stationaryPoint;
|
||||
}
|
||||
}
|
||||
|
||||
internal struct SlideEventList : ISlideEventSink
|
||||
{
|
||||
public List<(double Distance, Vector MovingPoint, Vector StationaryPoint)> Events;
|
||||
|
||||
public bool IsDone => false;
|
||||
|
||||
public void Add(double distance, Vector movingPoint, Vector stationaryPoint) =>
|
||||
Events.Add((distance, movingPoint, stationaryPoint));
|
||||
}
|
||||
|
||||
public static class SlideResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Travel to the first contact that blocks the slide, or double.MaxValue. When the
|
||||
/// nearest contact blocks (every contact, for unresolved boundaries), the result is
|
||||
/// exactly the nearest event distance and the events are enumerated once.
|
||||
/// </summary>
|
||||
public static double FirstBlocking<TSource>(
|
||||
ref TSource source,
|
||||
SlideContactClassifier contacts,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
where TSource : struct, ISlideEventSource
|
||||
{
|
||||
var nearest = new NearestSlideEvent();
|
||||
source.Enumerate(ref nearest);
|
||||
|
||||
if (!nearest.Found)
|
||||
return double.MaxValue;
|
||||
|
||||
if (contacts.Blocks(nearest.MovingPoint, nearest.StationaryPoint, dirX, dirY))
|
||||
return nearest.Distance;
|
||||
|
||||
var all = new SlideEventList
|
||||
{
|
||||
Events = new List<(double, Vector, Vector)>(),
|
||||
};
|
||||
source.Enumerate(ref all);
|
||||
all.Events.Sort((a, b) => a.Distance.CompareTo(b.Distance));
|
||||
|
||||
foreach (var (distance, movingPoint, stationaryPoint) in all.Events)
|
||||
{
|
||||
if (contacts.Blocks(movingPoint, stationaryPoint, dirX, dirY))
|
||||
return distance;
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a first-contact event found by a directional slide stops the slide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Parts that already touch may slide along each other or apart. Only a direction that
|
||||
/// would create positive-area overlap blocks: with S the stationary material sector and
|
||||
/// M the moving one at the contact point, that is the open Minkowski cone S ⊕ −M.
|
||||
/// A direction on that cone's boundary is a tangential slide; it blocks only when an
|
||||
/// incident curve is concave, because the second-order bend then closes the gap.
|
||||
/// Unresolved or ambiguous topology blocks, which is the previous behavior for every
|
||||
/// contact.
|
||||
/// </remarks>
|
||||
public static class SlideContact
|
||||
{
|
||||
internal const double AngleTolerance = 1e-7;
|
||||
|
||||
// Reflex sectors are split into two overlapping convex halves; the overlap keeps
|
||||
// the split ray in the interior of the union.
|
||||
internal const double SplitOverlap = 1e-3;
|
||||
|
||||
/// <summary>
|
||||
/// True when moving along (dirX, dirY) from this contact would push material into
|
||||
/// material, or when the contact cannot be classified. False for a near-miss whose
|
||||
/// point is not on both boundaries.
|
||||
/// </summary>
|
||||
/// <param name="movingPoint">Contact point in the moving entities' own frame.</param>
|
||||
/// <param name="stationaryPoint">The same contact in the stationary frame.</param>
|
||||
public static bool Blocks(
|
||||
SlideContactGeometry moving,
|
||||
Vector movingPoint,
|
||||
SlideContactGeometry stationary,
|
||||
Vector stationaryPoint,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
if (moving == null || stationary == null)
|
||||
return true;
|
||||
|
||||
var stationarySide = stationary.GetMaterialSector(
|
||||
stationaryPoint,
|
||||
out var stationaryStart,
|
||||
out var stationaryWidth,
|
||||
out var stationaryStartConcave,
|
||||
out var stationaryEndConcave
|
||||
);
|
||||
var movingSide = moving.GetMaterialSector(
|
||||
movingPoint,
|
||||
out var movingStart,
|
||||
out var movingWidth,
|
||||
out var movingStartConcave,
|
||||
out var movingEndConcave
|
||||
);
|
||||
|
||||
if (stationarySide == ContactSide.Unresolved || movingSide == ContactSide.Unresolved)
|
||||
return true;
|
||||
|
||||
// Ray tolerances report hits slightly beyond an entity's end; such a point is
|
||||
// not on the other boundary, so the parts pass without touching there.
|
||||
if (stationarySide == ContactSide.Off || movingSide == ContactSide.Off)
|
||||
return false;
|
||||
|
||||
if (stationarySide == ContactSide.Ambiguous || movingSide == ContactSide.Ambiguous)
|
||||
return true;
|
||||
|
||||
var direction = System.Math.Atan2(dirY, dirX);
|
||||
var stationaryPieces = Split(stationaryStart, stationaryWidth);
|
||||
var movingPieces = Split(movingStart + System.Math.PI, movingWidth);
|
||||
var onBoundary = false;
|
||||
|
||||
foreach (var s in stationaryPieces)
|
||||
{
|
||||
foreach (var m in movingPieces)
|
||||
{
|
||||
if (!TryHull(s, m, out var hullStart, out var hullWidth))
|
||||
return true;
|
||||
|
||||
var offset = Angle.NormalizeRad(direction - hullStart);
|
||||
if (offset > AngleTolerance && offset < hullWidth - AngleTolerance)
|
||||
return true;
|
||||
|
||||
if (
|
||||
offset <= AngleTolerance
|
||||
|| offset >= Angle.TwoPI - AngleTolerance
|
||||
|| System.Math.Abs(offset - hullWidth) <= AngleTolerance
|
||||
)
|
||||
onBoundary = true;
|
||||
}
|
||||
}
|
||||
|
||||
return onBoundary && (
|
||||
stationaryStartConcave && SameRay(direction, stationaryStart)
|
||||
|| stationaryEndConcave && SameRay(direction, stationaryStart + stationaryWidth)
|
||||
|| movingStartConcave && SameRay(direction, movingStart + System.Math.PI)
|
||||
|| movingEndConcave && SameRay(direction, movingStart + movingWidth + System.Math.PI));
|
||||
}
|
||||
|
||||
private static bool SameRay(double a, double b)
|
||||
{
|
||||
var offset = Angle.NormalizeRad(a - b);
|
||||
return offset <= AngleTolerance || offset >= Angle.TwoPI - AngleTolerance;
|
||||
}
|
||||
|
||||
private static (double Start, double Width)[] Split(double start, double width)
|
||||
{
|
||||
if (width <= System.Math.PI + AngleTolerance)
|
||||
return new[] { (start, width) };
|
||||
|
||||
var half = width / 2;
|
||||
return new[]
|
||||
{
|
||||
(start, half + SplitOverlap),
|
||||
(start + half - SplitOverlap, half + SplitOverlap),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convex cone generated by two convex sectors. False when it is the whole plane.
|
||||
/// </summary>
|
||||
private static bool TryHull(
|
||||
(double Start, double Width) a,
|
||||
(double Start, double Width) b,
|
||||
out double start,
|
||||
out double width
|
||||
)
|
||||
{
|
||||
var fromA = System.Math.Max(a.Width, Angle.NormalizeRad(b.Start - a.Start) + b.Width);
|
||||
var fromB = System.Math.Max(b.Width, Angle.NormalizeRad(a.Start - b.Start) + a.Width);
|
||||
|
||||
if (fromA <= fromB)
|
||||
{
|
||||
start = a.Start;
|
||||
width = fromA;
|
||||
}
|
||||
else
|
||||
{
|
||||
start = b.Start;
|
||||
width = fromB;
|
||||
}
|
||||
|
||||
return width <= System.Math.PI + AngleTolerance;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>Shared curve primitives for raw distance queries and slide contact events.</summary>
|
||||
internal static class SlideCurvePrimitives
|
||||
{
|
||||
internal static bool ContainsContactAngle(Arc arc, double radius, double x, double y)
|
||||
{
|
||||
// A zero-radius curve is a point: its angular range has no geometric meaning.
|
||||
if (arc == null || radius == 0)
|
||||
return true;
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(y, x));
|
||||
return Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed);
|
||||
}
|
||||
|
||||
/// <summary>Returns both ray-circle parameters, before forward filtering or epsilon snapping.</summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining
|
||||
)]
|
||||
internal static bool SolveRayCircle(
|
||||
double vx,
|
||||
double vy,
|
||||
double cx,
|
||||
double cy,
|
||||
double r,
|
||||
double dirX,
|
||||
double dirY,
|
||||
out double t1,
|
||||
out double t2
|
||||
)
|
||||
{
|
||||
var ox = vx - cx;
|
||||
var oy = vy - cy;
|
||||
|
||||
var a = dirX * dirX + dirY * dirY;
|
||||
var b = 2.0 * (ox * dirX + oy * dirY);
|
||||
var c = ox * ox + oy * oy - r * r;
|
||||
|
||||
var discriminant = b * b - 4.0 * a * c;
|
||||
if (discriminant < 0)
|
||||
{
|
||||
t1 = t2 = double.MaxValue;
|
||||
return false;
|
||||
}
|
||||
|
||||
var sqrtD = System.Math.Sqrt(discriminant);
|
||||
var inv2a = 1.0 / (2.0 * a);
|
||||
t1 = (-b - sqrtD) * inv2a;
|
||||
t2 = (-b + sqrtD) * inv2a;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Candidate contact events of directional slides. Each emitter reports every forward
|
||||
/// hit its distance kernel considers, with the distance snapped exactly as that kernel
|
||||
/// snaps it, so the nearest event equals the kernel's historical minimum.
|
||||
/// </summary>
|
||||
internal static class SlideEvents
|
||||
{
|
||||
private const double Eps = Tolerance.Epsilon;
|
||||
|
||||
private static double Snap(double t) => t > Eps ? t : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Ray from a vertex against one entity. When <paramref name="vertexMoves"/> is true
|
||||
/// the vertex belongs to the moving boundary and the ray follows the push direction;
|
||||
/// otherwise it is a stationary vertex and the ray runs opposite to the push.
|
||||
/// </summary>
|
||||
public static void Ray<TSink>(
|
||||
ref TSink sink,
|
||||
double vx,
|
||||
double vy,
|
||||
Entity entity,
|
||||
double entityDx,
|
||||
double entityDy,
|
||||
double rayX,
|
||||
double rayY,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
RayLine(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
line.pt1.X + entityDx,
|
||||
line.pt1.Y + entityDy,
|
||||
line.pt2.X + entityDx,
|
||||
line.pt2.Y + entityDy,
|
||||
rayX,
|
||||
rayY,
|
||||
vertexMoves
|
||||
);
|
||||
break;
|
||||
|
||||
case Arc arc:
|
||||
{
|
||||
var cx = arc.Center.X + entityDx;
|
||||
var cy = arc.Center.Y + entityDy;
|
||||
if (!SolveRayCircle(vx, vy, cx, cy, arc.Radius, rayX, rayY, out var t1, out var t2))
|
||||
return;
|
||||
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var t = k == 0 ? t1 : t2;
|
||||
if (t <= -Eps)
|
||||
continue;
|
||||
|
||||
var hitAngle = Angle.NormalizeRad(
|
||||
System.Math.Atan2(vy + t * rayY - cy, vx + t * rayX - cx)
|
||||
);
|
||||
if (!Angle.IsBetweenRad(hitAngle, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||
continue;
|
||||
|
||||
Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Circle circle:
|
||||
{
|
||||
if (
|
||||
!SolveRayCircle(
|
||||
vx,
|
||||
vy,
|
||||
circle.Center.X + entityDx,
|
||||
circle.Center.Y + entityDy,
|
||||
circle.Radius,
|
||||
rayX,
|
||||
rayY,
|
||||
out var t1,
|
||||
out var t2
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var t = k == 0 ? t1 : t2;
|
||||
if (t < -Eps)
|
||||
continue;
|
||||
|
||||
Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Same hit rule as <see cref="SpatialQuery.RayEdgeDistance(double, double, double, double, double, double, double, double)"/>.</summary>
|
||||
public static void RayLine<TSink>(
|
||||
ref TSink sink,
|
||||
double vx,
|
||||
double vy,
|
||||
double p1x,
|
||||
double p1y,
|
||||
double p2x,
|
||||
double p2y,
|
||||
double rayX,
|
||||
double rayY,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
var ex = p2x - p1x;
|
||||
var ey = p2y - p1y;
|
||||
|
||||
var det = ex * rayY - ey * rayX;
|
||||
if (System.Math.Abs(det) < Eps)
|
||||
return;
|
||||
|
||||
var dvx = p1x - vx;
|
||||
var dvy = p1y - vy;
|
||||
|
||||
var t = (ex * dvy - ey * dvx) / det;
|
||||
if (t < -Eps)
|
||||
return;
|
||||
|
||||
var s = (rayX * dvy - rayY * dvx) / det;
|
||||
if (s < -Eps || s > 1.0 + Eps)
|
||||
return;
|
||||
|
||||
Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Axis-aligned ray against a segment, with the same hit rule as the
|
||||
/// <see cref="PushDirection"/> kernel.
|
||||
/// </summary>
|
||||
public static void AxisRayLine<TSink>(
|
||||
ref TSink sink,
|
||||
double vx,
|
||||
double vy,
|
||||
double p1x,
|
||||
double p1y,
|
||||
double p2x,
|
||||
double p2y,
|
||||
PushDirection rayDirection,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
double dist,
|
||||
hx,
|
||||
hy;
|
||||
|
||||
switch (rayDirection)
|
||||
{
|
||||
case PushDirection.Left:
|
||||
case PushDirection.Right:
|
||||
{
|
||||
var dy = p2y - p1y;
|
||||
if (System.Math.Abs(dy) < Eps)
|
||||
return;
|
||||
|
||||
var t = (vy - p1y) / dy;
|
||||
if (t < -Eps || t > 1.0 + Eps)
|
||||
return;
|
||||
|
||||
hx = p1x + t * (p2x - p1x);
|
||||
hy = vy;
|
||||
dist = rayDirection == PushDirection.Left ? vx - hx : hx - vx;
|
||||
break;
|
||||
}
|
||||
|
||||
case PushDirection.Down:
|
||||
case PushDirection.Up:
|
||||
{
|
||||
var dx = p2x - p1x;
|
||||
if (System.Math.Abs(dx) < Eps)
|
||||
return;
|
||||
|
||||
var t = (vx - p1x) / dx;
|
||||
if (t < -Eps || t > 1.0 + Eps)
|
||||
return;
|
||||
|
||||
hx = vx;
|
||||
hy = p1y + t * (p2y - p1y);
|
||||
dist = rayDirection == PushDirection.Down ? vy - hy : hy - vy;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (dist < -Eps)
|
||||
return;
|
||||
|
||||
var vertex = new Vector(vx, vy);
|
||||
var hit = new Vector(hx, hy);
|
||||
if (vertexMoves)
|
||||
sink.Add(Snap(dist), vertex, hit);
|
||||
else
|
||||
sink.Add(Snap(dist), hit, vertex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closest-approach points of arcs against lines, which vertex sampling can miss.
|
||||
/// </summary>
|
||||
public static void ArcToLine<TSink>(
|
||||
ref TSink sink,
|
||||
List<Entity> arcEntities,
|
||||
double arcDx,
|
||||
double arcDy,
|
||||
List<Entity> lineEntities,
|
||||
double lineDx,
|
||||
double lineDy,
|
||||
double rayX,
|
||||
double rayY,
|
||||
bool arcMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
for (var i = 0; i < arcEntities.Count; i++)
|
||||
{
|
||||
if (!TryGetCurve(arcEntities[i], out var localCx, out var localCy, out var r))
|
||||
continue;
|
||||
|
||||
var arc = arcEntities[i] as Arc;
|
||||
var cx = localCx + arcDx;
|
||||
var cy = localCy + arcDy;
|
||||
|
||||
for (var j = 0; j < lineEntities.Count; j++)
|
||||
{
|
||||
if (lineEntities[j] is not Line line)
|
||||
continue;
|
||||
|
||||
var p1x = line.pt1.X + lineDx;
|
||||
var p1y = line.pt1.Y + lineDy;
|
||||
var p2x = line.pt2.X + lineDx;
|
||||
var p2y = line.pt2.Y + lineDy;
|
||||
var ex = p2x - p1x;
|
||||
var ey = p2y - p1y;
|
||||
|
||||
var det = ex * rayY - ey * rayX;
|
||||
if (System.Math.Abs(det) < Eps)
|
||||
continue;
|
||||
|
||||
// The directional distance from an arc point at angle θ to the
|
||||
// line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det.
|
||||
// dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π.
|
||||
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
|
||||
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
|
||||
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var theta = k == 0 ? theta1 : theta2;
|
||||
|
||||
if (arc != null && !Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||
continue;
|
||||
|
||||
var qx = cx + r * System.Math.Cos(theta);
|
||||
var qy = cy + r * System.Math.Sin(theta);
|
||||
|
||||
RayLine(ref sink, qx, qy, p1x, p1y, p2x, p2y, rayX, rayY, arcMoves);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// External and internal tangencies of two curves along a unit direction. Radii must
|
||||
/// be nonnegative; a null arc is a full circle.
|
||||
/// </summary>
|
||||
public static void CurveTangency<TSink>(
|
||||
ref TSink sink,
|
||||
double movingCx,
|
||||
double movingCy,
|
||||
double movingRadius,
|
||||
Arc movingArc,
|
||||
double stationaryCx,
|
||||
double stationaryCy,
|
||||
double stationaryRadius,
|
||||
Arc stationaryArc,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
for (var kind = 0; kind < 2; kind++)
|
||||
{
|
||||
var internalContact = kind == 1;
|
||||
var radius = internalContact
|
||||
? System.Math.Abs(movingRadius - stationaryRadius)
|
||||
: movingRadius + stationaryRadius;
|
||||
|
||||
// Equal-radius internal contact has coincident centers, not a unique
|
||||
// tangent point. Endpoints detect any overlap of those angular spans.
|
||||
if (radius == 0)
|
||||
continue;
|
||||
|
||||
if (
|
||||
!SolveRayCircle(
|
||||
movingCx,
|
||||
movingCy,
|
||||
stationaryCx,
|
||||
stationaryCy,
|
||||
radius,
|
||||
dirX,
|
||||
dirY,
|
||||
out var t1,
|
||||
out var t2
|
||||
)
|
||||
)
|
||||
continue;
|
||||
|
||||
// The nearer center-circle root can be outside an arc while the farther
|
||||
// root is its first contact. Check the actual tangent point at BOTH roots.
|
||||
for (var root = 0; root < 2; root++)
|
||||
{
|
||||
var t = root == 0 ? t1 : t2;
|
||||
if (t < -Eps)
|
||||
continue;
|
||||
|
||||
var toX = stationaryCx - (movingCx + t * dirX);
|
||||
var toY = stationaryCy - (movingCy + t * dirY);
|
||||
var movingSign = internalContact && movingRadius < stationaryRadius ? -1 : 1;
|
||||
var stationarySign = internalContact ? movingSign : -1;
|
||||
if (
|
||||
!ContainsContactAngle(
|
||||
movingArc,
|
||||
movingRadius,
|
||||
movingSign * toX,
|
||||
movingSign * toY
|
||||
)
|
||||
|| !ContainsContactAngle(
|
||||
stationaryArc,
|
||||
stationaryRadius,
|
||||
stationarySign * toX,
|
||||
stationarySign * toY
|
||||
)
|
||||
)
|
||||
continue;
|
||||
|
||||
var length = System.Math.Sqrt(toX * toX + toY * toY);
|
||||
var ux = length > 0 ? toX / length : 0;
|
||||
var uy = length > 0 ? toY / length : 0;
|
||||
var movingPoint = new Vector(
|
||||
movingCx + movingSign * movingRadius * ux,
|
||||
movingCy + movingSign * movingRadius * uy
|
||||
);
|
||||
var stationaryPoint = new Vector(
|
||||
stationaryCx + stationarySign * stationaryRadius * ux,
|
||||
stationaryCy + stationarySign * stationaryRadius * uy
|
||||
);
|
||||
|
||||
sink.Add(Snap(t), movingPoint, stationaryPoint);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetCurve(Entity entity, out double cx, out double cy, out double r)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Circle circle:
|
||||
cx = circle.Center.X;
|
||||
cy = circle.Center.Y;
|
||||
r = circle.Radius;
|
||||
return true;
|
||||
case Arc arc:
|
||||
cx = arc.Center.X;
|
||||
cy = arc.Center.Y;
|
||||
r = arc.Radius;
|
||||
return true;
|
||||
default:
|
||||
cx = cy = r = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Emit<TSink>(
|
||||
ref TSink sink,
|
||||
double vx,
|
||||
double vy,
|
||||
double t,
|
||||
double rayX,
|
||||
double rayY,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
var vertex = new Vector(vx, vy);
|
||||
var hit = new Vector(vx + t * rayX, vy + t * rayY);
|
||||
if (vertexMoves)
|
||||
sink.Add(Snap(t), vertex, hit);
|
||||
else
|
||||
sink.Add(Snap(t), hit, vertex);
|
||||
}
|
||||
|
||||
private static bool ContainsContactAngle(Arc arc, double radius, double x, double y)
|
||||
{
|
||||
return SlideCurvePrimitives.ContainsContactAngle(arc, radius, x, y);
|
||||
}
|
||||
|
||||
internal static bool SolveRayCircle(
|
||||
double vx,
|
||||
double vy,
|
||||
double cx,
|
||||
double cy,
|
||||
double r,
|
||||
double dirX,
|
||||
double dirY,
|
||||
out double t1,
|
||||
out double t2
|
||||
)
|
||||
{
|
||||
return SlideCurvePrimitives.SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out t1, out t2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slide events between native Line/Arc/Circle boundaries. The moving entities and
|
||||
/// vertices are translated by (movingDx, movingDy); vertex arrays may be subsets.
|
||||
/// </summary>
|
||||
public struct EntitySlideEvents : ISlideEventSource
|
||||
{
|
||||
private readonly List<Entity> moving;
|
||||
private readonly Vector[] movingVertices;
|
||||
private readonly double movingDx;
|
||||
private readonly double movingDy;
|
||||
private readonly List<Entity> stationary;
|
||||
private readonly Vector[] stationaryVertices;
|
||||
private readonly double dirX;
|
||||
private readonly double dirY;
|
||||
private readonly bool arcToLine;
|
||||
|
||||
public EntitySlideEvents(
|
||||
List<Entity> moving,
|
||||
Vector[] movingVertices,
|
||||
double movingDx,
|
||||
double movingDy,
|
||||
List<Entity> stationary,
|
||||
Vector[] stationaryVertices,
|
||||
double dirX,
|
||||
double dirY,
|
||||
bool arcToLine
|
||||
)
|
||||
{
|
||||
this.moving = moving;
|
||||
this.movingVertices = movingVertices;
|
||||
this.movingDx = movingDx;
|
||||
this.movingDy = movingDy;
|
||||
this.stationary = stationary;
|
||||
this.stationaryVertices = stationaryVertices;
|
||||
this.dirX = dirX;
|
||||
this.dirY = dirY;
|
||||
this.arcToLine = arcToLine;
|
||||
}
|
||||
|
||||
public void Enumerate<TSink>(ref TSink sink)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
// Phase 1: moving vertices along the push against stationary entities.
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
var vx = movingVertices[v].X + movingDx;
|
||||
var vy = movingVertices[v].Y + movingDy;
|
||||
|
||||
for (var j = 0; j < stationary.Count; j++)
|
||||
{
|
||||
SlideEvents.Ray(ref sink, vx, vy, stationary[j], 0, 0, dirX, dirY, true);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: stationary vertices against the push onto moving entities.
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
var vx = stationaryVertices[v].X;
|
||||
var vy = stationaryVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < moving.Count; j++)
|
||||
{
|
||||
SlideEvents.Ray(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
moving[j],
|
||||
movingDx,
|
||||
movingDy,
|
||||
-dirX,
|
||||
-dirY,
|
||||
false
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: arc-to-line closest points, which vertex sampling can miss.
|
||||
if (arcToLine)
|
||||
{
|
||||
SlideEvents.ArcToLine(
|
||||
ref sink,
|
||||
moving,
|
||||
movingDx,
|
||||
movingDy,
|
||||
stationary,
|
||||
0,
|
||||
0,
|
||||
dirX,
|
||||
dirY,
|
||||
true
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
SlideEvents.ArcToLine(
|
||||
ref sink,
|
||||
stationary,
|
||||
0,
|
||||
0,
|
||||
moving,
|
||||
movingDx,
|
||||
movingDy,
|
||||
-dirX,
|
||||
-dirY,
|
||||
false
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 4: native curve tangency, including a convex corner inside a concave arc.
|
||||
for (var i = 0; i < moving.Count; i++)
|
||||
{
|
||||
if (!SlideEvents.TryGetCurve(moving[i], out var mcx, out var mcy, out var mr))
|
||||
continue;
|
||||
|
||||
for (var j = 0; j < stationary.Count; j++)
|
||||
{
|
||||
if (!SlideEvents.TryGetCurve(stationary[j], out var scx, out var scy, out var sr))
|
||||
continue;
|
||||
|
||||
SlideEvents.CurveTangency(
|
||||
ref sink,
|
||||
mcx + movingDx,
|
||||
mcy + movingDy,
|
||||
mr,
|
||||
moving[i] as Arc,
|
||||
scx,
|
||||
scy,
|
||||
sr,
|
||||
stationary[j] as Arc,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slide events between line boundaries along an arbitrary unit direction. The moving
|
||||
/// lines and vertices are translated by (movingDx, movingDy); vertex arrays may be subsets.
|
||||
/// </summary>
|
||||
public struct LineSlideEvents : ISlideEventSource
|
||||
{
|
||||
private readonly List<Line> moving;
|
||||
private readonly Vector[] movingVertices;
|
||||
private readonly double movingDx;
|
||||
private readonly double movingDy;
|
||||
private readonly List<Line> stationary;
|
||||
private readonly Vector[] stationaryVertices;
|
||||
private readonly double dirX;
|
||||
private readonly double dirY;
|
||||
|
||||
public LineSlideEvents(
|
||||
List<Line> moving,
|
||||
Vector[] movingVertices,
|
||||
double movingDx,
|
||||
double movingDy,
|
||||
List<Line> stationary,
|
||||
Vector[] stationaryVertices,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
this.moving = moving;
|
||||
this.movingVertices = movingVertices;
|
||||
this.movingDx = movingDx;
|
||||
this.movingDy = movingDy;
|
||||
this.stationary = stationary;
|
||||
this.stationaryVertices = stationaryVertices;
|
||||
this.dirX = dirX;
|
||||
this.dirY = dirY;
|
||||
}
|
||||
|
||||
public void Enumerate<TSink>(ref TSink sink)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
var vx = movingVertices[v].X + movingDx;
|
||||
var vy = movingVertices[v].Y + movingDy;
|
||||
|
||||
for (var j = 0; j < stationary.Count; j++)
|
||||
{
|
||||
var e = stationary[j];
|
||||
SlideEvents.RayLine(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
e.pt1.X,
|
||||
e.pt1.Y,
|
||||
e.pt2.X,
|
||||
e.pt2.Y,
|
||||
dirX,
|
||||
dirY,
|
||||
true
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
var vx = stationaryVertices[v].X;
|
||||
var vy = stationaryVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < moving.Count; j++)
|
||||
{
|
||||
var e = moving[j];
|
||||
SlideEvents.RayLine(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
e.pt1.X + movingDx,
|
||||
e.pt1.Y + movingDy,
|
||||
e.pt2.X + movingDx,
|
||||
e.pt2.Y + movingDy,
|
||||
-dirX,
|
||||
-dirY,
|
||||
false
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Axis-aligned slide events between edge arrays sorted for pruning, as used by the
|
||||
/// <see cref="PushDirection"/> kernel. Offsets translate each side into world space.
|
||||
/// </summary>
|
||||
public struct AxisSlideEvents : ISlideEventSource
|
||||
{
|
||||
private readonly (Vector start, Vector end)[] movingEdges;
|
||||
private readonly Vector movingOffset;
|
||||
private readonly Vector[] movingVertices;
|
||||
private readonly (Vector start, Vector end)[] stationaryEdges;
|
||||
private readonly Vector stationaryOffset;
|
||||
private readonly Vector[] stationaryVertices;
|
||||
private readonly PushDirection direction;
|
||||
|
||||
/// <param name="movingVertices">World-space moving vertices.</param>
|
||||
/// <param name="stationaryVertices">World-space stationary vertices.</param>
|
||||
public AxisSlideEvents(
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
Vector movingOffset,
|
||||
Vector[] movingVertices,
|
||||
(Vector start, Vector end)[] stationaryEdges,
|
||||
Vector stationaryOffset,
|
||||
Vector[] stationaryVertices,
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
this.movingEdges = movingEdges;
|
||||
this.movingOffset = movingOffset;
|
||||
this.movingVertices = movingVertices;
|
||||
this.stationaryEdges = stationaryEdges;
|
||||
this.stationaryOffset = stationaryOffset;
|
||||
this.stationaryVertices = stationaryVertices;
|
||||
this.direction = direction;
|
||||
}
|
||||
|
||||
public void Enumerate<TSink>(ref TSink sink)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
OneWay(ref sink, movingVertices[v], stationaryEdges, stationaryOffset, direction, true);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
|
||||
var opposite = SpatialQuery.OppositeDirection(direction);
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
OneWay(ref sink, stationaryVertices[v], movingEdges, movingOffset, opposite, false);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OneWay<TSink>(
|
||||
ref TSink sink,
|
||||
Vector vertex,
|
||||
(Vector start, Vector end)[] edges,
|
||||
Vector edgeOffset,
|
||||
PushDirection rayDirection,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
var vx = vertex.X;
|
||||
var vy = vertex.Y;
|
||||
var horizontal = SpatialQuery.IsHorizontalDirection(rayDirection);
|
||||
|
||||
// Edges are sorted by their perpendicular min-coordinate.
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
{
|
||||
var e1 = edges[i].start + edgeOffset;
|
||||
var e2 = edges[i].end + edgeOffset;
|
||||
|
||||
double perpValue,
|
||||
edgeMin,
|
||||
edgeMax;
|
||||
if (horizontal)
|
||||
{
|
||||
perpValue = vy;
|
||||
edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
|
||||
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
perpValue = vx;
|
||||
edgeMin = e1.X < e2.X ? e1.X : e2.X;
|
||||
edgeMax = e1.X > e2.X ? e1.X : e2.X;
|
||||
}
|
||||
|
||||
if (perpValue < edgeMin - Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
if (perpValue > edgeMax + Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
SlideEvents.AxisRayLine(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
e1.X,
|
||||
e1.Y,
|
||||
e2.X,
|
||||
e2.Y,
|
||||
rayDirection,
|
||||
vertexMoves
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,25 +147,7 @@ namespace OpenNest.Geometry
|
||||
out double t2
|
||||
)
|
||||
{
|
||||
var ox = vx - cx;
|
||||
var oy = vy - cy;
|
||||
|
||||
var a = dirX * dirX + dirY * dirY;
|
||||
var b = 2.0 * (ox * dirX + oy * dirY);
|
||||
var c = ox * ox + oy * oy - r * r;
|
||||
|
||||
var discriminant = b * b - 4.0 * a * c;
|
||||
if (discriminant < 0)
|
||||
{
|
||||
t1 = t2 = double.MaxValue;
|
||||
return false;
|
||||
}
|
||||
|
||||
var sqrtD = System.Math.Sqrt(discriminant);
|
||||
var inv2a = 1.0 / (2.0 * a);
|
||||
t1 = (-b - sqrtD) * inv2a;
|
||||
t2 = (-b + sqrtD) * inv2a;
|
||||
return true;
|
||||
return SlideCurvePrimitives.SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out t1, out t2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -312,16 +294,13 @@ namespace OpenNest.Geometry
|
||||
|
||||
private static bool ContainsContactAngle(Arc arc, double radius, double x, double y)
|
||||
{
|
||||
// A zero-radius curve is a point: its angular range has no geometric meaning.
|
||||
if (arc == null || radius == 0)
|
||||
return true;
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(y, x));
|
||||
return Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed);
|
||||
return SlideCurvePrimitives.ContainsContactAngle(arc, radius, x, y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along a push direction before
|
||||
/// any edge of movingLines contacts any edge of stationaryLines.
|
||||
/// Computes the translation distance along a push direction before any edge of
|
||||
/// movingLines first blocks against an edge of stationaryLines. A contact that
|
||||
/// the push slides along or leaves does not block (see <see cref="SlideContact"/>).
|
||||
/// Returns double.MaxValue if no collision path exists.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
@@ -334,7 +313,7 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum directional distance with the moving lines translated
|
||||
/// Computes the directional distance with the moving lines translated
|
||||
/// by (movingDx, movingDy) without creating new Line objects.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
@@ -345,37 +324,57 @@ namespace OpenNest.Geometry
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var movingOffset = new Vector(movingDx, movingDy);
|
||||
return DirectionalDistance(
|
||||
movingLines,
|
||||
movingDx,
|
||||
movingDy,
|
||||
stationaryLines,
|
||||
direction,
|
||||
SlideContactClassifier.FromLines(
|
||||
movingLines,
|
||||
new Vector(movingDx, movingDy),
|
||||
stationaryLines,
|
||||
Vector.Zero
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = CollectVertices(movingLines, movingOffset);
|
||||
/// <summary>
|
||||
/// <see cref="DirectionalDistance(List{Line}, double, double, List{Line}, PushDirection)"/>
|
||||
/// with caller-supplied contact topology, for inputs that are not complete closed
|
||||
/// boundaries (for example direction-filtered edges).
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines,
|
||||
double movingDx,
|
||||
double movingDy,
|
||||
List<Line> stationaryLines,
|
||||
PushDirection direction,
|
||||
SlideContactClassifier contacts
|
||||
)
|
||||
{
|
||||
var movingOffset = new Vector(movingDx, movingDy);
|
||||
var movingVertices = CollectVertices(movingLines, movingOffset).ToArray();
|
||||
|
||||
var stationaryEdges = ToEdgeArray(stationaryLines);
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero).ToArray();
|
||||
|
||||
var movingEdges = ToEdgeArray(movingLines);
|
||||
SortEdgesForPruning(movingEdges, opposite);
|
||||
SortEdgesForPruning(movingEdges, OppositeDirection(direction));
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
var source = new AxisSlideEvents(
|
||||
movingEdges,
|
||||
movingOffset,
|
||||
movingVertices,
|
||||
stationaryEdges,
|
||||
Vector.Zero,
|
||||
stationaryVertices,
|
||||
direction
|
||||
);
|
||||
var unit = DirectionToOffset(direction, 1.0);
|
||||
return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -396,8 +395,8 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum directional distance using raw edge arrays and location offsets
|
||||
/// to avoid all intermediate object allocations.
|
||||
/// Computes the blocking directional distance using raw edge arrays and location
|
||||
/// offsets. Sorts both edge arrays in place for pruning.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
@@ -407,36 +406,58 @@ namespace OpenNest.Geometry
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = CollectVertices(movingEdges, movingOffset);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
SortEdgesForPruning(movingEdges, opposite);
|
||||
|
||||
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
return DirectionalDistance(
|
||||
movingEdges,
|
||||
movingOffset,
|
||||
stationaryEdges,
|
||||
stationaryOffset,
|
||||
direction,
|
||||
SlideContactClassifier.FromEdges(
|
||||
movingEdges,
|
||||
movingOffset,
|
||||
stationaryEdges,
|
||||
stationaryOffset
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge-array overload with caller-supplied contact topology. The classifier's
|
||||
/// origins must match <paramref name="movingOffset"/> and
|
||||
/// <paramref name="stationaryOffset"/> in the frame of its boundaries.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
Vector movingOffset,
|
||||
(Vector start, Vector end)[] stationaryEdges,
|
||||
Vector stationaryOffset,
|
||||
PushDirection direction,
|
||||
SlideContactClassifier contacts
|
||||
)
|
||||
{
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
var movingVertices = CollectVertices(movingEdges, movingOffset).ToArray();
|
||||
|
||||
SortEdgesForPruning(movingEdges, OppositeDirection(direction));
|
||||
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset).ToArray();
|
||||
|
||||
var source = new AxisSlideEvents(
|
||||
movingEdges,
|
||||
movingOffset,
|
||||
movingVertices,
|
||||
stationaryEdges,
|
||||
stationaryOffset,
|
||||
stationaryVertices,
|
||||
direction
|
||||
);
|
||||
var unit = DirectionToOffset(direction, 1.0);
|
||||
return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nearest raw hit from one vertex along a push direction against edges sorted for
|
||||
/// pruning. This is a first-touch primitive; it does not classify sliding contacts.
|
||||
/// </summary>
|
||||
public static double OneWayDistance(
|
||||
Vector vertex,
|
||||
(Vector start, Vector end)[] edges,
|
||||
@@ -628,8 +649,8 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along an arbitrary unit direction
|
||||
/// before any edge of movingLines contacts any edge of stationaryLines.
|
||||
/// Computes the translation distance along an arbitrary unit direction before any
|
||||
/// edge of movingLines first blocks against an edge of stationaryLines.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines,
|
||||
@@ -637,58 +658,41 @@ namespace OpenNest.Geometry
|
||||
Vector direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var dirX = direction.X;
|
||||
var dirY = direction.Y;
|
||||
return DirectionalDistance(
|
||||
movingLines,
|
||||
stationaryLines,
|
||||
direction,
|
||||
SlideContactClassifier.FromLines(
|
||||
movingLines,
|
||||
Vector.Zero,
|
||||
stationaryLines,
|
||||
Vector.Zero
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
var movingVertices = CollectVertices(movingLines, Vector.Zero);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
for (var i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
var e = stationaryLines[i];
|
||||
var d = RayEdgeDistance(
|
||||
mv.X,
|
||||
mv.Y,
|
||||
e.pt1.X,
|
||||
e.pt1.Y,
|
||||
e.pt2.X,
|
||||
e.pt2.Y,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
for (var i = 0; i < movingLines.Count; i++)
|
||||
{
|
||||
var e = movingLines[i];
|
||||
var d = RayEdgeDistance(
|
||||
sv.X,
|
||||
sv.Y,
|
||||
e.pt1.X,
|
||||
e.pt1.Y,
|
||||
e.pt2.X,
|
||||
e.pt2.Y,
|
||||
oppX,
|
||||
oppY
|
||||
);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
/// <summary>
|
||||
/// <see cref="DirectionalDistance(List{Line}, List{Line}, Vector)"/> with
|
||||
/// caller-supplied contact topology.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines,
|
||||
List<Line> stationaryLines,
|
||||
Vector direction,
|
||||
SlideContactClassifier contacts
|
||||
)
|
||||
{
|
||||
var source = new LineSlideEvents(
|
||||
movingLines,
|
||||
CollectVertices(movingLines, Vector.Zero).ToArray(),
|
||||
0,
|
||||
0,
|
||||
stationaryLines,
|
||||
CollectVertices(stationaryLines, Vector.Zero).ToArray(),
|
||||
direction.X,
|
||||
direction.Y
|
||||
);
|
||||
return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -710,10 +714,10 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along an arbitrary unit direction
|
||||
/// before any vertex/edge of movingEntities contacts any vertex/edge of
|
||||
/// stationaryEntities. Works with native Line, Arc, and Circle entities
|
||||
/// without tessellation.
|
||||
/// Computes the translation distance along an arbitrary unit direction before any
|
||||
/// vertex/edge of movingEntities first blocks against stationaryEntities. Works with
|
||||
/// native Line, Arc, and Circle entities without tessellation. A contact that the
|
||||
/// push slides along or leaves does not block (see <see cref="SlideContact"/>).
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Entity> movingEntities,
|
||||
@@ -721,228 +725,42 @@ namespace OpenNest.Geometry
|
||||
Vector direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var dirX = direction.X;
|
||||
var dirY = direction.Y;
|
||||
|
||||
var movingVertices = ExtractEntityVertices(movingEntities);
|
||||
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
var vx = movingVertices[v].X;
|
||||
var vy = movingVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(vx, vy, stationaryEntities[j], dirX, dirY);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var stationaryVertices = ExtractEntityVertices(stationaryEntities);
|
||||
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
var vx = stationaryVertices[v].X;
|
||||
var vy = stationaryVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < movingEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(vx, vy, movingEntities[j], oppX, oppY);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Arc-to-line closest-point check.
|
||||
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual
|
||||
// closest point on a small corner arc to a straight edge may lie between
|
||||
// those samples. Use ClosestPointTo to find it and fire a ray from there.
|
||||
minDist = ArcToLineClosestDistance(
|
||||
return DirectionalDistance(
|
||||
movingEntities,
|
||||
stationaryEntities,
|
||||
dirX,
|
||||
dirY,
|
||||
minDist
|
||||
direction,
|
||||
new SlideContactClassifier(movingEntities, stationaryEntities)
|
||||
);
|
||||
if (minDist <= 0)
|
||||
return 0;
|
||||
minDist = ArcToLineClosestDistance(
|
||||
stationaryEntities,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="DirectionalDistance(List{Entity}, List{Entity}, Vector)"/> with
|
||||
/// caller-supplied contact topology.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Entity> movingEntities,
|
||||
List<Entity> stationaryEntities,
|
||||
Vector direction,
|
||||
SlideContactClassifier contacts
|
||||
)
|
||||
{
|
||||
// Phases: vertex rays both ways, arc-to-line closest points (vertex sampling
|
||||
// misses interior arc contact), then native curve tangency.
|
||||
var source = new EntitySlideEvents(
|
||||
movingEntities,
|
||||
oppX,
|
||||
oppY,
|
||||
minDist
|
||||
ExtractEntityVertices(movingEntities),
|
||||
0,
|
||||
0,
|
||||
stationaryEntities,
|
||||
ExtractEntityVertices(stationaryEntities),
|
||||
direction.X,
|
||||
direction.Y,
|
||||
arcToLine: true
|
||||
);
|
||||
if (minDist <= 0)
|
||||
return 0;
|
||||
|
||||
// Phase 4: Native curve tangency, including a convex corner inside a concave arc.
|
||||
for (var i = 0; i < movingEntities.Count; i++)
|
||||
{
|
||||
var me = movingEntities[i];
|
||||
if (!TryGetCurveParams(me, out var mcx, out var mcy, out var mr))
|
||||
continue;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var se = stationaryEntities[j];
|
||||
if (!TryGetCurveParams(se, out var scx, out var scy, out var sr))
|
||||
continue;
|
||||
|
||||
var d = CurveTangencyDistance(
|
||||
mcx, mcy, mr, me as Arc,
|
||||
scx, scy, sr, se as Arc, dirX, dirY);
|
||||
if (d >= minDist)
|
||||
continue;
|
||||
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y);
|
||||
}
|
||||
|
||||
private static double ArcToLineClosestDistance(
|
||||
List<Entity> arcEntities,
|
||||
List<Entity> lineEntities,
|
||||
double dirX,
|
||||
double dirY,
|
||||
double minDist
|
||||
)
|
||||
{
|
||||
for (var i = 0; i < arcEntities.Count; i++)
|
||||
{
|
||||
if (arcEntities[i] is not Arc arc)
|
||||
continue;
|
||||
|
||||
var cx = arc.Center.X;
|
||||
var cy = arc.Center.Y;
|
||||
var r = arc.Radius;
|
||||
|
||||
for (var j = 0; j < lineEntities.Count; j++)
|
||||
{
|
||||
if (lineEntities[j] is not Line line)
|
||||
continue;
|
||||
|
||||
var p1x = line.pt1.X;
|
||||
var p1y = line.pt1.Y;
|
||||
var ex = line.pt2.X - p1x;
|
||||
var ey = line.pt2.Y - p1y;
|
||||
|
||||
var det = ex * dirY - ey * dirX;
|
||||
if (System.Math.Abs(det) < Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
// The directional distance from an arc point at angle θ to the
|
||||
// line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det.
|
||||
// dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π.
|
||||
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
|
||||
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
|
||||
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var theta = k == 0 ? theta1 : theta2;
|
||||
|
||||
if (
|
||||
!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed)
|
||||
)
|
||||
continue;
|
||||
|
||||
var qx = cx + r * System.Math.Cos(theta);
|
||||
var qy = cy + r * System.Math.Sin(theta);
|
||||
|
||||
var d = RayEdgeDistance(
|
||||
qx,
|
||||
qy,
|
||||
p1x,
|
||||
p1y,
|
||||
line.pt2.X,
|
||||
line.pt2.Y,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return minDist;
|
||||
}
|
||||
|
||||
private static double RayEntityDistance(
|
||||
double vx,
|
||||
double vy,
|
||||
Entity entity,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
return RayEdgeDistance(
|
||||
vx,
|
||||
vy,
|
||||
line.pt1.X,
|
||||
line.pt1.Y,
|
||||
line.pt2.X,
|
||||
line.pt2.Y,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
return RayArcDistance(
|
||||
vx,
|
||||
vy,
|
||||
arc.Center.X,
|
||||
arc.Center.Y,
|
||||
arc.Radius,
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
return RayCircleDistance(
|
||||
vx,
|
||||
vy,
|
||||
circle.Center.X,
|
||||
circle.Center.Y,
|
||||
circle.Radius,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
private static Vector[] ExtractEntityVertices(List<Entity> entities)
|
||||
public static Vector[] ExtractEntityVertices(List<Entity> entities)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
|
||||
@@ -1041,31 +859,6 @@ namespace OpenNest.Geometry
|
||||
);
|
||||
}
|
||||
|
||||
private static bool TryGetCurveParams(
|
||||
Entity entity,
|
||||
out double cx,
|
||||
out double cy,
|
||||
out double r
|
||||
)
|
||||
{
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
cx = circle.Center.X;
|
||||
cy = circle.Center.Y;
|
||||
r = circle.Radius;
|
||||
return true;
|
||||
}
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
cx = arc.Center.X;
|
||||
cy = arc.Center.Y;
|
||||
r = arc.Radius;
|
||||
return true;
|
||||
}
|
||||
cx = cy = r = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double BoxProjectionMin(Box box, double dx, double dy)
|
||||
{
|
||||
var x = dx >= 0 ? box.Left : box.Right;
|
||||
|
||||
@@ -80,7 +80,7 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
var finalPoints = points.GetRange(start, endIdx - start + 1);
|
||||
var sweep = System.Math.Abs(SumSignedAngles(center, finalPoints));
|
||||
var sweep = System.Math.Abs(ArcFit.SumSignedAngles(center, finalPoints));
|
||||
if (sweep < Angle.ToRadians(5))
|
||||
return null;
|
||||
|
||||
@@ -151,27 +151,10 @@ namespace OpenNest.Geometry
|
||||
double radius
|
||||
) => ArcFit.MaxRadialDeviation(points, cx, cy, radius);
|
||||
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI)
|
||||
da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI)
|
||||
da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
|
||||
{
|
||||
var lastPt = points[^1];
|
||||
var totalAngle = SumSignedAngles(center, points);
|
||||
var totalAngle = ArcFit.SumSignedAngles(center, points);
|
||||
|
||||
var rx = lastPt.X - center.X;
|
||||
var ry = lastPt.Y - center.Y;
|
||||
@@ -186,7 +169,7 @@ namespace OpenNest.Geometry
|
||||
|
||||
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X);
|
||||
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var isReversed = ArcFit.SumSignedAngles(center, points) < 0;
|
||||
|
||||
if (startAngle < 0)
|
||||
startAngle += Angle.TwoPI;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// A post-processor whose <see cref="IPostProcessor.Post(Nest, string)"/>
|
||||
/// can write more than one file (for example one program per sheet).
|
||||
/// </summary>
|
||||
public interface IMultiFilePostProcessor : IPostProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// The files <see cref="IPostProcessor.Post(Nest, string)"/> will write
|
||||
/// for this nest and chosen path, in order, with the current settings.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> GetOutputFiles(Nest nest, string outputFile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Optional post contract for nest-level rapid verification. Opt in only when the
|
||||
/// post preserves Plate.Parts order and each placed Program's contour order and
|
||||
/// pierce positions. This does not certify retract height, parking or controller macros.
|
||||
/// Unknown/reordering posts still get nest diagnostics, but require acknowledgment
|
||||
/// that their final rapid sequence has not been verified.
|
||||
/// </summary>
|
||||
public interface IPostVerificationSupport
|
||||
{
|
||||
bool PreservesPlacedProgramOrder { get; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace OpenNest.Math
|
||||
namespace OpenNest.Math
|
||||
{
|
||||
public static class Angle
|
||||
{
|
||||
@@ -106,14 +106,14 @@
|
||||
if (reversed)
|
||||
Generic.Swap(ref a1, ref a2);
|
||||
|
||||
var diff = Angle.NormalizeRad(a2 - a1);
|
||||
var diff = Angle.NormalizeDeg(a2 - a1);
|
||||
|
||||
// full circle
|
||||
if (a2.IsEqualTo(a1))
|
||||
return true;
|
||||
|
||||
a1 = Angle.NormalizeRad(angle - a1);
|
||||
a2 = Angle.NormalizeRad(a2 - angle);
|
||||
a1 = Angle.NormalizeDeg(angle - a1);
|
||||
a2 = Angle.NormalizeDeg(a2 - angle);
|
||||
|
||||
return diff >= a1 - Tolerance.Epsilon || diff >= a2 - Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ namespace OpenNest.Math
|
||||
public static class Fraction
|
||||
{
|
||||
public static readonly Regex FractionRegex = new Regex(
|
||||
@"((?<WholeNum>\d+)(\ |-))?(?<Fraction>\d+\/\d+)"
|
||||
@"((?<WholeNum>\d+)(\ |-))?(?<Fraction>\d+\/\d+)",
|
||||
RegexOptions.None,
|
||||
TimeSpan.FromMilliseconds(250)
|
||||
);
|
||||
|
||||
public static bool IsValid(string s)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
@@ -38,6 +38,10 @@ namespace OpenNest
|
||||
|
||||
public string AssistGas { get; set; } = "";
|
||||
|
||||
public NestStatus Status { get; set; } = NestStatus.Quote;
|
||||
|
||||
public string MadeBy { get; set; } = "";
|
||||
|
||||
public double Thickness { get; set; }
|
||||
|
||||
public Material Material { get; set; }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Shop-floor workflow state of a saved nest, persisted as a string in
|
||||
/// <c>nest.json</c> so unknown future values can fall back safely.
|
||||
/// </summary>
|
||||
public enum NestStatus
|
||||
{
|
||||
Quote,
|
||||
ToBeCut,
|
||||
HasBeenCut,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||
<InternalsVisibleTo Include="OpenNest.Engine" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Clipper2" Version="2.0.0" />
|
||||
|
||||
+86
-2
@@ -111,6 +111,63 @@ namespace OpenNest
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs an owned, already-rotated saved cutting program without rotating it again.
|
||||
/// The current pose remains the clean-drawing pose used by RemoveLeadIns.
|
||||
/// </summary>
|
||||
public bool RestoreLeadInProgram(Program program, bool locked)
|
||||
{
|
||||
if (program == null || !program.Codes.Any(code => code is Motion
|
||||
|| code is SubProgramCall call && call.Program != null
|
||||
&& call.Program.Codes.Any(subCode => subCode is Motion)))
|
||||
return false;
|
||||
|
||||
// Compute before changing state, so a malformed program cannot half-install.
|
||||
var bounds = program.BoundingBox();
|
||||
bounds.Offset(Location);
|
||||
preLeadInRotation = Rotation;
|
||||
Program = program;
|
||||
ownsProgram = true;
|
||||
HasManualLeadIns = true;
|
||||
LeadInsLocked = locked;
|
||||
CuttingParameters = null;
|
||||
BoundingBox = bounds;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Exact cutting-state record for freshness and rollback; references are not copied.</summary>
|
||||
internal CNC.CuttingPlanning.PartCuttingState CaptureCuttingState() =>
|
||||
new(Program, ownsProgram, preLeadInRotation, HasManualLeadIns, LeadInsLocked,
|
||||
CuttingParameters, location, BoundingBox);
|
||||
|
||||
/// <summary>Reinstates a state captured from this part, field for field.</summary>
|
||||
internal void RestoreCuttingState(CNC.CuttingPlanning.PartCuttingState state)
|
||||
{
|
||||
Program = state.Program;
|
||||
ownsProgram = state.OwnsProgram;
|
||||
preLeadInRotation = state.PreLeadInRotation;
|
||||
HasManualLeadIns = state.HasManualLeadIns;
|
||||
LeadInsLocked = state.LeadInsLocked;
|
||||
CuttingParameters = state.CuttingParameters;
|
||||
location = state.Location;
|
||||
BoundingBox = state.BoundingBox;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs an owned, prevalidated planned program and its precomputed placed bounds.
|
||||
/// Pose and lock are unchanged; nothing is regenerated or rotated here.
|
||||
/// </summary>
|
||||
internal void InstallPlannedProgram(Program program, Box bounds,
|
||||
CNC.CuttingStrategy.CuttingParameters parameters)
|
||||
{
|
||||
preLeadInRotation = Rotation;
|
||||
Program = program;
|
||||
ownsProgram = true;
|
||||
CuttingParameters = parameters;
|
||||
HasManualLeadIns = true;
|
||||
BoundingBox = bounds;
|
||||
}
|
||||
|
||||
public void RemoveLeadIns()
|
||||
{
|
||||
var rotation = preLeadInRotation;
|
||||
@@ -145,7 +202,7 @@ namespace OpenNest
|
||||
EnsureOwnedProgram();
|
||||
Program.Rotate(angle);
|
||||
location = Location.Rotate(angle);
|
||||
preLeadInRotation = Program.Rotation;
|
||||
TrackRotation(angle);
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
@@ -159,10 +216,22 @@ namespace OpenNest
|
||||
EnsureOwnedProgram();
|
||||
Program.Rotate(angle);
|
||||
location = Location.Rotate(angle, origin);
|
||||
preLeadInRotation = Program.Rotation;
|
||||
TrackRotation(angle);
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the part's rotation after it turned by <paramref name="angle"/>. A lead-in
|
||||
/// program is rebuilt by the cutting strategy and starts over at zero program
|
||||
/// rotation, so for those parts the rotation is accumulated rather than read back.
|
||||
/// </summary>
|
||||
private void TrackRotation(double angle)
|
||||
{
|
||||
preLeadInRotation = HasManualLeadIns
|
||||
? Angle.NormalizeRad(preLeadInRotation + angle)
|
||||
: Program.Rotation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets the part.
|
||||
/// </summary>
|
||||
@@ -323,6 +392,7 @@ namespace OpenNest
|
||||
new Box(BoundingBox.X, BoundingBox.Y, BoundingBox.Length, BoundingBox.Width)
|
||||
);
|
||||
part.ownsProgram = true;
|
||||
part.CopyLeadInStateFrom(this);
|
||||
|
||||
return part;
|
||||
}
|
||||
@@ -347,10 +417,24 @@ namespace OpenNest
|
||||
BoundingBox.Width
|
||||
)
|
||||
);
|
||||
part.CopyLeadInStateFrom(this);
|
||||
|
||||
return part;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the lead-in state that goes with a copied program. Without it a copy of a
|
||||
/// lead-in part reads its rotation from the rebuilt program (zero), and lead-in
|
||||
/// assignment does not know to strip the copied lead-ins before adding new ones.
|
||||
/// </summary>
|
||||
private void CopyLeadInStateFrom(Part source)
|
||||
{
|
||||
HasManualLeadIns = source.HasManualLeadIns;
|
||||
LeadInsLocked = source.LeadInsLocked;
|
||||
CuttingParameters = source.CuttingParameters;
|
||||
preLeadInRotation = source.preLeadInRotation;
|
||||
}
|
||||
|
||||
private void EnsureOwnedProgram()
|
||||
{
|
||||
if (!ownsProgram)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Drawing colors: golden-angle hue walk (skipping the etch-green band)
|
||||
/// cycled through eight saturation/lightness tiers for wide separation.
|
||||
/// </summary>
|
||||
public static class PartColorPalette
|
||||
{
|
||||
// (lightness, saturation) tiers cycled on an irrational stride so any
|
||||
// window of neighboring parts spans clearly different tones.
|
||||
private static readonly (double Lightness, double Saturation)[] Tiers =
|
||||
{
|
||||
(0.52, 0.68), (0.64, 0.46), (0.75, 0.54), (0.61, 0.87),
|
||||
(0.79, 0.97), (0.43, 0.82), (0.65, 0.76), (0.48, 0.97),
|
||||
};
|
||||
|
||||
// Plastic-number stride: tier(i) never falls into a short repeating phase with the hue walk.
|
||||
private const double TierStride = 0.7548776662466927;
|
||||
|
||||
// Hues land in [0,95) + [170,295) mapped onto the golden-angle cycle,
|
||||
// keeping fills out of the bright-green etch band.
|
||||
private const double HueSpan = 295.0;
|
||||
private const double BandStart = 95.0;
|
||||
private const double BandWidth = 75.0;
|
||||
|
||||
public static Color GoldenAngle(int index)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||
var hue = index * 137.508 % HueSpan;
|
||||
if (hue >= BandStart)
|
||||
hue += BandWidth;
|
||||
var (lightness, saturation) = Tiers[
|
||||
unchecked((int)(Tiers.Length * (index * TierStride % 1.0)) % Tiers.Length)];
|
||||
|
||||
var q = lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
var p = 2 * lightness - q;
|
||||
|
||||
int Channel(double t)
|
||||
{
|
||||
t = (t % 1 + 1) % 1;
|
||||
var value = t < 1 / 6.0 ? p + (q - p) * 6 * t
|
||||
: t < 0.5 ? q
|
||||
: t < 2 / 3.0 ? p + (q - p) * (2 / 3.0 - t) * 6
|
||||
: p;
|
||||
return (int)System.Math.Round(value * 255);
|
||||
}
|
||||
|
||||
var h = hue / 360.0;
|
||||
return Color.FromArgb(Channel(h + 1 / 3.0), Channel(h), Channel(h - 1 / 3.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,20 +31,9 @@ namespace OpenNest
|
||||
double chordTolerance = 0.001
|
||||
)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var shapes = ShapeBuilder.GetShapes(
|
||||
entities.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
return GetDirectionalPartLines(
|
||||
part, chordTolerance, useVector: false, facingDirection, default
|
||||
);
|
||||
var lines = new List<Line>();
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var polygon = shape.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.Offset(part.Location);
|
||||
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -162,6 +151,19 @@ namespace OpenNest
|
||||
Vector facingDirection,
|
||||
double chordTolerance = 0.001
|
||||
)
|
||||
{
|
||||
return GetDirectionalPartLines(
|
||||
part, chordTolerance, useVector: true, default, facingDirection
|
||||
);
|
||||
}
|
||||
|
||||
private static List<Line> GetDirectionalPartLines(
|
||||
Part part,
|
||||
double chordTolerance,
|
||||
bool useVector,
|
||||
PushDirection cardinalDirection,
|
||||
Vector vectorDirection
|
||||
)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var shapes = ShapeBuilder.GetShapes(
|
||||
@@ -173,7 +175,10 @@ namespace OpenNest
|
||||
{
|
||||
var polygon = shape.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.Offset(part.Location);
|
||||
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
||||
// Keep the overload-specific arithmetic and invalid-direction behavior.
|
||||
lines.AddRange(useVector
|
||||
? GetDirectionalLines(polygon, vectorDirection)
|
||||
: GetDirectionalLines(polygon, cardinalDirection));
|
||||
}
|
||||
|
||||
return lines;
|
||||
|
||||
+55
-7
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Collections;
|
||||
@@ -30,6 +30,17 @@ namespace OpenNest
|
||||
remove { Parts.ItemChanged -= value; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised once after the part order changes without adding or removing parts, including
|
||||
/// a committed cutting plan that also installed planned programs. Refresh order- and
|
||||
/// program-derived views; quantity accounting is unchanged.
|
||||
/// </summary>
|
||||
public event EventHandler PartsReordered
|
||||
{
|
||||
add { Parts.ItemsReordered += value; }
|
||||
remove { Parts.ItemsReordered -= value; }
|
||||
}
|
||||
|
||||
public Plate()
|
||||
: this(60, 120) { }
|
||||
|
||||
@@ -94,18 +105,40 @@ namespace OpenNest
|
||||
/// <summary>
|
||||
/// Regenerates all cut-off drawings and materializes them as parts.
|
||||
/// Existing cut-off parts are removed first, then each cut-off is
|
||||
/// regenerated and added back if it produces any geometry.
|
||||
/// regenerated and put back at the same place in the cut sequence
|
||||
/// (<see cref="Parts"/> order). New cut-offs are added at the end.
|
||||
/// </summary>
|
||||
public void RegenerateCutOffs(CutOffSettings settings)
|
||||
{
|
||||
// Remove existing cut-off parts
|
||||
// Remember each cut-off's place in the cut sequence, so a part drag
|
||||
// or cut-off move doesn't send it to the end of the sequence.
|
||||
var sequence = new Dictionary<CutOff, int>();
|
||||
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (Parts[i].BaseDrawing.IsCutOff)
|
||||
Parts.RemoveAt(i);
|
||||
if (!Parts[i].BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
|
||||
if (cutoff != null)
|
||||
sequence[cutoff] = i;
|
||||
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
RegenerateCutOffs(settings, sequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regenerates all cut-off drawings and materializes them as parts, placing
|
||||
/// each cut-off at its index in <paramref name="sequence"/> (its place in
|
||||
/// <see cref="Parts"/> order). Cut-offs missing from it are added at the end.
|
||||
/// Callers must remove existing cut-off parts first.
|
||||
/// </summary>
|
||||
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence)
|
||||
{
|
||||
var cache = BuildPerimeterCache(this);
|
||||
var placed = new List<(int Index, Part Part)>();
|
||||
|
||||
// Regenerate and materialize each cut-off
|
||||
foreach (var cutoff in CutOffs)
|
||||
@@ -116,8 +149,17 @@ namespace OpenNest
|
||||
continue;
|
||||
|
||||
var part = new Part(cutoff.Drawing);
|
||||
Parts.Add(part);
|
||||
|
||||
if (sequence != null && sequence.TryGetValue(cutoff, out var index))
|
||||
placed.Add((index, part));
|
||||
else
|
||||
Parts.Add(part);
|
||||
}
|
||||
|
||||
// Lowest index first: each insert then lands on its saved index, because
|
||||
// every part sequenced before it is already in place.
|
||||
foreach (var (index, part) in placed.OrderBy(p => p.Index))
|
||||
Parts.Insert(System.Math.Clamp(index, 0, Parts.Count), part);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -143,7 +185,13 @@ namespace OpenNest
|
||||
|
||||
if (entities.Count > 0)
|
||||
{
|
||||
var profile = new Geometry.ShapeProfile(entities);
|
||||
// Leads are cutting paths in scrap, not boundary edges. Chaining them
|
||||
// into the outline can make a closed part appear open and replace its
|
||||
// recesses with a convex hull. Keep the full geometry for the existing
|
||||
// conservative fallback when the material contour really is open.
|
||||
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
|
||||
e.Layer != SpecialLayers.Leadout).ToList();
|
||||
var profile = new Geometry.ShapeProfile(outline);
|
||||
|
||||
if (profile.Perimeter.IsClosed())
|
||||
{
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Display-only plate numbering for labels. The editor keeps one trailing empty plate as the
|
||||
/// "new plate" workspace (PlateManager.EnsureSentinel), and that sentinel is excluded from the
|
||||
/// shown total so a one-plate nest reads "Plate 1 of 1". Navigation, storage indexes, exported
|
||||
/// names and batch selection keep using the real collection positions; an interior empty plate
|
||||
/// keeps its slot and its number.
|
||||
/// </summary>
|
||||
public static class PlateDisplayNumbering
|
||||
{
|
||||
/// <summary>
|
||||
/// The number shown for the plate at <paramref name="storageIndex"/>: real collection
|
||||
/// positions count as 1-based plate numbers. Returns null for the trailing "new plate"
|
||||
/// sentinel and for indexes outside the collection.
|
||||
/// </summary>
|
||||
public static int? DisplayedPlateNumber(IList<Plate> plates, int storageIndex)
|
||||
{
|
||||
if (plates == null || storageIndex < 0 || storageIndex >= plates.Count)
|
||||
return null;
|
||||
if (IsTrailingSentinel(plates, storageIndex))
|
||||
return null;
|
||||
return storageIndex + 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The number shown in an "of M" total: the plates minus only a trailing empty sentinel.
|
||||
/// An all-empty collection shows nothing (the caller says "No plates").
|
||||
/// </summary>
|
||||
public static int DisplayedPlateCount(IList<Plate> plates)
|
||||
{
|
||||
if (plates == null || plates.Count == 0)
|
||||
return 0;
|
||||
var last = plates[plates.Count - 1];
|
||||
return plates.Count - (last != null && last.Parts.Count == 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="storageIndex"/> is the trailing empty new-plate sentinel.</summary>
|
||||
public static bool IsTrailingSentinel(IList<Plate> plates, int storageIndex)
|
||||
{
|
||||
if (plates == null || plates.Count == 0)
|
||||
return false;
|
||||
if (storageIndex != plates.Count - 1)
|
||||
return false;
|
||||
var plate = plates[storageIndex];
|
||||
return plate != null && plate.Parts.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>Header text for the plate at <paramref name="storageIndex"/>.</summary>
|
||||
public static string FormatHeader(IList<Plate> plates, int storageIndex, string plateSizeText)
|
||||
{
|
||||
var displayed = DisplayedPlateNumber(plates, storageIndex);
|
||||
if (displayed == null)
|
||||
return IsTrailingSentinel(plates, storageIndex)
|
||||
? "New plate (empty)"
|
||||
: "No plates";
|
||||
return string.IsNullOrEmpty(plateSizeText)
|
||||
? string.Format("Plate {0} of {1}", displayed.Value, DisplayedPlateCount(plates))
|
||||
: string.Format("Plate {0} of {1} | {2}", displayed.Value, DisplayedPlateCount(plates), plateSizeText);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class PlateOptimizerResult
|
||||
{
|
||||
public List<Part> Parts { get; set; } = new();
|
||||
public PlateOption ChosenSize { get; set; }
|
||||
public double NetCost { get; set; }
|
||||
public double Utilization { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest.PostSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Places a post-processor config property in a section of the desktop
|
||||
/// settings editor. A config type opts into the sectioned editor by marking
|
||||
/// at least one property; unmarked configs keep the generic PropertyGrid.
|
||||
/// Label and help text come from <c>DisplayName</c> and <c>Description</c>.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
|
||||
public sealed class PostSettingAttribute : Attribute
|
||||
{
|
||||
public PostSettingAttribute(string section, int order = 0)
|
||||
{
|
||||
Section = section;
|
||||
Order = order;
|
||||
}
|
||||
|
||||
public string Section { get; }
|
||||
|
||||
public int Order { get; }
|
||||
|
||||
/// <summary>Lower bound for numeric fields; NaN uses the kind's default.</summary>
|
||||
public double Minimum { get; set; } = double.NaN;
|
||||
|
||||
/// <summary>Upper bound for numeric fields; NaN uses the kind's default.</summary>
|
||||
public double Maximum { get; set; } = double.NaN;
|
||||
|
||||
/// <summary>Decimal places for decimal fields; negative uses the default.</summary>
|
||||
public int DecimalPlaces { get; set; } = -1;
|
||||
|
||||
/// <summary>Column header for the key of a name/value table.</summary>
|
||||
public string KeyHeader { get; set; }
|
||||
|
||||
/// <summary>Column header for the value of a name/value table.</summary>
|
||||
public string ValueHeader { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Declares a settings section's order and description.</summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
|
||||
public sealed class PostSettingsSectionAttribute : Attribute
|
||||
{
|
||||
public PostSettingsSectionAttribute(string name, int order)
|
||||
{
|
||||
Name = name;
|
||||
Order = order;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public int Order { get; }
|
||||
|
||||
public string Description { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace OpenNest.PostSettings
|
||||
{
|
||||
public enum PostSettingKind
|
||||
{
|
||||
Text,
|
||||
Integer,
|
||||
Decimal,
|
||||
Boolean,
|
||||
Choice,
|
||||
StringMap,
|
||||
}
|
||||
|
||||
public sealed class PostSettingsField
|
||||
{
|
||||
internal PostSettingsField(
|
||||
PropertyInfo property,
|
||||
PostSettingKind kind,
|
||||
PostSettingAttribute setting
|
||||
)
|
||||
{
|
||||
Property = property;
|
||||
Kind = kind;
|
||||
Label =
|
||||
property.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName
|
||||
?? property.Name;
|
||||
Description = property.GetCustomAttribute<DescriptionAttribute>()?.Description ?? "";
|
||||
|
||||
var (min, max) = DefaultRange(kind);
|
||||
Minimum = setting != null && !double.IsNaN(setting.Minimum) ? setting.Minimum : min;
|
||||
Maximum = setting != null && !double.IsNaN(setting.Maximum) ? setting.Maximum : max;
|
||||
DecimalPlaces =
|
||||
kind == PostSettingKind.Integer ? 0
|
||||
: setting != null && setting.DecimalPlaces >= 0 ? setting.DecimalPlaces
|
||||
: 3;
|
||||
KeyHeader = setting?.KeyHeader ?? "Name";
|
||||
ValueHeader = setting?.ValueHeader ?? "Value";
|
||||
}
|
||||
|
||||
public PropertyInfo Property { get; }
|
||||
|
||||
public string Name => Property.Name;
|
||||
|
||||
public PostSettingKind Kind { get; }
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public string Description { get; }
|
||||
|
||||
public double Minimum { get; }
|
||||
|
||||
public double Maximum { get; }
|
||||
|
||||
public int DecimalPlaces { get; }
|
||||
|
||||
public string KeyHeader { get; }
|
||||
|
||||
public string ValueHeader { get; }
|
||||
|
||||
public string[] ChoiceNames =>
|
||||
Kind == PostSettingKind.Choice
|
||||
? Enum.GetNames(Property.PropertyType)
|
||||
: Array.Empty<string>();
|
||||
|
||||
public object GetValue(object config) => Property.GetValue(config);
|
||||
|
||||
public void SetValue(object config, object value) => Property.SetValue(config, value);
|
||||
|
||||
private static (double, double) DefaultRange(PostSettingKind kind) =>
|
||||
kind == PostSettingKind.Integer ? (int.MinValue, int.MaxValue) : (-1e9, 1e9);
|
||||
}
|
||||
|
||||
public sealed class PostSettingsSection
|
||||
{
|
||||
internal PostSettingsSection(
|
||||
string name,
|
||||
string description,
|
||||
IReadOnlyList<PostSettingsField> fields
|
||||
)
|
||||
{
|
||||
Name = name;
|
||||
Description = description ?? "";
|
||||
Fields = fields;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string Description { get; }
|
||||
|
||||
public IReadOnlyList<PostSettingsField> Fields { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the sectioned settings layout for a post-processor config from its
|
||||
/// <see cref="PostSettingAttribute"/> metadata, without any UI dependency.
|
||||
/// </summary>
|
||||
public static class PostSettingsLayout
|
||||
{
|
||||
public const string OtherSection = "Other";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ordered sections, or null when the config should use the
|
||||
/// generic PropertyGrid: it has no <see cref="PostSettingAttribute"/>,
|
||||
/// or one of its editable properties has a type the editor cannot show.
|
||||
/// Editable properties without the attribute go to the
|
||||
/// <see cref="OtherSection"/> so none become uneditable.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PostSettingsSection> TryBuild(Type configType)
|
||||
{
|
||||
if (configType == null)
|
||||
return null;
|
||||
|
||||
var properties = configType
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p =>
|
||||
p.CanRead
|
||||
&& p.GetSetMethod() != null
|
||||
&& p.GetIndexParameters().Length == 0
|
||||
&& p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false
|
||||
)
|
||||
.OrderBy(p => p.MetadataToken)
|
||||
.ToList();
|
||||
|
||||
if (!properties.Any(p => p.GetCustomAttribute<PostSettingAttribute>() != null))
|
||||
return null;
|
||||
|
||||
var entries = new List<(string Section, int Order, int Index, PostSettingsField Field)>();
|
||||
for (var i = 0; i < properties.Count; i++)
|
||||
{
|
||||
var property = properties[i];
|
||||
var kind = KindOf(property.PropertyType);
|
||||
if (kind == null)
|
||||
return null;
|
||||
|
||||
var setting = property.GetCustomAttribute<PostSettingAttribute>();
|
||||
var section = string.IsNullOrWhiteSpace(setting?.Section)
|
||||
? OtherSection
|
||||
: setting.Section;
|
||||
entries.Add(
|
||||
(
|
||||
section,
|
||||
setting?.Order ?? int.MaxValue,
|
||||
i,
|
||||
new PostSettingsField(property, kind.Value, setting)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
var declared = configType
|
||||
.GetCustomAttributes<PostSettingsSectionAttribute>()
|
||||
.GroupBy(a => a.Name, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
|
||||
return entries
|
||||
.GroupBy(e => e.Section, StringComparer.Ordinal)
|
||||
.Select(g => new
|
||||
{
|
||||
Name = g.Key,
|
||||
Declared = declared.TryGetValue(g.Key, out var d) ? d : null,
|
||||
FirstIndex = g.Min(e => e.Index),
|
||||
Fields = g.OrderBy(e => e.Order).ThenBy(e => e.Index).Select(e => e.Field).ToList(),
|
||||
})
|
||||
.OrderBy(s => s.Name == OtherSection && s.Declared == null ? 2
|
||||
: s.Declared != null ? 0
|
||||
: 1)
|
||||
.ThenBy(s => s.Declared?.Order ?? 0)
|
||||
.ThenBy(s => s.FirstIndex)
|
||||
.Select(s => new PostSettingsSection(s.Name, s.Declared?.Description, s.Fields))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a name/value map from edited table rows using the supplied key
|
||||
/// comparer. Keys and values are trimmed; fully blank rows are skipped.
|
||||
/// Throws <see cref="FormatException"/> naming the 1-based row for a
|
||||
/// missing or duplicate name.
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> BuildMap(
|
||||
IEnumerable<KeyValuePair<string, string>> rows,
|
||||
IEqualityComparer<string> comparer
|
||||
)
|
||||
{
|
||||
var map = new Dictionary<string, string>(comparer ?? StringComparer.Ordinal);
|
||||
var row = 0;
|
||||
|
||||
foreach (var entry in rows ?? Enumerable.Empty<KeyValuePair<string, string>>())
|
||||
{
|
||||
row++;
|
||||
var key = entry.Key?.Trim() ?? "";
|
||||
var value = entry.Value?.Trim() ?? "";
|
||||
|
||||
if (key.Length == 0 && value.Length == 0)
|
||||
continue;
|
||||
if (key.Length == 0)
|
||||
throw new FormatException($"Row {row}: a name is required.");
|
||||
if (map.ContainsKey(key))
|
||||
throw new FormatException($"Row {row}: \"{key}\" is listed more than once.");
|
||||
|
||||
map.Add(key, value);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static PostSettingKind? KindOf(Type type)
|
||||
{
|
||||
if (type == typeof(string))
|
||||
return PostSettingKind.Text;
|
||||
if (type == typeof(int))
|
||||
return PostSettingKind.Integer;
|
||||
if (type == typeof(double))
|
||||
return PostSettingKind.Decimal;
|
||||
if (type == typeof(bool))
|
||||
return PostSettingKind.Boolean;
|
||||
if (type.IsEnum)
|
||||
return PostSettingKind.Choice;
|
||||
if (
|
||||
type == typeof(Dictionary<string, string>)
|
||||
|| type == typeof(IDictionary<string, string>)
|
||||
)
|
||||
return PostSettingKind.StringMap;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Last-used Auto Nest engine, stored separately from nest/plate defaults.
|
||||
/// Loading does not resolve the name: the host must finish plug-in discovery first.
|
||||
/// </summary>
|
||||
public sealed class EngineSelectionSettings
|
||||
{
|
||||
public const string DefaultEngineName = "Default";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public string EngineName { get; set; } = DefaultEngineName;
|
||||
|
||||
/// <summary>%APPDATA%\OpenNest\engine-selection.json.</summary>
|
||||
public static string DefaultPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenNest", "engine-selection.json");
|
||||
|
||||
/// <summary>Missing, unreadable or corrupt settings safely use Default without writing.</summary>
|
||||
public static EngineSelectionSettings Load(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return new();
|
||||
|
||||
try
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<EngineSelectionSettings>(
|
||||
File.ReadAllText(path), JsonOptions) ?? new();
|
||||
settings.EngineName = NormalizeName(settings.EngineName);
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves against the host's selectable engines AFTER plug-in loading. A missing engine
|
||||
/// returns Default plus a status-bar warning, without replacing the saved preference.
|
||||
/// Names use registry casing so desktop combo-box selection remains exact. When the saved
|
||||
/// name is not selectable, <paramref name="renamed"/> (the registry's legacy-name lookup)
|
||||
/// may map it to the engine that replaced it; the result must itself be selectable.
|
||||
/// </summary>
|
||||
public string Resolve(
|
||||
IEnumerable<string> availableEngineNames,
|
||||
out string? statusMessage,
|
||||
Func<string, string?>? renamed = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(availableEngineNames);
|
||||
var available = availableEngineNames.ToList();
|
||||
var requestedName = NormalizeName(EngineName);
|
||||
var registeredName = Find(available, requestedName)
|
||||
?? (renamed?.Invoke(requestedName) is { } replacement ? Find(available, replacement) : null);
|
||||
if (registeredName is not null)
|
||||
{
|
||||
statusMessage = null;
|
||||
return registeredName;
|
||||
}
|
||||
|
||||
statusMessage = $"Saved Auto Nest engine '{requestedName}' is unavailable. Using Default.";
|
||||
return DefaultEngineName;
|
||||
}
|
||||
|
||||
private static string? Find(IEnumerable<string> names, string name) =>
|
||||
names.FirstOrDefault(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Writes camelCase JSON, creating the parent directory and retrying IO collisions as
|
||||
/// LocalJsonProvider does. The host handles a persistent write failure.
|
||||
/// </summary>
|
||||
public void Save(string path)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new EngineSelectionSettings { EngineName = NormalizeName(EngineName) }, JsonOptions);
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
|
||||
for (var attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < 2)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeName(string? name) =>
|
||||
string.IsNullOrWhiteSpace(name) ? DefaultEngineName : name.Trim();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Storage backend for saved nests. <see cref="RemoteNestRepository"/> talks to the
|
||||
/// central nest server; File mode in the desktop app keeps using SaveFileDialog and
|
||||
/// does not go through this interface.
|
||||
/// </summary>
|
||||
public interface INestRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// All stored nests, newest saved first. This is the full-enumeration contract for
|
||||
/// backup and verification; browsing uses <see cref="QueryAsync"/>.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>One bounded page of nests matching the query, filtered and ordered by the server.</summary>
|
||||
Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Downloaded .nest archive bytes, or null when the id does not exist.</summary>
|
||||
Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Stores a new nest; returns the server-assigned record.</summary>
|
||||
Task<NestRecord> UploadAsync(
|
||||
byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Replaces the archive and metadata of an existing id, keeping the id.</summary>
|
||||
Task<NestRecord> UpdateFileAsync(
|
||||
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Replaces metadata only (status, made-by, comments...), leaving the archive.</summary>
|
||||
Task<NestRecord> UpdateMetadataAsync(
|
||||
Guid id, NestRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Browse state for the Database-mode Open dialog: the requested search, sort and page,
|
||||
/// and the last page applied. Every change issues one bounded server query. A response
|
||||
/// that a later request superseded is discarded (and the earlier request is cancelled),
|
||||
/// so an older result can never replace a newer one. Call from one thread (the UI thread).
|
||||
/// </summary>
|
||||
public sealed class NestBrowseSession : IDisposable
|
||||
{
|
||||
private readonly INestRepository _repository;
|
||||
private CancellationTokenSource? _pending;
|
||||
private int _generation;
|
||||
private bool _disposed;
|
||||
|
||||
public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
if (pageSize < 1 || pageSize > NestQuery.MaxLimit)
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {NestQuery.MaxLimit}.");
|
||||
|
||||
_repository = repository;
|
||||
PageSize = pageSize;
|
||||
}
|
||||
|
||||
public int PageSize { get; }
|
||||
|
||||
/// <summary>Trimmed search text of the latest request.</summary>
|
||||
public string Search { get; private set; } = "";
|
||||
|
||||
public NestSortField Sort { get; private set; } = NestSortField.SavedAt;
|
||||
|
||||
public bool Descending { get; private set; } = true;
|
||||
|
||||
/// <summary>Offset of the latest request.</summary>
|
||||
public int Offset { get; private set; }
|
||||
|
||||
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
|
||||
public NestPage? Page { get; private set; }
|
||||
|
||||
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
|
||||
public bool IsLoading { get; private set; }
|
||||
|
||||
public bool CanGoPrevious => Offset > 0;
|
||||
|
||||
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
|
||||
|
||||
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 nests".</summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Page is not { } page)
|
||||
return "";
|
||||
if (page.Total == 0)
|
||||
return Search.Length == 0 ? "No nests on the server." : "No nests match the filter.";
|
||||
if (page.Items.Count == 0)
|
||||
return $"No nests on this page ({Count(page.Total)} in total).";
|
||||
|
||||
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} nests";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies new search text and returns to the first page.</summary>
|
||||
public Task<bool> SetSearchAsync(string? search)
|
||||
{
|
||||
Search = (search ?? "").Trim();
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
|
||||
public Task<bool> SortByAsync(NestSortField field)
|
||||
{
|
||||
Descending = field == Sort && !Descending;
|
||||
Sort = field;
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> NextPageAsync()
|
||||
{
|
||||
if (!CanGoNext)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = Page!.Offset + PageSize;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> PreviousPageAsync()
|
||||
{
|
||||
if (!CanGoPrevious)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = System.Math.Max(0, Offset - PageSize);
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
|
||||
public Task<bool> RefreshAsync() => RunAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Sends the current request. Returns true when its page was applied, false when a later
|
||||
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
|
||||
/// failures of superseded requests are ignored.
|
||||
/// </summary>
|
||||
private async Task<bool> RunAsync()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
// Supersede before cancelling: a request that completes synchronously when cancelled
|
||||
// must already see itself as superseded. Each source is released by its own request.
|
||||
var previous = _pending;
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
_pending = cancellation;
|
||||
var generation = ++_generation;
|
||||
IsLoading = true;
|
||||
|
||||
try
|
||||
{
|
||||
previous?.Cancel();
|
||||
var page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
|
||||
// A deletion or another PC can leave the requested page past the end.
|
||||
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
|
||||
{
|
||||
Offset = (page.Total - 1) / PageSize * PageSize;
|
||||
page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
}
|
||||
|
||||
Page = page;
|
||||
return true;
|
||||
}
|
||||
catch (Exception) when (generation != _generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Page = null;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_pending, cancellation))
|
||||
_pending = null;
|
||||
if (generation == _generation)
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task<NestPage> QueryAsync(CancellationToken cancellationToken) =>
|
||||
_repository.QueryAsync(
|
||||
new NestQuery
|
||||
{
|
||||
Search = Search,
|
||||
Sort = Sort,
|
||||
Descending = Descending,
|
||||
Offset = Offset,
|
||||
Limit = PageSize,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels any request in flight (which releases its own source when it ends);
|
||||
/// the repository is not owned and stays open.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_generation++;
|
||||
IsLoading = false;
|
||||
var pending = _pending;
|
||||
_pending = null;
|
||||
pending?.Cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>Outcome of <see cref="NestDefaults.Load(string, out NestDefaultsStatus)"/>.</summary>
|
||||
public enum NestDefaultsStatus
|
||||
{
|
||||
/// <summary>Defaults were read from the file (invalid fields still fall back individually).</summary>
|
||||
Ok,
|
||||
|
||||
/// <summary>No file exists at the path; built-in fallback values were used.</summary>
|
||||
Missing,
|
||||
|
||||
/// <summary>The file exists but could not be read or parsed; fallback values were used.</summary>
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plate/nest defaults persisted to a single JSON file
|
||||
/// (by default %APPDATA%\OpenNest\defaults.json), replacing the
|
||||
/// .nstdot nest-template mechanism. Loading never throws: a missing,
|
||||
/// corrupt, or partially valid file degrades field-by-field to
|
||||
/// <see cref="Fallback"/> values so creating a new nest is never blocked.
|
||||
/// </summary>
|
||||
public sealed class NestDefaults
|
||||
{
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
public Units Units { get; set; } = Units.Inches;
|
||||
|
||||
public Size Size { get; set; } = new(100, 100);
|
||||
|
||||
public int Quadrant { get; set; } = 1;
|
||||
|
||||
public double PartSpacing { get; set; } = 1;
|
||||
|
||||
public Spacing EdgeSpacing { get; set; } = new(1, 1, 1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// The built-in defaults used when no file exists and for every field
|
||||
/// that is missing or invalid. Matches the historical
|
||||
/// MainForm.CreateDefaultNest values (units default to Inches; callers
|
||||
/// may override from their own settings).
|
||||
/// </summary>
|
||||
public static NestDefaults Fallback => new();
|
||||
|
||||
/// <summary>%APPDATA%\OpenNest\defaults.json.</summary>
|
||||
public static string DefaultPath =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenNest",
|
||||
"defaults.json"
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Loads defaults from <paramref name="path"/>, falling back field by
|
||||
/// field for a missing file, invalid JSON, or invalid values.
|
||||
/// </summary>
|
||||
public static NestDefaults Load(string path) => Load(path, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Loads defaults and reports whether the file was missing, loaded, or
|
||||
/// present but unreadable/invalid, so callers can warn about a corrupt
|
||||
/// file while still returning usable values.
|
||||
/// </summary>
|
||||
public static NestDefaults Load(string path, out NestDefaultsStatus status) =>
|
||||
Load(path, Fallback.Units, out status);
|
||||
|
||||
/// <summary>
|
||||
/// Loads defaults like <see cref="Load(string, out NestDefaultsStatus)"/>,
|
||||
/// but a missing file, an unusable file, or a missing/undefined unit
|
||||
/// field yields <paramref name="fallbackUnits"/> (the caller's existing
|
||||
/// unit preference) instead of the built-in units. Other valid fields
|
||||
/// still load. An undefined <paramref name="fallbackUnits"/> is ignored.
|
||||
/// </summary>
|
||||
public static NestDefaults Load(
|
||||
string path,
|
||||
Units fallbackUnits,
|
||||
out NestDefaultsStatus status
|
||||
)
|
||||
{
|
||||
var defaults = Fallback;
|
||||
if (Enum.IsDefined(fallbackUnits))
|
||||
defaults.Units = fallbackUnits;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
status = NestDefaultsStatus.Missing;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
NestDefaultsDto? dto;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json, JsonOptions);
|
||||
}
|
||||
catch (Exception ex) when (IsUnreadableFile(ex))
|
||||
{
|
||||
// A file that exists but cannot be read (locked, access-denied)
|
||||
// or parsed must never block creating a nest.
|
||||
status = NestDefaultsStatus.Invalid;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
if (dto is null)
|
||||
{
|
||||
status = NestDefaultsStatus.Invalid;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
status = NestDefaultsStatus.Ok;
|
||||
|
||||
if (TryParseUnits(dto.Units, out var units))
|
||||
defaults.Units = units;
|
||||
|
||||
if (
|
||||
dto.Size?.Width is { } width
|
||||
&& dto.Size.Length is { } length
|
||||
&& IsValidSize(width, length)
|
||||
)
|
||||
defaults.Size = new Size(width, length);
|
||||
|
||||
if (dto.Quadrant is { } quadrant && quadrant is >= 1 and <= 4)
|
||||
defaults.Quadrant = quadrant;
|
||||
|
||||
if (dto.PartSpacing is { } partSpacing && IsValidSpacing(partSpacing))
|
||||
defaults.PartSpacing = partSpacing;
|
||||
|
||||
if (
|
||||
dto.EdgeSpacing?.Left is { } left
|
||||
&& dto.EdgeSpacing.Bottom is { } bottom
|
||||
&& dto.EdgeSpacing.Right is { } right
|
||||
&& dto.EdgeSpacing.Top is { } top
|
||||
&& IsValidSpacing(left)
|
||||
&& IsValidSpacing(bottom)
|
||||
&& IsValidSpacing(right)
|
||||
&& IsValidSpacing(top)
|
||||
)
|
||||
defaults.EdgeSpacing = new Spacing(left, bottom, right, top);
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the current units and plate defaults from a nest.
|
||||
/// </summary>
|
||||
public static NestDefaults FromNest(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
var plate = nest.PlateDefaults;
|
||||
return new NestDefaults
|
||||
{
|
||||
Units = nest.Units,
|
||||
Size = plate.Size,
|
||||
Quadrant = plate.Quadrant,
|
||||
PartSpacing = plate.PartSpacing,
|
||||
EdgeSpacing = plate.EdgeSpacing,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures defaults from an existing plate (a copy of its size,
|
||||
/// quadrant, and spacing), e.g. the active plate in the desktop app.
|
||||
/// </summary>
|
||||
public static NestDefaults FromPlate(Units units, Plate plate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
return new NestDefaults
|
||||
{
|
||||
Units = units,
|
||||
Size = plate.Size,
|
||||
Quadrant = plate.Quadrant,
|
||||
PartSpacing = plate.PartSpacing,
|
||||
EdgeSpacing = plate.EdgeSpacing,
|
||||
};
|
||||
}
|
||||
|
||||
public void ApplyTo(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
nest.Units = Units;
|
||||
var plate = nest.PlateDefaults;
|
||||
plate.Size = Size;
|
||||
plate.Quadrant = Quadrant;
|
||||
plate.PartSpacing = PartSpacing;
|
||||
plate.EdgeSpacing = EdgeSpacing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the file (creating the parent directory), retrying briefly on
|
||||
/// IO collisions the same way <see cref="LocalJsonProvider"/> does.
|
||||
/// </summary>
|
||||
public void Save(string path, int maxRetries = 3)
|
||||
{
|
||||
var dto = new NestDefaultsDto
|
||||
{
|
||||
Version = CurrentVersion,
|
||||
Units = JsonSerializer.SerializeToElement(Units.ToString().ToLowerInvariant()),
|
||||
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
|
||||
Quadrant = Quadrant,
|
||||
PartSpacing = PartSpacing,
|
||||
EdgeSpacing = new SpacingDto
|
||||
{
|
||||
Left = EdgeSpacing.Left,
|
||||
Bottom = EdgeSpacing.Bottom,
|
||||
Right = EdgeSpacing.Right,
|
||||
Top = EdgeSpacing.Top,
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < maxRetries - 1)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts only a defined unit name (case-insensitive). Enum.TryParse
|
||||
/// would also accept numeric strings ("7") and comma-joined names,
|
||||
/// producing undefined or unintended values.
|
||||
/// </summary>
|
||||
private static bool TryParseUnits(JsonElement? element, out Units units)
|
||||
{
|
||||
units = default;
|
||||
if (element is not { ValueKind: JsonValueKind.String } value)
|
||||
return false;
|
||||
|
||||
var text = value.GetString();
|
||||
foreach (var candidate in Enum.GetValues<Units>())
|
||||
{
|
||||
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
units = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsUnreadableFile(Exception ex) =>
|
||||
ex
|
||||
is JsonException
|
||||
or IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or System.Security.SecurityException;
|
||||
|
||||
private static bool IsValidSize(double width, double length) =>
|
||||
!double.IsNaN(width)
|
||||
&& !double.IsNaN(length)
|
||||
&& !double.IsInfinity(width)
|
||||
&& !double.IsInfinity(length)
|
||||
&& width > 0
|
||||
&& length > 0;
|
||||
|
||||
private static bool IsValidSpacing(double value) =>
|
||||
!double.IsNaN(value) && !double.IsInfinity(value) && value >= 0;
|
||||
|
||||
/// <summary>
|
||||
/// Wire format. Every field is nullable so a partial file merges over
|
||||
/// the fallback field by field; unknown fields (including a future
|
||||
/// higher <c>version</c>) are ignored rather than rejected.
|
||||
/// </summary>
|
||||
private sealed record NestDefaultsDto
|
||||
{
|
||||
public int? Version { get; init; } = CurrentVersion;
|
||||
// Read as raw JSON so a wrongly typed unit falls back on its own
|
||||
// instead of failing the whole file.
|
||||
public JsonElement? Units { get; init; }
|
||||
public SizeDto? Size { get; init; }
|
||||
public int? Quadrant { get; init; }
|
||||
public double? PartSpacing { get; init; }
|
||||
public SpacingDto? EdgeSpacing { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SizeDto
|
||||
{
|
||||
public double? Width { get; init; }
|
||||
public double? Length { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SpacingDto
|
||||
{
|
||||
public double? Left { get; init; }
|
||||
public double? Bottom { get; init; }
|
||||
public double? Right { get; init; }
|
||||
public double? Top { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Plate and drawing rows for one saved nest, shown below the nest list in the
|
||||
/// Database-mode nest browser. The server stores only nest-level metadata, so these
|
||||
/// rows are built from the nest's downloaded archive.
|
||||
/// </summary>
|
||||
public sealed class NestDetails
|
||||
{
|
||||
public Units Units { get; init; }
|
||||
|
||||
/// <summary>One row per plate, in nest order.</summary>
|
||||
public IReadOnlyList<NestPlateDetail> Plates { get; init; } = Array.Empty<NestPlateDetail>();
|
||||
|
||||
/// <summary>One row per drawing (cutoffs excluded), in nest order.</summary>
|
||||
public IReadOnlyList<NestDrawingDetail> Drawings { get; init; } = Array.Empty<NestDrawingDetail>();
|
||||
|
||||
/// <summary>
|
||||
/// The plates themselves, in the same order as <see cref="Plates"/>, for the plate
|
||||
/// preview. They belong to the downloaded copy of the nest, never to an open document.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Plate> PlateLayouts { get; init; } = Array.Empty<Plate>();
|
||||
|
||||
public static NestDetails FromNest(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
|
||||
var plates = new List<NestPlateDetail>(nest.Plates.Count);
|
||||
var nested = new Dictionary<Drawing, int>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
for (var i = 0; i < nest.Plates.Count; i++)
|
||||
{
|
||||
var plate = nest.Plates[i];
|
||||
var parts = plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).ToList();
|
||||
foreach (var part in parts)
|
||||
nested[part.BaseDrawing] = nested.GetValueOrDefault(part.BaseDrawing) + plate.Quantity;
|
||||
|
||||
plates.Add(new NestPlateDetail(
|
||||
Number: i + 1,
|
||||
Duplicates: plate.Quantity,
|
||||
Width: plate.Size.Width,
|
||||
Length: plate.Size.Length,
|
||||
PartCount: parts.Count,
|
||||
DrawingCount: parts.Select(part => part.BaseDrawing).Distinct(ReferenceEqualityComparer.Instance).Count(),
|
||||
// A zero-size plate has no meaningful utilization; report 0 rather than NaN.
|
||||
Utilization: plate.Area() > 0 ? plate.Utilization() : 0));
|
||||
}
|
||||
|
||||
var drawings = nest.Drawings
|
||||
.Where(drawing => !drawing.IsCutOff)
|
||||
.Select(drawing => new NestDrawingDetail(
|
||||
Name: drawing.Name ?? "",
|
||||
Customer: drawing.Customer ?? "",
|
||||
Required: drawing.Quantity.Required,
|
||||
Nested: nested.GetValueOrDefault(drawing),
|
||||
Area: drawing.Area))
|
||||
.ToList();
|
||||
|
||||
return new NestDetails
|
||||
{
|
||||
Units = nest.Units,
|
||||
Plates = plates,
|
||||
Drawings = drawings,
|
||||
PlateLayouts = nest.Plates.ToList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <param name="Number">1-based position of the plate in the nest.</param>
|
||||
/// <param name="Duplicates">How many times the plate is cut (<see cref="Plate.Quantity"/>).</param>
|
||||
/// <param name="PartCount">Parts on one copy of the plate, cutoffs excluded.</param>
|
||||
/// <param name="Utilization">Fraction of the plate area used by parts, 0 to 1.</param>
|
||||
public sealed record NestPlateDetail(
|
||||
int Number,
|
||||
int Duplicates,
|
||||
double Width,
|
||||
double Length,
|
||||
int PartCount,
|
||||
int DrawingCount,
|
||||
double Utilization);
|
||||
|
||||
/// <param name="Nested">Parts placed across all plates, counting each plate's duplicates.</param>
|
||||
public sealed record NestDrawingDetail(string Name, string Customer, int Required, int Nested, double Area)
|
||||
{
|
||||
public int Remaining => System.Math.Max(0, Required - Nested);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the details of the nest highlighted in the Database-mode nest browser. Moving
|
||||
/// the highlight starts a new load and cancels the previous one; a superseded load's
|
||||
/// result or failure is discarded, so details never belong to a nest that is no longer
|
||||
/// highlighted. Also holds a small LRU cache of recently shown or <see cref="Prefetch"/>ed
|
||||
/// nests, so browsing back onto one, or onto one a caller warmed ahead of time, resolves
|
||||
/// without a server round trip. Call from one thread (the UI thread).
|
||||
/// </summary>
|
||||
public sealed class NestDetailsSession : IDisposable
|
||||
{
|
||||
// Bounds memory from cached plate/drawing geometry; generous enough for a caller's
|
||||
// prefetch window plus some scrollback before the oldest entries are evicted.
|
||||
private const int CacheCapacity = 25;
|
||||
|
||||
private readonly Func<Guid, CancellationToken, Task<NestDetails>> _load;
|
||||
private readonly Dictionary<Guid, NestDetails> _cache = new();
|
||||
private readonly List<Guid> _cacheOrder = new();
|
||||
private readonly Dictionary<Guid, CancellationTokenSource> _prefetches = new();
|
||||
private CancellationTokenSource? _pending;
|
||||
private int _generation;
|
||||
private bool _disposed;
|
||||
|
||||
/// <param name="load">
|
||||
/// Builds the details of one nest; throws when the nest cannot be read (for example
|
||||
/// when it no longer exists on the server).
|
||||
/// </param>
|
||||
public NestDetailsSession(Func<Guid, CancellationToken, Task<NestDetails>> load)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(load);
|
||||
_load = load;
|
||||
}
|
||||
|
||||
/// <summary>The nest of the latest load; null before the first load and after <see cref="Clear"/>.</summary>
|
||||
public Guid? NestId { get; private set; }
|
||||
|
||||
/// <summary>Details of <see cref="NestId"/>; null while loading, after a failure and after <see cref="Clear"/>.</summary>
|
||||
public NestDetails? Details { get; private set; }
|
||||
|
||||
/// <summary>True while the latest load is outstanding; superseded loads do not count.</summary>
|
||||
public bool IsLoading { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Loads one nest's details. Returns true when they were applied, false when a later
|
||||
/// load or <see cref="Clear"/> superseded this one. A failure of the latest load
|
||||
/// propagates; failures of superseded loads are ignored.
|
||||
/// </summary>
|
||||
public async Task<bool> LoadAsync(Guid id)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
// Supersede before cancelling: a load that completes synchronously when cancelled
|
||||
// must already see itself as superseded. Each source is released by its own load.
|
||||
var cancellation = Supersede();
|
||||
var generation = _generation;
|
||||
NestId = id;
|
||||
IsLoading = true;
|
||||
|
||||
try
|
||||
{
|
||||
NestDetails details;
|
||||
if (_cache.TryGetValue(id, out var cached))
|
||||
{
|
||||
Touch(id);
|
||||
details = cached;
|
||||
}
|
||||
else
|
||||
{
|
||||
details = await _load(id, cancellation.Token);
|
||||
Remember(id, details);
|
||||
}
|
||||
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
|
||||
Details = details;
|
||||
return true;
|
||||
}
|
||||
catch (Exception) when (generation != _generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_pending, cancellation))
|
||||
_pending = null;
|
||||
if (generation == _generation)
|
||||
IsLoading = false;
|
||||
cancellation.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts loading one nest's details in the background so a later <see cref="LoadAsync"/>
|
||||
/// for it resolves from the cache instead of the server. Skipped when the nest is already
|
||||
/// cached, already prefetching, or is the loaded/loading nest. Failures are swallowed here;
|
||||
/// an on-demand load surfaces the error if the nest is actually selected later.
|
||||
/// </summary>
|
||||
public void Prefetch(Guid id)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (id == NestId || _cache.ContainsKey(id) || _prefetches.ContainsKey(id))
|
||||
return;
|
||||
|
||||
var cancellation = new CancellationTokenSource();
|
||||
_prefetches[id] = cancellation;
|
||||
_ = PrefetchCoreAsync(id, cancellation);
|
||||
}
|
||||
|
||||
private async Task PrefetchCoreAsync(Guid id, CancellationTokenSource cancellation)
|
||||
{
|
||||
try
|
||||
{
|
||||
var details = await _load(id, cancellation.Token);
|
||||
if (!cancellation.IsCancellationRequested)
|
||||
Remember(id, details);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silent: this prefetch only mattered if the nest is later selected, and
|
||||
// LoadAsync's own on-demand fetch will surface the error then.
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_prefetches.GetValueOrDefault(id), cancellation))
|
||||
_prefetches.Remove(id);
|
||||
cancellation.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void Remember(Guid id, NestDetails details)
|
||||
{
|
||||
_cache[id] = details;
|
||||
Touch(id);
|
||||
while (_cacheOrder.Count > CacheCapacity)
|
||||
{
|
||||
var oldest = _cacheOrder[0];
|
||||
_cacheOrder.RemoveAt(0);
|
||||
_cache.Remove(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
private void Touch(Guid id)
|
||||
{
|
||||
_cacheOrder.Remove(id);
|
||||
_cacheOrder.Add(id);
|
||||
}
|
||||
|
||||
/// <summary>Forgets the current details and discards any load or prefetch in flight.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_generation++;
|
||||
var pending = _pending;
|
||||
_pending = null;
|
||||
NestId = null;
|
||||
Details = null;
|
||||
IsLoading = false;
|
||||
pending?.Cancel();
|
||||
foreach (var prefetch in _prefetches.Values)
|
||||
prefetch.Cancel();
|
||||
}
|
||||
|
||||
private CancellationTokenSource Supersede()
|
||||
{
|
||||
var previous = _pending;
|
||||
var cancellation = new CancellationTokenSource();
|
||||
_pending = cancellation;
|
||||
_generation++;
|
||||
Details = null;
|
||||
previous?.Cancel();
|
||||
return cancellation;
|
||||
}
|
||||
|
||||
/// <summary>Cancels any load in flight (which releases its own source when it ends).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
Clear();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Chooses the nest material when the Nest Info dialog is accepted. The dialog
|
||||
/// edits only the material name, so grade and density must come from the
|
||||
/// material the nest already has.
|
||||
/// </summary>
|
||||
public static class NestMaterialSelection
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a new material named <paramref name="selectedName"/>. When that
|
||||
/// name matches the current material's name by <see cref="SharedListNames.Key"/>,
|
||||
/// the current grade and density are kept; any other name gets a name-only
|
||||
/// material. The current material is never modified or returned.
|
||||
/// </summary>
|
||||
public static Material Apply(Material? current, string? selectedName)
|
||||
{
|
||||
var name = selectedName ?? "";
|
||||
|
||||
if (current != null && SharedListNames.Key(current.Name) == SharedListNames.Key(name))
|
||||
return new Material(name, current.Grade, current.Density);
|
||||
|
||||
return new Material(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>One page of saved-nest metadata returned by <c>GET /api/nests/query</c>.</summary>
|
||||
public sealed class NestPage
|
||||
{
|
||||
/// <summary>At most <see cref="Limit"/> matching records, in the requested order.</summary>
|
||||
public IReadOnlyList<NestRecord> Items { get; init; } = Array.Empty<NestRecord>();
|
||||
|
||||
/// <summary>Number of records matching the search across all pages.</summary>
|
||||
public int Total { get; init; }
|
||||
|
||||
public int Offset { get; init; }
|
||||
|
||||
public int Limit { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// One bounded page request for browsing saved nests (<c>GET /api/nests/query</c>).
|
||||
/// The server filters and pages in SQL; the client and server share these bounds.
|
||||
/// </summary>
|
||||
public sealed class NestQuery
|
||||
{
|
||||
public const int DefaultLimit = 100;
|
||||
public const int MaxLimit = 500;
|
||||
public const int MaxSearchLength = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive substring matched against name, customer, material, made by,
|
||||
/// comments and status (stored and display names). Blank means no filter.
|
||||
/// </summary>
|
||||
public string Search { get; init; } = "";
|
||||
|
||||
/// <summary>Column to order by; ties are broken by id in the same direction.</summary>
|
||||
public NestSortField Sort { get; init; } = NestSortField.SavedAt;
|
||||
|
||||
/// <summary>Largest/newest/Z first when true (the default, newest saved first).</summary>
|
||||
public bool Descending { get; init; } = true;
|
||||
|
||||
/// <summary>Number of matching records to skip, zero or greater.</summary>
|
||||
public int Offset { get; init; }
|
||||
|
||||
/// <summary>Maximum records returned, 1 to <see cref="MaxLimit"/>.</summary>
|
||||
public int Limit { get; init; } = DefaultLimit;
|
||||
|
||||
/// <summary>The search text as matched: trimmed, never null.</summary>
|
||||
public string NormalizedSearch => (Search ?? "").Trim();
|
||||
|
||||
/// <summary>The first violated bound, or null when the query may be sent.</summary>
|
||||
public string? GetValidationError()
|
||||
{
|
||||
if (!Enum.IsDefined(Sort))
|
||||
return "sort must be a supported column.";
|
||||
if (Offset < 0)
|
||||
return "offset must be zero or greater.";
|
||||
if (Limit < 1 || Limit > MaxLimit)
|
||||
return $"limit must be between 1 and {MaxLimit}.";
|
||||
if (NormalizedSearch.Length > MaxSearchLength)
|
||||
return $"search must be at most {MaxSearchLength} characters.";
|
||||
// SQLite LIKE stops at an embedded NUL, which would turn the rest of the text into a wildcard.
|
||||
if (NormalizedSearch.Contains('\0'))
|
||||
return "search must not contain NUL characters.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using OpenNest;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side metadata for one saved nest. The .nest archive itself is stored
|
||||
/// alongside; this record is what lists, filters and status tracking read.
|
||||
/// Counts are captured by the client at save time.
|
||||
/// </summary>
|
||||
public sealed class NestRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string Customer { get; set; } = "";
|
||||
|
||||
public DateTime DateCreated { get; set; }
|
||||
|
||||
public DateTime DateModified { get; set; }
|
||||
|
||||
public string Material { get; set; } = "";
|
||||
|
||||
public double Thickness { get; set; }
|
||||
|
||||
/// <summary>quote | toBeCut | hasBeenCut (camelCase on the wire, case-insensitive readers).</summary>
|
||||
public NestStatus Status { get; set; } = NestStatus.Quote;
|
||||
|
||||
public int PlateCount { get; set; }
|
||||
|
||||
public int PartCount { get; set; }
|
||||
|
||||
public string Comments { get; set; } = "";
|
||||
|
||||
public string MadeBy { get; set; } = "";
|
||||
|
||||
/// <summary>Size of the stored .nest archive in bytes.</summary>
|
||||
public long FileSize { get; set; }
|
||||
|
||||
/// <summary>When the server last stored the nest contents or metadata.</summary>
|
||||
public DateTime SavedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the shareable <see cref="NestRecord"/> metadata from a live <see cref="Nest"/>.
|
||||
/// Counts are computed here (not carried on <see cref="Nest"/> itself) so every caller
|
||||
/// that saves to the nest server gets the same definition of "plate" and "part" counts.
|
||||
/// </summary>
|
||||
public static class NestRecordFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a record for uploading <paramref name="nest"/>. <paramref name="id"/> is the
|
||||
/// client-tracked id: <see cref="System.Guid.Empty"/> for a first save (the server
|
||||
/// assigns one), or the previously returned id to update an existing record.
|
||||
/// <paramref name="fileSize"/> is the size of the serialized .nest archive being
|
||||
/// uploaded alongside this record; the server also recomputes it independently.
|
||||
/// </summary>
|
||||
public static NestRecord FromNest(Nest nest, System.Guid id, long fileSize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
|
||||
return new NestRecord
|
||||
{
|
||||
Id = id,
|
||||
Name = nest.Name ?? "",
|
||||
Customer = nest.Customer ?? "",
|
||||
DateCreated = nest.DateCreated,
|
||||
DateModified = nest.DateLastModified,
|
||||
Material = nest.Material?.Name ?? "",
|
||||
Thickness = nest.Thickness,
|
||||
Status = nest.Status,
|
||||
PlateCount = nest.Plates.Count,
|
||||
PartCount = nest.Plates.Sum(CountNonCutoffParts),
|
||||
// Nest has no separate "comments" field; Notes is the closest analog
|
||||
// shown in the nest info dialog, so it round-trips as Comments here.
|
||||
Comments = nest.Notes ?? "",
|
||||
MadeBy = nest.MadeBy ?? "",
|
||||
FileSize = fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountNonCutoffParts(Plate plate) =>
|
||||
plate.Parts.Count(part => !part.BaseDrawing.IsCutOff);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks a document's database identity, bound to a particular server address.
|
||||
/// A failed upload leaves that identity untouched. Switching servers creates a new
|
||||
/// record rather than accidentally updating an unrelated record with the same id.
|
||||
/// </summary>
|
||||
public sealed class NestSaveSession
|
||||
{
|
||||
public Guid RemoteId { get; private set; }
|
||||
public string ServerUrl { get; private set; } = "";
|
||||
|
||||
public void Bind(Guid id, string serverUrl)
|
||||
{
|
||||
if (id == Guid.Empty)
|
||||
throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id));
|
||||
RemoteId = id;
|
||||
ServerUrl = Normalize(serverUrl);
|
||||
}
|
||||
|
||||
public async Task<NestRecord> SaveAsync(
|
||||
INestRepository repository, string serverUrl, Nest nest, byte[] archive,
|
||||
bool saveCopy = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
ArgumentNullException.ThrowIfNull(archive);
|
||||
|
||||
var normalizedUrl = Normalize(serverUrl);
|
||||
if (normalizedUrl.Length == 0)
|
||||
throw new ArgumentException("A database server URL is required.", nameof(serverUrl));
|
||||
|
||||
var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty;
|
||||
var record = NestRecordFactory.FromNest(nest, id, archive.LongLength);
|
||||
var saved = id == Guid.Empty
|
||||
? await repository.UploadAsync(archive, record, cancellationToken)
|
||||
: await repository.UpdateFileAsync(id, archive, record, cancellationToken);
|
||||
if (saved.Id == Guid.Empty || (id != Guid.Empty && saved.Id != id))
|
||||
throw new InvalidDataException("Server returned an invalid nest id.");
|
||||
|
||||
Bind(saved.Id, normalizedUrl);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>Columns a saved-nest browse query may order by (camelCase on the wire).</summary>
|
||||
public enum NestSortField
|
||||
{
|
||||
SavedAt,
|
||||
Name,
|
||||
Customer,
|
||||
Status,
|
||||
Material,
|
||||
DateCreated,
|
||||
DateModified,
|
||||
Thickness,
|
||||
PlateCount,
|
||||
PartCount,
|
||||
MadeBy,
|
||||
Comments,
|
||||
FileSize,
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>Where the desktop app persists nests: local .nest files or the shared nest server.</summary>
|
||||
public enum NestStorageMode
|
||||
{
|
||||
File,
|
||||
Database,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage-mode toggle and nest-server address, stored at %APPDATA%\OpenNest\storage.json.
|
||||
/// Loading never throws; a missing or corrupt file means File mode so existing installs
|
||||
/// keep the original behavior until the operator opts in.
|
||||
/// </summary>
|
||||
public sealed class NestStorageSettings
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public NestStorageMode Mode { get; set; } = NestStorageMode.File;
|
||||
|
||||
/// <summary>Base URL of the nest server, e.g. http://barge.lan:8090. No trailing slash required.</summary>
|
||||
public string ServerUrl { get; set; } = "";
|
||||
|
||||
/// <summary>True when Database mode is active and a server URL is configured.</summary>
|
||||
public bool IsDatabaseMode =>
|
||||
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
|
||||
|
||||
/// <summary>%APPDATA%\OpenNest\storage.json.</summary>
|
||||
public static string DefaultPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenNest", "storage.json");
|
||||
|
||||
public static NestStorageSettings Load(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return new();
|
||||
|
||||
try
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<NestStorageSettings>(
|
||||
File.ReadAllText(path), JsonOptions) ?? new();
|
||||
settings.ServerUrl = NormalizeUrl(settings.ServerUrl);
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes camelCase JSON, creating the parent directory and retrying IO collisions.</summary>
|
||||
public void Save(string path)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new NestStorageSettings { Mode = Mode, ServerUrl = NormalizeUrl(ServerUrl) },
|
||||
JsonOptions);
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
|
||||
for (var attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < 2)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeUrl(string? url) => (url ?? "").Trim().TrimEnd('/');
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Talks to the central OpenNest nest server over HTTP. Uploads are multipart:
|
||||
/// a "metadata" JSON part and a "file" part holding the .nest archive.
|
||||
/// Connection and HTTP failures surface as HttpRequestException/IOException to the caller.
|
||||
/// </summary>
|
||||
public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsClient;
|
||||
private readonly Uri _baseUri;
|
||||
|
||||
public RemoteNestRepository(string baseUrl)
|
||||
: this(new HttpClient(), baseUrl)
|
||||
{
|
||||
_ownsClient = true;
|
||||
}
|
||||
|
||||
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
|
||||
public RemoteNestRepository(HttpClient httpClient, string baseUrl)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsClient = false;
|
||||
var url = (baseUrl ?? "").Trim().TrimEnd('/');
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var baseUri)
|
||||
|| (baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new ArgumentException($"Invalid nest server URL: '{baseUrl}'", nameof(baseUrl));
|
||||
}
|
||||
// Keep the trailing slash so relative combines replace no path segment.
|
||||
_baseUri = new Uri(baseUri.AbsoluteUri.TrimEnd('/') + "/");
|
||||
}
|
||||
|
||||
private Uri Url(string relative) => new(_baseUri, relative);
|
||||
|
||||
public async Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(Url("api/nests"), cancellationToken);
|
||||
await EnsureSuccess(response, "list nests", cancellationToken);
|
||||
var items = await response.Content
|
||||
.ReadFromJsonAsync<List<NestRecord>>(JsonOptions, cancellationToken);
|
||||
return items ?? new List<NestRecord>();
|
||||
}
|
||||
|
||||
public async Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
var error = query.GetValidationError();
|
||||
if (error is not null)
|
||||
throw new ArgumentException(error, nameof(query));
|
||||
|
||||
using var response = await _httpClient.GetAsync(
|
||||
Url("api/nests/query?" + BuildQueryString(query)), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
throw new IOException(
|
||||
"This nest server does not support filtered browsing (HTTP 404). Update the nest server to this OpenNest version.");
|
||||
}
|
||||
|
||||
await EnsureSuccess(response, "query nests", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<NestPage>(JsonOptions, cancellationToken)
|
||||
?? throw new IOException("Nest server returned an empty page.");
|
||||
if (page.Items.Count > query.Limit)
|
||||
{
|
||||
throw new IOException(
|
||||
$"Nest server returned {page.Items.Count} records for a page limited to {query.Limit}.");
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
private static string BuildQueryString(NestQuery query) =>
|
||||
"search=" + Uri.EscapeDataString(query.NormalizedSearch)
|
||||
+ "&sort=" + JsonNamingPolicy.CamelCase.ConvertName(query.Sort.ToString())
|
||||
+ "&order=" + (query.Descending ? "desc" : "asc")
|
||||
+ "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture)
|
||||
+ "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
await EnsureSuccess(response, $"get nest {id}", cancellationToken);
|
||||
return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/file"), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
await EnsureSuccess(response, $"download nest {id}", cancellationToken);
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NestRecord> UploadAsync(
|
||||
byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = BuildMultipart(nestFile, record);
|
||||
using var response = await _httpClient.PostAsync(Url("api/nests"), content, cancellationToken);
|
||||
await EnsureSuccess(response, $"upload nest '{record.Name}'", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NestRecord> UpdateFileAsync(
|
||||
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = BuildMultipart(nestFile, record);
|
||||
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/file"), content, cancellationToken);
|
||||
await EnsureSuccess(response, $"update nest {id}", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NestRecord> UpdateMetadataAsync(
|
||||
Guid id, NestRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
|
||||
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/metadata"), content, cancellationToken);
|
||||
await EnsureSuccess(response, $"update nest metadata {id}", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _httpClient.DeleteAsync(Url($"api/nests/{id}"), cancellationToken);
|
||||
await EnsureSuccess(response, $"delete nest {id}", cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsClient)
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
|
||||
private static MultipartFormDataContent BuildMultipart(byte[] nestFile, NestRecord record)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
var metadata = new StringContent(
|
||||
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
|
||||
content.Add(metadata, "metadata");
|
||||
var file = new ByteArrayContent(nestFile);
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
|
||||
content.Add(file, "file", record.Name.Length > 0 ? $"{record.Name}.nest" : "nest.nest");
|
||||
return content;
|
||||
}
|
||||
|
||||
private static async Task<NestRecord> ReadRecord(
|
||||
HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var record = await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
|
||||
return record ?? throw new IOException("Nest server returned an empty record.");
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccess(
|
||||
HttpResponseMessage response, string action, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
|
||||
var detail = "";
|
||||
try
|
||||
{
|
||||
detail = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidOperationException)
|
||||
{
|
||||
// The status code is the useful part; the body is best-effort.
|
||||
}
|
||||
|
||||
if (detail.Length > 200)
|
||||
detail = detail[..200] + "…";
|
||||
throw new IOException(
|
||||
$"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized matching key for names in company lists (customers, materials).
|
||||
/// </summary>
|
||||
public static class SharedListNames
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the name trimmed, with internal whitespace runs collapsed to one
|
||||
/// space, in invariant upper case. Names that differ only in spacing or case
|
||||
/// share a key. A null or blank name has the empty key.
|
||||
/// </summary>
|
||||
public static string Key(string? name) =>
|
||||
name == null
|
||||
? ""
|
||||
: string.Join(' ', name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
|
||||
.ToUpperInvariant();
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Drawing;
|
||||
using OpenNest.Engine.CirclePacking;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.CirclePacking;
|
||||
|
||||
public class ItemCloneTests
|
||||
{
|
||||
[Fact]
|
||||
public void Clone_FromItemReference_ReturnsItem()
|
||||
{
|
||||
var item = CreateItem();
|
||||
|
||||
var clone = item.Clone();
|
||||
|
||||
Assert.IsType<Item>(clone);
|
||||
Assert.NotSame(item, clone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clone_FromCircleReference_ReturnsItem()
|
||||
{
|
||||
var item = CreateItem();
|
||||
var circle = (Circle)item;
|
||||
|
||||
var clone = circle.Clone();
|
||||
|
||||
Assert.IsType<Item>(clone);
|
||||
Assert.NotSame(item, clone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clone_FromEntityReference_ReturnsItem()
|
||||
{
|
||||
var item = CreateItem();
|
||||
var entity = (Entity)item;
|
||||
|
||||
var clone = entity.Clone();
|
||||
|
||||
Assert.IsType<Item>(clone);
|
||||
Assert.NotSame(item, clone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clone_PreservesPackingIdAndCreatesNewGeometryId()
|
||||
{
|
||||
var item = CreateItem();
|
||||
|
||||
var clone = Assert.IsType<Item>(item.Clone());
|
||||
|
||||
Assert.Equal(item.PackingId, clone.PackingId);
|
||||
Assert.NotEqual(Guid.Empty, ((Entity)clone).Id);
|
||||
Assert.NotEqual(((Entity)item).Id, ((Entity)clone).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clone_PreservesRotation()
|
||||
{
|
||||
var item = CreateItem();
|
||||
|
||||
var clone = Assert.IsType<Item>(item.Clone());
|
||||
|
||||
Assert.Equal(item.Rotation, clone.Rotation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clone_PreservesEntityMetadata()
|
||||
{
|
||||
var item = CreateItem();
|
||||
|
||||
var clone = Assert.IsType<Item>(item.Clone());
|
||||
|
||||
Assert.Equal(item.Color, clone.Color);
|
||||
Assert.Same(item.Layer, clone.Layer);
|
||||
Assert.Equal(item.LineTypeName, clone.LineTypeName);
|
||||
Assert.Equal(item.IsVisible, clone.IsVisible);
|
||||
Assert.Equal(item.Tag, clone.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clone_HasIndependentGeometryAndBounds()
|
||||
{
|
||||
var item = CreateItem();
|
||||
var sourceCenter = item.Center;
|
||||
var sourceRadius = item.Radius;
|
||||
var sourceBounds = (item.Left, item.Bottom, item.Right, item.Top);
|
||||
|
||||
var clone = Assert.IsType<Item>(item.Clone());
|
||||
|
||||
Assert.Equal(sourceCenter, clone.Center);
|
||||
Assert.Equal(sourceRadius, clone.Radius);
|
||||
Assert.Equal(sourceBounds, (clone.Left, clone.Bottom, clone.Right, clone.Top));
|
||||
Assert.NotSame(item.BoundingBox, clone.BoundingBox);
|
||||
|
||||
var cloneCenter = clone.Center;
|
||||
cloneCenter.X += 30;
|
||||
cloneCenter.Y -= 10;
|
||||
clone.Center = cloneCenter;
|
||||
|
||||
Assert.Equal(sourceCenter, item.Center);
|
||||
Assert.Equal(sourceBounds, (item.Left, item.Bottom, item.Right, item.Top));
|
||||
Assert.Equal(cloneCenter, clone.Center);
|
||||
Assert.Equal(sourceRadius, clone.Radius);
|
||||
Assert.NotEqual(sourceBounds, (clone.Left, clone.Bottom, clone.Right, clone.Top));
|
||||
Assert.Equal(
|
||||
(cloneCenter.X - sourceRadius, cloneCenter.Y - sourceRadius,
|
||||
cloneCenter.X + sourceRadius, cloneCenter.Y + sourceRadius),
|
||||
(clone.Left, clone.Bottom, clone.Right, clone.Top));
|
||||
|
||||
clone.Radius += 2;
|
||||
|
||||
Assert.Equal(sourceRadius, item.Radius);
|
||||
Assert.Equal(sourceBounds, (item.Left, item.Bottom, item.Right, item.Top));
|
||||
Assert.Equal(
|
||||
(cloneCenter.X - clone.Radius, cloneCenter.Y - clone.Radius,
|
||||
cloneCenter.X + clone.Radius, cloneCenter.Y + clone.Radius),
|
||||
(clone.Left, clone.Bottom, clone.Right, clone.Top));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clone_Twice_CreatesDistinctGeometryIds()
|
||||
{
|
||||
var item = CreateItem();
|
||||
|
||||
var first = (Entity)Assert.IsType<Item>(item.Clone());
|
||||
var second = (Entity)Assert.IsType<Item>(item.Clone());
|
||||
|
||||
Assert.NotEqual(Guid.Empty, first.Id);
|
||||
Assert.NotEqual(Guid.Empty, second.Id);
|
||||
Assert.NotEqual(((Entity)item).Id, first.Id);
|
||||
Assert.NotEqual(((Entity)item).Id, second.Id);
|
||||
Assert.NotEqual(first.Id, second.Id);
|
||||
}
|
||||
|
||||
private static Item CreateItem() => new Item
|
||||
{
|
||||
PackingId = 42,
|
||||
Radius = 5,
|
||||
Center = new Vector(10, 20),
|
||||
Rotation = RotationType.CCW,
|
||||
Color = Color.CornflowerBlue,
|
||||
Layer = new Layer("packing"),
|
||||
LineTypeName = "Dashed",
|
||||
IsVisible = false,
|
||||
Tag = "packing-item",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Engine.Tests.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests;
|
||||
|
||||
public class CutOffBoundedFillTests
|
||||
{
|
||||
public static IEnumerable<object[]> Cases()
|
||||
{
|
||||
foreach (var strategy in PlateFillService.BuiltInStrategies)
|
||||
foreach (var axis in new[] { CutOffAxis.Vertical, CutOffAxis.Horizontal })
|
||||
foreach (var quadrant in new[] { 1, 3 })
|
||||
foreach (var farSide in new[] { false, true })
|
||||
yield return new object[] { strategy, axis, quadrant, farSide };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Cases))]
|
||||
public void FillItemCannotCrossSelectedCutOffBoundary(
|
||||
string strategy, CutOffAxis axis, int quadrant, bool farSide)
|
||||
{
|
||||
var plate = new Plate(new Size(20, 20)) { Quadrant = quadrant, PartSpacing = 0.5 };
|
||||
var bounds = plate.WorkArea();
|
||||
var position = new Vector(bounds.Left + 10, bounds.Bottom + 10);
|
||||
plate.CutOffs.Add(new CutOff(position, axis));
|
||||
// Independent expected selection box: one side of the cutoff, with spacing.
|
||||
var area = axis == CutOffAxis.Vertical
|
||||
? new Box(farSide ? position.X + 0.5 : bounds.Left, bounds.Bottom, 9.5, bounds.Width)
|
||||
: new Box(bounds.Left, farSide ? position.Y + 0.5 : bounds.Bottom, bounds.Length, 9.5);
|
||||
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(3, 3));
|
||||
var parts = PlateFillService.FillItem(strategy, plate,
|
||||
new NestItem { Drawing = drawing }, area, null, CancellationToken.None);
|
||||
|
||||
Assert.NotEmpty(parts);
|
||||
Assert.Empty(plate.Parts);
|
||||
Assert.All(parts, part =>
|
||||
{
|
||||
Assert.Same(drawing, part.BaseDrawing);
|
||||
var box = part.BoundingBox;
|
||||
Assert.True(box.Left >= area.Left - 1e-6 && box.Right <= area.Right + 1e-6);
|
||||
Assert.True(box.Bottom >= area.Bottom - 1e-6 && box.Top <= area.Top + 1e-6);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Fill;
|
||||
|
||||
/// <summary>
|
||||
/// PlateView spacing expander: grows part-to-part spacing with the work area
|
||||
/// and non-selected parts as hard boundaries.
|
||||
/// </summary>
|
||||
public class ExpanderTests
|
||||
{
|
||||
private static Program Rectangle(double width = 4, double length = 4)
|
||||
{
|
||||
var program = new Program();
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(width, 0);
|
||||
program.LineTo(width, length);
|
||||
program.LineTo(0, length);
|
||||
program.LineTo(0, 0);
|
||||
return program;
|
||||
}
|
||||
|
||||
private static Part AddSquare(Plate plate, double x, double y, double size = 4)
|
||||
{
|
||||
var part = new Part(new Drawing($"sq{plate.Parts.Count}", Rectangle(size, size)), new Vector(x, y));
|
||||
plate.Parts.Add(part);
|
||||
return part;
|
||||
}
|
||||
|
||||
private static Plate MakePlate(double lengthX, double widthY, double edge = 0.5)
|
||||
{
|
||||
var plate = new Plate(new Size(widthY, lengthX));
|
||||
plate.EdgeSpacing = new Spacing(edge, edge);
|
||||
return plate;
|
||||
}
|
||||
|
||||
/// <summary>Independent clearance oracle: naive vertex/segment min distance over raw part lines.</summary>
|
||||
private static double BruteClearance(Part a, Part b)
|
||||
{
|
||||
var linesA = PartGeometry.GetPartLines(a);
|
||||
var linesB = PartGeometry.GetPartLines(b);
|
||||
|
||||
double min = double.MaxValue;
|
||||
foreach (var la in linesA)
|
||||
foreach (var lb in linesB)
|
||||
{
|
||||
min = System.Math.Min(min, PointSegment(a, la.StartPoint, lb));
|
||||
min = System.Math.Min(min, PointSegment(a, la.EndPoint, lb));
|
||||
min = System.Math.Min(min, PointSegment(b, lb.StartPoint, la));
|
||||
min = System.Math.Min(min, PointSegment(b, lb.EndPoint, la));
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
private static double PointSegment(Part owner, Vector pt, Line seg)
|
||||
{
|
||||
var d = seg.EndPoint - seg.StartPoint;
|
||||
var len2 = d.DotProduct(d);
|
||||
var t = len2 <= 1e-12 ? 0 : System.Math.Clamp((pt - seg.StartPoint).DotProduct(d) / len2, 0, 1);
|
||||
return pt.DistanceTo(seg.StartPoint + d * t);
|
||||
}
|
||||
|
||||
private static void AssertNoOverlaps(Plate plate)
|
||||
{
|
||||
for (var i = 0; i < plate.Parts.Count; i++)
|
||||
for (var j = i + 1; j < plate.Parts.Count; j++)
|
||||
Assert.False(
|
||||
plate.Parts[i].Intersects(plate.Parts[j], out _),
|
||||
$"{plate.Parts[i].BaseDrawing.Name} overlaps {plate.Parts[j].BaseDrawing.Name}"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_TwoSquares_GrowUntilEdgeFloor_AndAnchorStaysPut()
|
||||
{
|
||||
var plate = MakePlate(24, 24);
|
||||
var a = AddSquare(plate, 6, 10);
|
||||
var b = AddSquare(plate, 14, 10);
|
||||
|
||||
var result = Expander.Expand(new List<Part> { a, b }, plate);
|
||||
|
||||
// Max gap: B flush against the right edge floor (23.5): 23.5 - 14 - 4 + gap base...
|
||||
// A stays (anchor); B slides to x=19.5 -> gap 9.5.
|
||||
Assert.Equal(6, a.Location.X, 6);
|
||||
Assert.Equal(10, a.Location.Y, 6);
|
||||
Assert.Equal(9.5, b.Location.X - (a.Location.X + 4), 1);
|
||||
Assert.True(result.AchievedSpacing >= 9.4, $"achieved {result.AchievedSpacing}");
|
||||
Assert.True(result.AchievedSpacing <= 9.6, $"achieved {result.AchievedSpacing}");
|
||||
AssertNoOverlaps(plate);
|
||||
Assert.True(b.BoundingBox.Right <= 23.5 + 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_SandwichedBetweenWalls_ConvergesOnlyToInitialGaps_AndKeepsWalls()
|
||||
{
|
||||
var plate = MakePlate(26, 10, edge: 0.0);
|
||||
var wallL = AddSquare(plate, 0, 3);
|
||||
var wallR = AddSquare(plate, 18, 3);
|
||||
var a = AddSquare(plate, 6, 3);
|
||||
var b = AddSquare(plate, 12, 3);
|
||||
|
||||
var result = Expander.Expand(new List<Part> { a, b }, plate);
|
||||
|
||||
// Every gap starts at exactly 2.0; straight separation moves cannot open
|
||||
// the row (opening one gap costs another), so the run stays at ~2.0.
|
||||
Assert.Equal(0, wallL.Location.X, 6);
|
||||
Assert.Equal(18, wallR.Location.X, 6);
|
||||
Assert.True(
|
||||
result.AchievedSpacing >= 1.9 && result.AchievedSpacing <= 2.05,
|
||||
$"achieved {result.AchievedSpacing}"
|
||||
);
|
||||
AssertNoOverlaps(plate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_OverlappingPair_SeparatesAndClearsOverlap()
|
||||
{
|
||||
var plate = MakePlate(30, 12);
|
||||
var a = AddSquare(plate, 5, 4);
|
||||
var b = AddSquare(plate, 7, 4); // 2.0 overlap in X
|
||||
|
||||
var result = Expander.Expand(new List<Part> { a, b }, plate, new Expander.Options
|
||||
{
|
||||
InitialStep = 0.5,
|
||||
MaxSpacing = 3,
|
||||
});
|
||||
|
||||
Assert.False(a.Intersects(b, out _));
|
||||
var gap = b.Location.X - (a.Location.X + 4);
|
||||
Assert.True(gap >= 2.99, $"gap {gap}");
|
||||
Assert.True(result.AchievedSpacing >= 2.9);
|
||||
AssertNoOverlaps(plate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Separate_PinnedPart_ReportsViolationsWithoutOverlap()
|
||||
{
|
||||
var plate = MakePlate(20, 20, edge: 0.0);
|
||||
var pinned = AddSquare(plate, 8, 8);
|
||||
// Walls 0.2 clear on all four sides.
|
||||
var left = AddSquare(plate, 3.8, 8);
|
||||
var right = AddSquare(plate, 12.2, 8);
|
||||
var bottom = AddSquare(plate, 8, 3.8);
|
||||
var top = AddSquare(plate, 8, 12.2);
|
||||
|
||||
var (converged, positions, violations) = Expander.Separate(
|
||||
new List<Part> { pinned },
|
||||
plate,
|
||||
spacing: 1.0
|
||||
);
|
||||
|
||||
Assert.False(converged);
|
||||
Assert.NotEmpty(violations);
|
||||
// The pinned part may slide into the walls but never through them.
|
||||
AssertNoOverlaps(plate);
|
||||
Assert.Equal(3.8, left.Location.X, 6);
|
||||
Assert.Equal(12.2, right.Location.X, 6);
|
||||
Assert.Equal(3.8, bottom.Location.Y, 6);
|
||||
Assert.Equal(12.2, top.Location.Y, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_CancelledBeforeRun_LeavesEverythingInPlace()
|
||||
{
|
||||
var plate = MakePlate(24, 24);
|
||||
var a = AddSquare(plate, 6, 10);
|
||||
var b = AddSquare(plate, 14, 10);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var result = Expander.Expand(
|
||||
new List<Part> { a, b },
|
||||
plate,
|
||||
token: cts.Token
|
||||
);
|
||||
|
||||
Assert.True(result.Cancelled);
|
||||
Assert.Equal(6, a.Location.X, 6);
|
||||
Assert.Equal(14, b.Location.X, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_ThreeInRow_FirstSelectedNeverMoves_AndOracleConfirmsSpacing()
|
||||
{
|
||||
var plate = MakePlate(60, 14);
|
||||
var a = AddSquare(plate, 5, 5);
|
||||
var b = AddSquare(plate, 10, 5);
|
||||
var c = AddSquare(plate, 15, 5);
|
||||
|
||||
var result = Expander.Expand(
|
||||
new List<Part> { a, b, c },
|
||||
plate,
|
||||
new Expander.Options { MaxSpacing = 8 }
|
||||
);
|
||||
|
||||
Assert.Equal(5, a.Location.X, 6); // anchor: never the later index of any pair
|
||||
Assert.True(result.AchievedSpacing >= 7.9);
|
||||
|
||||
// Independent oracle: every pair clears the reported spacing.
|
||||
var parts = new List<Part> { a, b, c };
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var clearance = BruteClearance(parts[i], parts[j]);
|
||||
Assert.True(
|
||||
clearance >= result.AchievedSpacing - 0.01,
|
||||
$"{parts[i].BaseDrawing.Name}/{parts[j].BaseDrawing.Name}: oracle {clearance} < reported {result.AchievedSpacing}"
|
||||
);
|
||||
}
|
||||
AssertNoOverlaps(plate);
|
||||
}
|
||||
|
||||
private static Program RectangleWithHole(
|
||||
double width,
|
||||
double length,
|
||||
double hx,
|
||||
double hy,
|
||||
double hw,
|
||||
double hh
|
||||
)
|
||||
{
|
||||
var program = Rectangle(width, length);
|
||||
program.MoveTo(hx, hy);
|
||||
program.LineTo(hx + hw, hy);
|
||||
program.LineTo(hx + hw, hy + hh);
|
||||
program.LineTo(hx, hy + hh);
|
||||
program.LineTo(hx, hy);
|
||||
return program;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hole-subtracting overlap check matching NestValidator semantics (a part in
|
||||
/// a cutout is legal). Part.Intersects is perimeter-only, so it cannot
|
||||
/// certify part-in-cutout layouts.
|
||||
/// </summary>
|
||||
private static bool MateriallyOverlaps(Part a, Part b)
|
||||
{
|
||||
var (outerA, holesA) = Rings(a);
|
||||
var (outerB, holesB) = Rings(b);
|
||||
return Collision.HasOverlap(outerA, outerB, holesA, holesB);
|
||||
}
|
||||
|
||||
private static (Polygon Outer, List<Polygon> Holes) Rings(Part part)
|
||||
{
|
||||
var entities = OpenNest.Converters.ConvertProgram
|
||||
.ToGeometry(part.Program)
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
.ToList();
|
||||
var profile = new ShapeProfile(entities);
|
||||
|
||||
var outer = profile.Perimeter.ToPolygonWithTolerance(0.001);
|
||||
outer.Offset(part.Location);
|
||||
|
||||
var holes = new List<Polygon>();
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
var hole = cutout.ToPolygonWithTolerance(0.001);
|
||||
hole.Offset(part.Location);
|
||||
holes.Add(hole);
|
||||
}
|
||||
|
||||
return (outer, holes.Count == 0 ? null : holes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_PartInsideCutout_KeepsLegalAndClearsHoleWalls()
|
||||
{
|
||||
var plate = MakePlate(40, 24);
|
||||
|
||||
// Wall part with a 10x10 cutout; a small selected part sits inside it.
|
||||
var wall = new Part(
|
||||
new Drawing(
|
||||
"wall",
|
||||
RectangleWithHole(20, 20, 5, 5, 10, 10)
|
||||
),
|
||||
new Vector(0, 0)
|
||||
);
|
||||
plate.Parts.Add(wall);
|
||||
|
||||
var inside = new Part(new Drawing("inside", Rectangle(2, 2)), new Vector(9, 9));
|
||||
var other = new Part(new Drawing("other", Rectangle(2, 2)), new Vector(30, 9));
|
||||
plate.Parts.Add(inside);
|
||||
plate.Parts.Add(other);
|
||||
|
||||
var result = Expander.Expand(
|
||||
new List<Part> { inside, other },
|
||||
plate,
|
||||
new Expander.Options { MaxSpacing = 2 }
|
||||
);
|
||||
|
||||
// Part-in-cutout is legal, never a material overlap.
|
||||
Assert.False(MateriallyOverlaps(inside, wall));
|
||||
Assert.False(MateriallyOverlaps(other, wall));
|
||||
Assert.False(MateriallyOverlaps(inside, other));
|
||||
Assert.True(result.AchievedSpacing >= 1.9);
|
||||
|
||||
// The part that started in the cutout must clear the hole walls too.
|
||||
var holeLeft = 5;
|
||||
var holeRight = 15;
|
||||
var gapLeft = inside.Location.X - holeLeft;
|
||||
var gapRight = holeRight - (inside.Location.X + 2);
|
||||
var gapBottom = inside.Location.Y - holeLeft;
|
||||
var gapTop = holeRight - (inside.Location.Y + 2);
|
||||
var minGap = System.Math.Min(
|
||||
System.Math.Min(gapLeft, gapRight),
|
||||
System.Math.Min(gapBottom, gapTop)
|
||||
);
|
||||
Assert.True(minGap >= 1.9, $"closest hole-wall gap {minGap}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_DegenerateInputs_Throw()
|
||||
{
|
||||
var plate = MakePlate(10, 10);
|
||||
var a = AddSquare(plate, 1, 1);
|
||||
var stranger = new Part(new Drawing("stranger", Rectangle()), new Vector(50, 50));
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part>(), plate));
|
||||
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part> { a }, plate));
|
||||
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part> { a, stranger }, plate));
|
||||
Assert.Throws<ArgumentNullException>(() => Expander.Expand(new List<Part> { a, a }, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance()
|
||||
{
|
||||
// Selection must not be pushed to open gaps between parts it excludes.
|
||||
var plate = MakePlate(40, 12);
|
||||
var w1 = AddSquare(plate, 2, 4);
|
||||
var w2 = AddSquare(plate, 6.5, 4); // 0.5 apart from w1, both unselected
|
||||
var a = AddSquare(plate, 14, 4);
|
||||
var b = AddSquare(plate, 20, 4);
|
||||
|
||||
Expander.Expand(new List<Part> { a, b }, plate);
|
||||
|
||||
Assert.Equal(2, w1.Location.X, 6);
|
||||
Assert.Equal(6.5, w2.Location.X, 6);
|
||||
Assert.True(a.Intersects(w1, out _) == false);
|
||||
Assert.True(b.Intersects(w2, out _) == false);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 523 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user